Skip to content

feat(runner-policy): register v0.14.2 contracts for five ci-workflows reusables - #382

Merged
kyle-sexton merged 1 commit into
mainfrom
feat/runner-policy-v0.14.2-contracts
Aug 14, 2026
Merged

feat(runner-policy): register v0.14.2 contracts for five ci-workflows reusables#382
kyle-sexton merged 1 commit into
mainfrom
feat/runner-policy-v0.14.2-contracts

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Registers reviewed approvedReusableWorkflowContracts entries at
7107b34832a7b6db5d08d3b132621c599fbe5e50 (v0.14.2) for the five
melodic-software/ci-workflows reusables downstream repositories consume but
standards does not, so those repositories can converge. Additive only — no
existing entry is removed or modified, no schema, validator, or check is
relaxed. components/runner-policy/README.md gains the per-revision review
narrative this registry documents every registered revision with.

All five are included. Nothing was excluded.

The derived contracts

Each was derived from the reusable's actual definition at 7107b34 (fetched
from melodic-software/ci-workflows via the contents API) and compared against
the entry for the SHA it is pinned at today.

Reusable New allowedInputs Difference from the outgoing entry
link-check.yml runner, args, issue-title, issue-labels, issue-type, auto-close none — term-for-term identical to @90f1c54, including allowedCallerPermissions: {contents: read, issues: write}
semantic-pr.yml runner, prerequisite-result none — term-for-term identical to @380612a
do-not-merge-gate.yml runner, prerequisite-result, label none — term-for-term identical to @380612a
pr-issue-linkage.yml runner, prerequisite-result, exempt-authors none — term-for-term identical to @e944387
zizmor.yml runner, paths, fail-on-severity vs @90f1c54 (runner, paths) adds fail-on-severity; identical to @31a5b76, which standards and claude-code-plugins already pin and pass

All five use routing: runner-input / runnerInput: runner with
allowedSecrets: {} (none of the five declares secrets:).
selectorResultInput: prerequisite-result carries forward on semantic-pr,
do-not-merge-gate, and pr-issue-linkage.

Why the outgoing allowlists were carried forward rather than widened

allowedInputs is a caller-side allowlist, not the callee's declared input
set: runner-policy.mjs filters caller-supplied with: keys against it, and
the file's precedent is unambiguous — semantic-pr@90f1c54 declares nine
inputs and allows two; zizmor@90f1c54 declares seven and allows two. Widening
each entry to the callee's full declared set would grant reach no caller asked
for. The allowlists here are the union of what fleet callers actually pass:
zizmor gains fail-on-severity only because standards' own ci.yml and
claude-code-plugins pass it at @31a5b76.

zizmor's new upload-sarif input is deliberately left out. Uploading
SARIF requires the caller to grant security-events: write, which is a
write-capable allowedCallerPermissions waiver and owes its own review.
Excluding it from the allowlist makes that state unreachable rather than
merely unwise: a caller enabling upload-sarif without the grant gets a
failing upload step, not a no-op.

The actions: read additions

do-not-merge-gate, semantic-pr, and pr-issue-linkage are the three that
added actions: read at v0.14.2 for the cancelled-vs-timed_out prerequisite
resolver (ci-workflows#458). Confirmed by parsing each definition at both SHAs:

Reusable permissions: at outgoing SHA permissions: at 7107b34
do-not-merge-gate.yml pull-requests: read pull-requests: read, actions: read
semantic-pr.yml pull-requests: read pull-requests: read, actions: read
pr-issue-linkage.yml {} pull-requests: read, actions: read
link-check.yml contents: read, issues: write unchanged
zizmor.yml contents: read unchanged workflow-level; job-level adds contents: read, security-events: write for the opt-in SARIF upload

This is correctly encoded as the absence of allowedCallerPermissions, not
its presence.
Two reasons, both load-bearing:

  • The validator rejects an all-read map outright:
    reusable workflow contract <ref>.allowedCallerPermissions must include at least one write permission. Every scope these three request is read, so the
    field is not expressible for them.
  • allowedCallerPermissions is the write-capable waiver, not a mirror of the
    callee's declared permissions. Omitting it keeps the ordinary read-only
    caller boundary, which is exactly what these three need.

Which of the five a future bump can auto-approve, for the reviewer's planning:
only zizmor. semantic-pr, do-not-merge-gate, and pr-issue-linkage are
declined unconditionally because they carry selectorResultInput, and
link-check because it carries an allowedCallerPermissions grant — both
categories are refused outright in resolveAutoApprovedContracts. Adding a
waiver to the three gate contracts would therefore forfeit nothing they still
have; it is simply not expressible, per the bullet above.

The caller-side obligation is real but lives in each consuming repository, not
here: a called workflow can only downgrade the caller's GITHUB_TOKEN
permissions, never elevate them
(https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows),
so every caller must grant, on its own job and in the same change as the pin,
the scopes the callee declares at 7107b34:

  • do-not-merge-gate, semantic-pr — add actions: read alongside the
    pull-requests: read they already grant.
  • pr-issue-linkageboth pull-requests: read and actions: read. Its
    caller jobs declare permissions: {} fleet-wide today, and this one affects
    the normal path, not only the cancelled-prerequisite path.

ci-runner#250 is the precedent for how badly this can present — a required
check emitting no context at all rather than a red check.

Known gap: policy cannot enforce a minimum caller scope

Raised by the Codex review lane on this PR and worth recording rather than
burying. A consumer can repin to 7107b34 without granting the scopes above
and still pass Runner policy: no contract in this registry expresses a
minimum caller permission. allowedCallerPermissions is an exact-match
waiver the validator refuses unless it contains at least one write, so for a
callee whose whole requested set is read it is not expressible.

This is a pre-existing, model-wide property — every entry without that field
has it — and withholding these three approvals would enforce nothing while
keeping consumers stuck, so they are kept. The runtime consequence is also
narrower than it first looks: in all three, the API call sits behind
if: inputs.prerequisite-result == 'cancelled', and its catch block calls
core.setFailed naming the missing scope, so a converged-but-under-granted
caller gets a red, self-diagnosing check rather than a silent one.

The enforceable fix — a new optional minimum-scope contract term, or a
permissions analogue of the repo-local requiredReusableCallInputs complement
— is a schema-plus-validator design change, deliberately out of scope for a PR
whose remit is to add reviewed entries and change no schema or validator.
Recommended as a follow-up issue against this component.

Correction to the issue's table

The issue attributes link-check's auto-approval decline to changed inputs.
It is not. Running the module's own exported comparator
(reusableWorkflowSecuritySurfacesMatch) over the real fetched sources gives:

Reusable Outgoing SHA Real diffField
zizmor.yml 90f1c54 / 31a5b76 inputs
link-check.yml 90f1c54 credentialReferences
do-not-merge-gate.yml 380612a permissions
semantic-pr.yml 380612a permissions
pr-issue-linkage.yml e944387 permissions

link-check's on.workflow_call block is byte-identical to @90f1c54.
What changed is its credential-bearing step surface: the rolling-issue open /
update / close steps moved off the gh CLI (GH_TOKEN: ${{ github.token }})
onto actions/github-script, plus an actions/checkout pin bump v7.0.0 →
v7.0.1. That is the whole of what a reviewer must actually review for this one,
and it is why its contract is unchanged.

Test plan

Real results, all run against the edited policy.json.

  • node --test components/runner-policy/runner-policy.test.mjs264 pass,
    0 fail
    (the component's only test file; referenced in
    distribution/sync-manifest.yml).

  • npm run lint:runner-policyRunner policy passed.

  • Schema: policy.json validates against
    components/runner-policy/policy.schema.json. The schema is not a separate
    command — validatePolicy compiles it with Ajv 2020 and every run above goes
    through it, so both the lint and the test run are schema-validating runs.

  • npm run lint:md — 0 issues (112 files). biome check on policy.json
    clean, no formatting fixes applied. Pre-commit hooks (typos, gitleaks,
    markdownlint, editorconfig, biome) all passed.

  • Convergence proof. Copied standards' own .github/ to a scratch root,
    repinned its five callers to 7107b34, added actions: read to the three
    gate callers, and ran auditRepository with auto-approval disabled and
    fetch stubbed to throw (so nothing could pass by network):

    • against origin/main's policy.json: 5 findings, one
      runner-target-contract "the reusable workflow path@SHA has no reviewed
      runner-input contract" per caller;
    • against this branch's policy.json: 0 findings.

    This also empirically confirms the read-only-boundary reasoning above: the
    converged callers granting {pull-requests: read, actions: read} are
    admitted with no allowedCallerPermissions entry present.

Not done here, deliberately

  • Downstream caller changes. Consuming repositories still need their own
    PRs to repin and to add actions: read to the three gate callers. This PR
    only removes the policy-side blocker.
  • ci-runner is already at 7107b34 for all five and its zizmor caller
    passes paths with no runner input while granting
    security-events: write. Under a runner-input contract the runner input
    must be passed explicitly, so that job will need review when ci-runner
    adopts this component — flagging it here so it does not resurface later as a
    mystery.

Open questions from the issue

Does zizmor warrant a privileged-control-plane exception rather than
staying advisory?
Recommendation: no, not yet — defer with a trigger.
The exception category exists for jobs that must run write-capable or
credential-bearing work on proven-hosted execution. zizmor at 7107b34 needs
none of that: its analysis is read-only, and the only privileged surface
v0.14.2 introduces is the opt-in SARIF upload, which no fleet caller enables.
The trigger is concrete: the first caller that wants upload-sarif: true.
That caller needs security-events: write, which forces an
allowedCallerPermissions waiver on the zizmor contract, and a waiver only
holds while the call is genuinely selector-routed — a fixed hosted literal
through the same input gets no waiver and lands squarely in
privileged-control-plane. Decide it then, with a concrete caller in hand,
rather than pre-granting now.

Should the registry refuse untagged SHAs outright? Recommendation:
yes in principle, but it cannot land as a bare refusal — it needs a
migration step first.
380612a is not the only untagged pin.
31a5b76 (2026-07-20) is also untagged, and standards' own ci.yml and
claude-code-plugins both pin it for zizmor today. A hard refusal would
invalidate this repository's current pin on the day it merges. Sequencing that
works:

  1. Retire the two untagged pins by converging every consumer of 380612a and
    31a5b76 onto tagged revisions — which the entries in this PR make possible
    for both.
  2. Then add the refusal, as a registration-time check on
    approvedReusableWorkflowContracts keys rather than a call-site check, so
    the failure lands on whoever adds the entry rather than on every downstream
    consumer at once.

Worth noting the refusal buys less than it looks like it does: SHA pins are
what actually make a reference immutable, and a tag is a mutable pointer. What
it really buys is provenance legibility — a SHA with no tag names a version
identity that does not exist, which is precisely how 380612a and 31a5b76
went unnoticed. A cheaper first move in the same direction would be requiring
the pin comment to carry a resolvable tag, which pin-comment-convention is
already positioned to enforce.

Closes #381

Related

  • melodic-software/provisioning#281 — converged 26/31 references, held these five
  • melodic-software/provisioning#276 — the split-pin issue that prompted it
  • build(deps): bump ci-workflows pin to v0.14.2 ci-runner#250 — the actions: read caller-contract
    precedent and the invisible-missing-check failure mode
  • ci-workflows#458 — the cancelled-vs-timed_out prerequisite resolver behind
    the actions: read additions

🤖 Generated with Claude Code

https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5

… reusables

Adds reviewed approvedReusableWorkflowContracts entries at
7107b34832a7b6db5d08d3b132621c599fbe5e50 (v0.14.2) for link-check,
semantic-pr, do-not-merge-gate, pr-issue-linkage, and zizmor, so
consumers pinned to older SHAs can converge. Each entry copies its terms
forward from that workflow's newest previously approved SHA; no existing
entry changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 23s —— View job


Code Review

  • Gather context (PR diff, description, repo conventions)
  • Review components/runner-policy/policy.json additions
  • Review components/runner-policy/README.md additions
  • Cross-check claims in PR description against actual diff
  • Run runner-policy tests/lint — blocked, see note below
  • Post findings

No issues found. This is a well-scoped, additive-only change and it checks out under manual/static review:

  • Diff scope matches the description exactly. The only changes are 5 new keys under approvedReusableWorkflowContracts in policy.json (link-check, semantic-pr, do-not-merge-gate, pr-issue-linkage, zizmor, all pinned @7107b34832a7b6db5d08d3b132621c599fbe5e50) plus a matching narrative paragraph in README.md. Nothing else moved.
  • No collisions / no overwrites. Grepped for the full SHA — all 5 new keys are unique; none of the SHAs registered previously (policy.json#L830-L835) is touched or replaced.
  • Validator invariants hold, traced against runner-policy.mjs (validate, ~L247-282):
    • Each routing: "runner-input" entry's allowedInputs includes its runnerInput ("runner").
    • semantic-pr, do-not-merge-gate, and pr-issue-linkage's selectorResultInput: "prerequisite-result" is present in allowedInputs and distinct from runnerInput, satisfying the required-field checks.
    • link-check is the only one of the five with allowedCallerPermissions ({contents: read, issues: write}); issues: write satisfies the "must include at least one write permission" rule at runner-policy.mjs#L265-272 — confirming the PR body's claim about why the other four correctly omit it (all-read permission sets aren't expressible for that field, by design).
    • All required schema fields (routing, runnerInput, allowedInputs, allowedSecrets) are present per policy.schema.json's runnerInputContract definition; allowedCallerPermissions/selectorResultInput are optional there too, consistent with the four entries that omit them.
  • "Outgoing entry" claims check out against this repo's live pins. .github/workflows/ currently pins link-check@90f1c54, pr-title.ymlsemantic-pr@90f1c54, do-not-merge.yml@90f1c54, pr-issue-linkage.yml@e944387, ci.ymlzizmor@31a5b76 — all consistent with the comparison table in the PR body (the table compares against each workflow's newest registry entry, not necessarily this repo's live pin, and that distinction holds up).
  • The cited internal machinery is real, not just asserted: reusableWorkflowSecuritySurfacesMatch and securitySurfaceDiffField exist and are wired exactly as described (runner-policy.mjs#L1625, #L1650, #L1864), and the exact validator error string quoted in the PR body (allowedCallerPermissions must include at least one write permission) matches runner-policy.mjs#L270 and its test at runner-policy.test.mjs#L4616.
  • The new README paragraph (README.md#L569-588) is consistent with the surrounding per-revision narrative style and doesn't require updating any aggregate counts elsewhere in the doc (none exist).

Tooling note: this review lane's sandbox declined to execute node --test, npm run lint:runner-policy, and even a standalone python3/node script (all returned "requires approval" with no one available to grant it), so I could not independently re-run the test suite, linter, or Ajv schema validation the PR description cites. The analysis above is a manual trace against runner-policy.mjs/policy.schema.json instead. If re-running those checks in this lane is wanted, the caller's --allowedTools will need Bash(node:*)/Bash(npm run *) widened.

No inline comments — nothing to anchor to a line; the change is straightforwardly correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f3c3d32dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread components/runner-policy/policy.json
@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton
kyle-sexton merged commit a117b36 into main Aug 14, 2026
87 of 89 checks passed
@kyle-sexton
kyle-sexton deleted the feat/runner-policy-v0.14.2-contracts branch August 14, 2026 04:17
kyle-sexton added a commit that referenced this pull request Aug 14, 2026
…res (#384)

## Summary

Add `allowedCallerPermissions` (`contents: read`, `security-events:
write`) to the `zizmor.yml@7107b34832a7b6db5d08d3b132621c599fbe5e50`
reusable-workflow contract.

## Fix

#382 registered the v0.14.2 contract for `zizmor.yml` without a
caller-permission waiver. That entry is incomplete.

At `7107b34` the reusable declares `security-events: write` on its inner
job, and a reusable workflow cannot elevate caller permissions — so the
**calling job** must grant it. The workflow says so itself, twice:

- header: *"security-events: write on the calling job — reusable
workflows cannot elevate caller permissions. The job-level
security-events: write below…"*
- `paths` input description: *"Callers must grant security-events:
write…"*

Without the waiver, a selector-routed caller granting that scope is
rejected by the gate. Observed verbatim in
`melodic-software/provisioning` while attempting to converge its pin:

```
.github/workflows/ci.yml#zizmor: hosted-exception-required: write GITHUB_TOKEN permissions (security-events) requires a hosted exception with reason privileged-control-plane
.github/workflows/ci.yml#zizmor: privileged-hosted-only: write GITHUB_TOKEN permissions (security-events) cannot use selector or reusable local-runner routing
```

That leaves a consumer only two bad options: weaken the gate, or take a
`privileged-control-plane` exception that forces a purely **advisory**
lint lane off selector routing onto fixed hosted runners. Neither is
acceptable, so `melodic-software/provisioning#284` held that one
reference back while converging the other four.

`link-check.yml@7107b34` already carries the equivalent waiver for its
`issues: write`, so this restores parity rather than introducing a new
shape.

## Verification

- `node components/runner-policy/runner-policy.test.mjs` — **264 tests,
264 pass, 0 fail**.
- The `security-events: write` value satisfies the validator's existing
rule that `allowedCallerPermissions` contain at least one `write`.
- Callee permissions read from `zizmor.yml` at `7107b34` directly:
top-level `contents: read`; inner job `contents: read` +
`security-events: write`.
- Local lefthook gates pass: typos, editorconfig, gitleaks, biome.
- Corroborating evidence that the grant is genuinely required at
runtime, from `melodic-software/ci-runner`: commit `381fe535` bumped the
zizmor pin without the caller grant and its `ci` run concluded
`startup_failure`; `52068787` ("grant zizmor security-events write") is
what fixed it.

Scope is one entry — no schema, validator, or other contract changed.

## Related

No linked issue.

- #382 — registered the incomplete entry this corrects.
- #383 — the separate, still-open gap: contracts cannot express a
**minimum required** caller scope. Distinct from this, which is an
exact-match waiver and expressible today because `security-events:
write` is a write.
- melodic-software/provisioning#284 — held the zizmor reference pending
this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 14, 2026
)

## Summary

`approvedReusableWorkflowContracts` could waive an *exact* caller
permission set
but could not require a **minimum** one, so a consumer repinning to a
reusable
that newly requests a read scope passed `Runner policy` while granting
less than
the callee needs. This adds the missing term,
`minimumCallerPermissions`, and
backfills it onto the three `7107b34` gate contracts.

`allowedCallerPermissions` is unchanged — still an exact-match waiver
the
validator refuses unless it carries at least one `write`. No existing
contract
entry, validator branch, or check is relaxed or removed.

The floor may only require `read`. That is the resolution of the Codex
P2 on
this PR, and it is what makes the two fields exactly complementary
rather than
overlapping — reasoning below.

### The term, and why it is separate rather than a relaxation of the
waiver

The two fields answer different questions and neither implies the other.

| | `allowedCallerPermissions` | `minimumCallerPermissions` |
|---|---|---|
| Direction | ceiling — the exact set a caller may present | floor — the
least a caller must grant |
| What it does | **waives** the ordinary read-only caller boundary so a
reviewed workflow may hold a privileged grant | **grants nothing, waives
nothing**; records what the callee's own `permissions:` block requests |
| Values | must contain at least one `write` | `read` only — a write
floor is rejected at policy load |
| Match semantics | exact, per scope | ordered, per scope; more passes |

The two are exactly complementary, and that is not incidental — see the
Codex P2
resolution below. Every **write** obligation already has a home: the
waiver,
reviewed against the calling job, which is the route #384 just used for
`zizmor.yml@7107b34`'s `security-events: write`. Every **read**
obligation now
has one too. Merging them into a single field was considered and
rejected: the
write requirement on the waiver is load-bearing — it is what makes the
field a
*privilege* waiver rather than a mirror of the callee's declared
permissions —
and the obligation #383 was filed about is entirely read, exactly the
shape that
requirement makes inexpressible.

### Codex P2, resolved by construction: no write floor can exist

A floor that could require `write` would be unsound. GitHub downgrades a
caller's write grants to read — and write-only scopes to none — on
**forked**
and **Dependabot** pull requests unless repository settings permit
otherwise, so
a caller's declared `write` is not the access the callee receives. A
write floor
compared against the YAML declaration would pass exactly the callers it
exists
to catch.

Rather than model event-time downgrades — the policy can read neither
repository
settings nor fork/Dependabot context at validation time, so such a check
would be
a guess dressed as a check — the term is **restricted to `read`
values**. That
disposes of the finding by construction rather than by argument: no
write floor
can be declared, so none can be silently downgraded.

The rule lives in the validator rather than the schema so the author of
a
rejected contract is told *why*. That is the split
`allowedCallerPermissions` already uses in the opposite direction — the
schema
permits an all-read waiver, and the validator rejects it with "must
include at
least one write permission". Verified behavior, not intended: running
`validatePolicy` over the real `policy.json` with the floor on
`semantic-pr@7107b34` mutated gives

```text
REJECTED {"actions":"write"}
  reusable workflow contract <ref>.minimumCallerPermissions must require read access only
  (actions); GitHub downgrades caller write grants on forked and Dependabot pull requests,
  so a write floor cannot be proven from the caller's declaration — use
  allowedCallerPermissions for a write obligation
REJECTED {"id-token":"write"}                  … must require read access only (id-token); …
REJECTED {"pull-requests":"read","contents":"write"}  … must require read access only (contents); …
REJECTED {"actions":"none"}     <ref>.minimumCallerPermissions.actions must NOT be valid
REJECTED {"id-token":"read"}    <ref>.minimumCallerPermissions.id-token must be equal to one of the allowed values
ACCEPTED {"actions":"read"}
```

The last two are the schema's own value domain: a floor value must name
a real
grant, and `id-token` is a write-only scope with no `read` level to
require.

### How permission comparison is ordered

The restriction is on what a contract may **require**, not on how grants
compare — the ordered comparison is unchanged. GitHub access is ordered
`none` < `read` < `write`, and a called workflow can only *downgrade*
the
caller's `GITHUB_TOKEN`, never elevate it ([reusable-workflow
docs][rw]), so the
check remains a floor, not a match:

- a caller granting `write` where the contract requires `read`
**passes**;
- extra scopes the contract does not name **pass** — the floor says
nothing
  about them;
- `read-all` and `write-all` both clear a read floor — and each still
clears it
after an event-time downgrade lands at `read`, which is precisely why a
  read-only floor is sound where a write floor is not;
- a scope the caller does not name is granted nothing and **fails**;
- effective job permissions that are **omitted** fail closed — they
resolve to
repository- or organization-defined defaults this policy cannot read, so
they
  can never *prove* the floor.

Job-level permissions override workflow-level, so the comparison runs
against
`effectivePermissions(workflow, job)`, the same surface the existing
waiver
check uses.

`write-all` clearing the floor is arithmetic, not absolution: the floor
waives
nothing, so a `write-all` caller still meets the ordinary
`privileged-control-plane` rules. A regression test asserts exactly
that.

A contract naming **both** fields is checked for satisfiability when the
policy
loads: because the waiver is the only mapping such a caller may present,
a
waiver falling short of its own floor would admit nothing at all, so it
is
rejected as a configuration error rather than left to fail silently at
every
call site.

### Auto-approval: deliberately *not* a decline category

`selectorResultInput`, `allowedCallerPermissions`, and a nonempty
`allowedSecrets` each decline Dependabot auto-approval unconditionally,
because
each is trusted for something the surface diff never inspects — what the
callee's *steps* do. `minimumCallerPermissions` is the opposite kind of
term: it
says nothing about steps, only what the callee's `permissions:` block
requests,
and that block is already part of the compared surface. A bump that
changes it
is declined by the diff; a bump that does not carries the same floor. So
the
term is **added to `reviewedContractSurface`** — two surface-matching
bases
holding different floors must still be caught as ambiguous, and there is
a test
for that — but **not** to the decline list.

(Moot for the three entries here: all three carry `selectorResultInput`
and are
already declined unconditionally.)

### Correction to the backfill list I was given

The task brief said `semantic-pr.yml` and `do-not-merge-gate.yml`
declare only
`actions: read`. **They do not.** Fetched from
`melodic-software/ci-workflows`
at `7107b34832a7b6db5d08d3b132621c599fbe5e50`, each of the three
declares
exactly one workflow-level `permissions:` block, with no job-level
override
anywhere in the file:

| Reusable | `permissions:` at `7107b34` | Backfilled floor |
|---|---|---|
| `semantic-pr.yml` (L93) | `pull-requests: read`, `actions: read` |
both |
| `do-not-merge-gate.yml` (L45) | `pull-requests: read`, `actions: read`
| both |
| `pr-issue-linkage.yml` (L61) | `pull-requests: read`, `actions: read`
| both |

This matches the table already recorded in #382's own body, so the
brief's list
was the outlier. Each floor is the callee's whole declared set: the
callee
narrows to that set, so a caller granting any less starves it.

## Blast radius

**No consumer breaks today. Nothing in the fleet needs a change before
or after
this merges.** Verified against the **live default branches** via `gh
api`, not
local clones.

The floor is keyed to `path@SHA`, so only callers pinned at `7107b34`
for these
three paths are governed at all.

| Repo | Runs `runner-policy`? | Pins at `7107b34` for these three? |
Effect |
|---|---|---|---|
| `provisioning` | **yes** — `.github/runner-policy.json` and the
managed materialization both present | **yes**, all three (converged by
melodic-software/provisioning#284) | **governed and compliant** — each
calling job already grants `pull-requests: read` **and** `actions: read`
|
| `ci-runner` | **no** — neither `.github/runner-policy.json` nor
`.github/standards/runner-policy/` exists (HTTP 404 on both) | yes, all
three | none; and its caller jobs already grant both grants too, so it
would pass if it adopted the component |

`provisioning` was re-verified after #284 merged mid-flight, against its
**live
default branch**, in two ways: `gh api` on all three caller files,
confirming the
`7107b34` pin and both `read` grants on each calling job; and the
component from
**this branch** run over a `git archive origin/main` export of that tree
with
real owner evidence —

```text
GITHUB_REPOSITORY=melodic-software/provisioning CI_REPOSITORY_VISIBILITY=private \
  node components/runner-policy/runner-policy.mjs --root <provisioning@origin/main> \
    --policy <this branch>/components/runner-policy/policy.json
Runner policy passed.
```

Identical result under `origin/main`'s policy, so this PR changes
nothing for
it.

**What the gate actually buys, then:** every caller still on an older
SHA —
`standards` itself, `.github`, `dotfiles`, `medley`, `github-iac`,
`claude-code-plugins`, `codex-plugins` — grants `permissions: {}` or
`pull-requests: read` on its gate jobs. Each of those now fails
**pre-merge, in
the repin PR**, instead of at workflow startup or with a runtime 403 in
the
cancelled-prerequisite resolver. `provisioning` reaching the same end
state by
hand, in #284, is the case for the gate rather than against it: nothing
forced
that convergence to include the grants, and nothing would have caught it
had it
not. That is why the change lands with zero present-day breakage — it
catches
the *next* repin, not the current state.

Delivery is gated too: `components/runner-policy/policy.json` is a
`managed`
component in `distribution/sync-manifest.yml`, so the tightened policy
reaches
each consumer through a reviewed sync PR, never at this PR's merge.

## Test plan

Real results, run on this branch, rebased onto `origin/main` at
`0fb6464`
(post-#384).

- `node --test components/runner-policy/runner-policy.test.mjs` — **272
pass,
0 fail**. `origin/main` measured the same way (`git archive origin/main`
into
  a clean tree) is **264 pass, 0 fail**; +8 net tests, three of them
  table-driven case sets.
- `npm run lint:runner-policy` — **Runner policy passed.**
- **Schema:** `validatePolicy` compiles `policy.schema.json` with Ajv
2020
  (`strict: true`) on every run above, so the lint and test runs are
schema-validating runs. `githubMinimumPermissionMap` was additionally
probed
directly against the component's own Ajv 8.20.0 with its real options
before
  being adopted: it compiles clean under `strict: true`, accepts
  `{"actions":"read"}`, and rejects `{"actions":"none"}`, `{}`,
`{"id-token":"read"}`, `{"models":"write"}`, and unknown scopes —
inheriting
the whole 17-scope table and its per-scope constraints through `$ref`
rather
than duplicating them. The read-only rule sits in the validator, not
here, for
  the message-quality reason given above.
- `npm run lint:md` — 0 issues, 112 files. `lefthook run pre-commit` —
typos,
  editorconfig, gitleaks, markdownlint, biome all pass.
- Neighbouring components, to show nothing cross-broke: `test:packages`
14/14,
  `test:concurrency-policy` 24/24, `test:dependabot-policy` 35/35,
  `test:pr-convention-policy` 10/10, `test:lefthook-dotnet` 12/12,
  `lint:hooks` "All good", `lint:concurrency-policy` and
`lint:dependabot-policy` pass. (`lint:pr-convention-policy` fails
identically
on `origin/main` in this environment — its npm script self-checks with a
`$(cat …)` substitution Windows `cmd` does not expand. Untouched here.)

### Proof the new validation bites

The unit tests cover the semantics; this is the end-to-end proof against
the
**real** backfilled `policy.json` and this repository's **own real
callers**.
`standards`' `.github/` at `origin/main` was exported to a scratch root,
the
three gate callers repinned to `7107b34`, only the caller job's
`permissions:`
varied, and `auditRepository` run with auto-approval disabled and
`fetch`
stubbed to throw so nothing could pass by network.

Against **this branch's** `policy.json`:

```text
### today's grant, repinned to 7107b34            (permissions: pull-requests: read)
  .github/workflows/do-not-merge.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"
  .github/workflows/pr-issue-linkage.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"
  .github/workflows/pr-title.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"

### exactly the reviewed minimum                  (pull-requests: read, actions: read)
  (no findings)

### more scopes than the minimum                  (read-all)
  (no findings)

### higher access than the minimum                (pull-requests: write, actions: read)
  (no findings)

### no grant at all                               (permissions: {})
  .github/workflows/do-not-merge.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"
  .github/workflows/pr-issue-linkage.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"
  .github/workflows/pr-title.yml [runner-target-contract] reusable workflow caller permissions.actions must grant at least "read"; the grant is "none"
```

Against **`origin/main`'s** `policy.json`, those same five scenarios
produce
`(no findings)` every time — including both under-granted ones. That is
the gap
this PR closes, reproduced rather than asserted.

The check reports the **first** shortfall in sorted scope order,
matching
`exactCanonicalMap`'s existing first-failure style, which is why the
`permissions: {}` scenario names `actions` and stops rather than also
listing
the equally-missing `pull-requests`.

### New tests

- `an all-read minimum admits a caller granting exactly it` — the case
that is
  inexpressible on `main`.
- `a caller granting more than the minimum clears it` — a `write` grant
against
  a `read` floor, on a contract carrying **both** terms. This is the
  load-bearing proof that the ordered comparison survived the read-only
restriction: only what a contract may *require* narrowed, not how grants
are
  compared.
- `a caller granting less than the minimum is rejected` — omitted scope,
  explicit `{}`, and an unrelated scope granted instead.
- `a minimum caller permission floor cannot require write access` —
`write` on
a read/write scope, `id-token: write`, and a mixed map with one write
value;
  all rejected at config-validation time. **New for the P2.**
- `read-all and write-all callers both clear a read floor`, asserting
that
  `write-all` is nonetheless still caught by the privileged rules.
- `a caller with no explicit permissions cannot prove a minimum`.
- `a minimum caller permission scope must name a real read grant` —
`none`,
  unknown scopes, `{}`, and `models: write`.
- `a contract naming both caller-permission terms must be satisfiable`,
re-targeted to a read floor the waiver omits entirely (the previous
`write`
  floor is no longer a legal contract).

One row was added to the existing `Dependabot SHA bump declines
ambiguous
surface-matching reviewed contracts` table so two bases differing
**only** in
`minimumCallerPermissions` are proven to be caught.

### Not done here, deliberately

- **`hosted-only` contracts do not get the term.** Not an oversight and
not
scope-trimming: `reusableWorkflowStatus` returns approved for
`hosted-only`
before any permission check runs, so extending the floor there means
extending
that path — a behavior change to a routing mode with no consumer in this
  issue. It belongs in its own change with its own review.
- **No consumer repins.** Nothing downstream needs one; see Blast
radius.
- **`ci-runner`'s callers pass no `runner` input**, so they would fail
the
`runner-input` contract for an unrelated, pre-existing reason if
`ci-runner`
ever adopts the component. Already flagged in #382; unchanged by this
PR.

### Proposal, not implemented: should the term be required?

Raised rather than built, per the brief. **Recommendation: yes
eventually, as a
registration-time check with a migration first — not now.**

The honest shape of the rule, after the read-only restriction, is "a
`runner-input` contract must record the **read** scopes of its callee's
`permissions:` block as its floor" — the callee's write scopes stay the
waiver's
business, as `link-check`'s `issues: write` and `zizmor`'s
`security-events: write` already are. Requiring it today would
invalidate every
existing entry at once, including several this PR does not touch, and
the check
cannot derive the callee's block itself without a network fetch at
policy-load time, which the module deliberately does not do outside the
auto-approval path. The workable sequence is the one #382 proposed for
untagged
SHAs: backfill the floor onto the remaining entries first, then add the
requirement as a registration-time check on
`approvedReusableWorkflowContracts`, so the failure lands on whoever
adds an
entry rather than on every downstream consumer simultaneously. Worth its
own
issue once the backfill is complete.

Closes #383

## Related

- #382 — registered the `7107b34` entries and recorded this gap in its
own body
  as a known limitation; this PR closes it
- #384 — waived `zizmor.yml@7107b34`'s `security-events: write` through
`allowedCallerPermissions`; this branch is rebased on it, and it is the
worked
  example of the write half of the ceiling/floor split
- #381 — the convergence effort those entries exist to unblock
- melodic-software/provisioning#284 — converged provisioning's three
gate
callers to `7107b34` with both `read` grants while this PR was open; the
  blast-radius table reflects the post-merge state
- melodic-software/ci-runner#250 — the adjacent, worse-behaved
caller-contract
failure mode: rejected at startup, with the required check emitting no
context
  at all
- ci-workflows#458 — the cancelled-vs-`timed_out` prerequisite resolver
behind
  the `actions: read` additions

[rw]:
https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013yvHrEronHPoznT1b3HtN5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runner-policy: approve v0.14.2 contracts for five ci-workflows reusables blocked from convergence

1 participant