feat(distribution): add actionlint queue-suppression component for no-config consumers - #284
Conversation
…-config consumers Distributes the Phase-0d-approved `.github/actionlint.yaml` suppression (scoped to actionlint's `concurrency.queue` syntax-check false positive, rhysd/actionlint#654) to the consumers that lint their own workflows and carry no actionlint config today: dotfiles, provisioning, and claude-code-plugins. The source doubles as this repository's own live config, since standards lints its own workflows via the same pinned ci-workflows composite. medley, ci-runner, and github-iac are deliberately excluded (existing repo-specific configs a whole-file sync would clobber); rationale recorded as comments at the component entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 50s —— View job Review
Summary: The PR is well-scoped, thoroughly documented, and the actionlint config format is correct. One Important finding on the component source path, one Nit on a documentation gap in the manifest comment. 🔴 Important — Component source breaks the established convention and creates a silent distribution riskEvery other component in the manifest sources its files from under # All other components:
agent-orientation:
files:
components/agent-orientation/orientation.md: AGENTS.md
# This component (unconventional):
actionlint:
files:
.github/actionlint.yaml: .github/actionlint.yamlThe risk: if a future contributor adds a The conventional fix is a canonical source at The PR body explains this is intentional ("the source doubles as this repository's own live config"), but the risk is real enough and the convention deviation clear enough that it warrants a deliberate call from the author before merging. 🟡 Nit —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbd618b490
ℹ️ 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".
…r, github-iac Makes the deliberate exclusions machine-readable per the ownership model in distribution/README.md, alongside the existing component-entry comments. `locally-owned` is the manifest's opt-out seam for a repo-specific config on an otherwise relevant component: the synchronizer never touches the file, and the periodic Reconcile obligation (canonical-minimum-content check) becomes the drift control for when queue-bearing callers land in those repos and their local configs must carry the suppression. Plan output over all six affected targets confirms the marking materializes nothing: medley, ci-runner, and github-iac each show "locally-owned actionlint (not modified)". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 21s —— View job Review
Summary: The second commit ( 🔴 Important — Verification admission evidence is missing per
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d104ac99fd
ℹ️ 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".
Independent re-measurement against medley's real 28-workflow tree found 12 label errors across 12 workflows, not 24, from overwriting its actionlint config in the fleet probe. Comment-only correction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 41s —— View job Review
Summary: The third commit ( 🔴 Important — Verification admission evidence still missing
None of the three commits add fixture files or a CI step exercising the suppression. The probe evidence cited in the PR body lives in Minimum evidence set:
Permalink: 🔴 Important —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 32f122f428
ℹ️ 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".
ci-workflows is a manifest target carrying its own .github/actionlint.yaml — the canonical origin this component's content mirrors — yet had no actionlint classification, which the ownership model reads as "not classified". Marks it locally-owned so the most relevant repo is deliberately classified; the removal trigger on rhysd/actionlint#654 fires there too. Plan output for ci-workflows confirms no materialization: "locally-owned actionlint (not modified)". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 58s —— View job Review
Summary: Commit 🔴 Important — Verification admission evidence still missing
Four commits in, no Minimum evidence set:
Permalink: 🔴 Important — Component source deviates from convention and creates a silent accumulation riskEvery other component in the manifest sources from # Every other component:
agent-orientation:
files:
components/agent-orientation/orientation.md: AGENTS.md
# This component (unconventional):
actionlint:
files:
.github/actionlint.yaml: .github/actionlint.yamlPermalink: The risk: a future contributor adds a standards-specific suppression to This finding has been open for four review rounds. The PR body acknowledges the deviation is intentional, but that is a description of the design decision, not a recorded acceptance of the risk. If the risk is accepted, a comment at the source entry stating so would close this finding without requiring a structural change. What commit What all four commits did:
Everything else looks good: the actionlint config format is correct, both |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e18553455
ℹ️ 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".
…ly-owned "Canonical origin" left two apparent authorities for one component: the ownership model gives a component one normative owner, and standards' root config is the source. The ci-workflows copy is the pre-existing locally-owned implementation the content was derived from — say exactly that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fixtures Completes the repository-shape contract for the new root tool config: components/actionlint/ documents and behaviorally tests the exported .github/actionlint.yaml per docs/component-lifecycle.md's Verification requirement — pass/fail fixtures against the pinned consumer entrypoint, not structural validation alone. The test builds a scratch consumer project from the root-canonical config: the concurrency.queue workflow lints clean, an unrelated syntax-check violation still fails alongside it (message-scope proof), and a configless control run reproduces the suppressed diagnostic. The control case doubles as the removal tripwire: when rhysd/actionlint#654 ships in the pinned engine it fails, firing the config's recorded removal trigger instead of leaving a stale suppression. CI runs the suite in the actionlint job, reusing the engine the pinned ci-workflows composite installs on the job PATH. Verified locally with actionlint 1.7.12: 7/7 assertions pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 4m 20s —— View job Review
Summary: Commit 🔴 Important —
|
…rigger The control-case comment embedded a tracker reference, which the comment-hygiene gate forbids in code comments. The config file owns that citation as its recorded removal trigger; the comment now points there instead of repeating it. Verified with the gate's own scan-tree.sh and patterns: clean; component test still 7/7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 43s —— View job Review
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b0814688b4
ℹ️ 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".
|
|
||
| queue_message='unexpected key "queue" for "concurrency" section' | ||
|
|
||
| out="$(cd "$project" && actionlint -no-color 2>&1)" |
There was a problem hiding this comment.
Exercise the composite entrypoint in the fixture test
The fresh suite added after the earlier thread still invokes the actionlint binary directly rather than the ci-workflows composite that consumers use. In the checked CI ordering (.github/workflows/ci.yml:463-466), the composite runs before the scratch project exists, and the repository itself has no queue-bearing workflow, so a wrapper change that stops discovering this config could leave both steps green while every queue-bearing consumer fails. docs/component-lifecycle.md:55-58 specifically requires CI to exercise the consumer entrypoint; make the composite see the queue fixture (or expose and test the exact script it invokes) rather than validating only the underlying binary.
AGENTS.md reference: AGENTS.md:L3-L8
Useful? React with 👍 / 👎.
## Summary
Re-pins the `sync` job's engine from ci-workflows `0b45b9f` (2026-07-18)
to
`ac223bb` (2026-07-28). `0b45b9f` predates the sync job's
`permission-workflows: write` token-mint request, so the
melodic-standards-sync
App's Workflows grant is inert at the old pin and the sync cannot
materialize a
target repository's workflow files.
The runner-policy gate admits a reusable call only at a reviewed
`path@SHA`, so
the new contract is registered alongside the pin in the same change, per
the
lockstep in `components/runner-policy/README.md`. The entry is
byte-identical to
the reviewed `0b45b9fb` one — same `runner-input` routing, same five
allowed
inputs, same two-secret map — because the engine's delta widens nothing
a caller
may pass.
The `0b45b9fb` entry is kept, but not for the usual mid-rollout reason:
an
org-wide code search finds exactly ONE caller of `standards-sync.yml`
(`melodic-software/standards/.github/workflows/sync.yml` — the five
downstream
`.github/standards/runner-policy/policy.json` hits are synced copies of
the
allowlist, not callers), and this PR migrates it. After merge, zero
workflows
pin `0b45b9fb`. It is retained only as the rollback target while
`ac223bb` is
unproven in a real run; the component's own step 4 says to drop
superseded
entries once every consumer has migrated, so it should be removed in the
Phase
3g sweep.
The grant this pin activates is present on installation 144867070,
verified live:
```console
$ gh api orgs/melodic-software/installations \
--jq '.installations[] | select(.id==144867070) | {app_slug, permissions}'
{"app_slug":"melodic-standards-sync","permissions":{"contents":"write",
"issues":"write","metadata":"read","pull_requests":"write","workflows":"write"}}
```
## Delta audit
The engine calls no local composite action and no ci-workflows-side
script — the
manifest interpreter (`distribution/sync-manifest.sh`) is owned by this
repository and travels with `standards-ref`, not with the pin.
Third-party
actions are pinned inline inside the engine file. So
`standards-sync.yml` is the
entire delta surface, and three commits in `0b45b9f..ac223bb` touch it.
Behavior was compared under YAML normalization rather than by reading
the raw
patch, so anchor/alias expansion and formatting collapse out and only
semantic
change survives:
```bash
git show 0b45b9f:.github/workflows/standards-sync.yml | yq -o=json '.' > old.json
git show ac223bb:.github/workflows/standards-sync.yml | yq -o=json '.' > new.json
diff -u old.json new.json
```
| Commit | Change | Changes behavior for an existing target? |
| --- | --- | --- |
| `e8e5f3f` (#205) | Dedupes the two identical checksum-verified yq
install steps via a YAML anchor/alias (`&install-yq` / `*install-yq`) |
**No.** The step vanishes entirely from the normalized diff — proof the
alias expands to the byte-identical step, not an eyeball judgement. Both
jobs still read the same workflow-level `YQ_VERSION` /
`YQ_LINUX_AMD64_SHA256`. |
| `dd45dac` (#213) | Adds `id: cpr` to the create-pull-request step and
a new step that arms auto-merge (squash) on the PR | **Yes** — see
below. |
| `ac223bb` (#284) | Adds `permission-workflows: write` to the
per-target App token mint | **Yes** — see below. |
The normalized diff isolates exactly three additions:
`permission-workflows:
write` on the mint, `id: cpr` on the create-PR step, and the auto-merge
arming
step. Nothing was removed or reworded.
### `ac223bb` — `permission-workflows: write` on the mint
Intended effect: the per-target token can write files under
`.github/workflows/`,
which is what Phase 3a's caller components require. Stated precisely,
this is
capability, not current behavior — **no manifest component maps under
`.github/workflows/` today**, so the grant is unexercised until Phase 3a
lands
the first such mapping.
Failure mode if the premise were wrong: `create-github-app-token`
**fails the
mint** when the installation lacks a requested permission, and the mint
runs
before any target work — so a missing grant would fail the apply job for
*every*
target, not degrade gracefully. The live installation read above is the
evidence
that the premise holds. No other permission was widened; the mint stays
`repositories: <one target>`, `contents: write`, `pull-requests: write`.
### `dd45dac` — auto-merge armed at sync-PR creation
This is the one genuine behavior change for existing targets. After this
pin, a
sync PR is armed for auto-merge (squash) at creation, so it merges into
the
target's default branch once required checks go green — with no human
review
step. Two guards bound it:
- It fires only when `steps.cpr.outputs.pull-request-operation ==
'created'`, so
a refresh of an already-open sync PR does **not** arm (and does not
re-arm a PR
a reviewer deliberately disarmed).
- It is skipped when the target's manifest record sets `automerge:
false`. That
key is already supported by this repository's interpreter and schema
(`distribution/sync-manifest.sh`, `sync-manifest.schema.json`),
absent-means-true.
Blast radius today — **no target in `distribution/sync-manifest.yml`
sets
`automerge: false`**, so arming is live for all eight, and the
`created`-only
guard is what actually splits them:
| Target | Open `standards-sync` PR | Next sync |
| --- | --- | --- |
| `melodic-software/.github` | none | **created → armed** |
| `melodic-software/ci-runner` | none | **created → armed** |
| `melodic-software/ci-workflows` | none | **created → armed** |
| `melodic-software/claude-code-plugins` | none | **created → armed** |
| `melodic-software/dotfiles` | none | **created → armed** |
| `melodic-software/provisioning` | none | **created → armed** |
| `melodic-software/github-iac` | #234 | updated → not armed |
| `melodic-software/medley` | #1665 | updated → not armed |
The "updated -> not armed" rows are a coincidence with a shelf life, not
a
property of those two targets: all eight repositories set
`delete_branch_on_merge: true`, so once #234 and #1665 close, the next
sync
creates a fresh PR and arms it like everyone else.
Arming never fails the run: the GraphQL mutation is wrapped and a
rejection
downgrades to `core.warning`. That cuts both ways — an arming FAILURE
yields an
unarmed PR, which is invisible to the stuck-armed-PR watchdog and
indistinguishable from today's behavior. The first real run is the only
test of
whether an App installation token may call `enablePullRequestAutoMerge`
at all;
if it may not, the sync silently reverts to manual merging behind a
warning. A
watchdog arm flagging UNARMED App-authored sync PRs would close that gap
and
does not exist. Not fixed here: it is a ci-workflows-side change and
this PR is
scoped to the standards caller.
The watchdog half of #213 is already wired here —
`.github/workflows/standards-sync-stuck-automerge-alert.yml` exists and
is
pinned at `43bc8d0` — so an armed-but-blocked sync PR is not silent.
### Verdict for existing targets
**Safe to re-pin; one sequencing hazard to schedule around, not a
defect.**
`e8e5f3f` is provably inert. `ac223bb` is safe given the verified grant,
and its
failure mode is loud and immediate rather than silent. `dd45dac` does
change
outcomes for the six targets without an open sync PR: merging this PR is
itself
the activation event (`on: push: branches: [main]` with
`dry-run: ${{ inputs.dry-run || false }}`, so a push-triggered run is a
real
run), and the next sync will open and arm PRs against those six.
Two things the rollout owner should not get wrong:
- **Merge order is not the control.** The caller passes no
`standards-ref`, so
the engine's plan job checks out `melodic-software/standards@main` at
job-execution time and pins every downstream job to whatever SHA that
resolves to. Two PRs merged minutes apart are both carried by the FIRST
push-triggered run. Sequencing on "merge this one first" is a race; the
real
control is the `automerge: false` window Phase 3d already plans, or
holding
the caller-component merge until a sync cycle has completed.
- **This PR's own merge is the activation event.** There is no staging
step
between merging and the six targets getting armed PRs.
Nothing here breaks a target — the targets' own required checks still
gate the
merge. This PR does not merge itself.
### The two changes compose
Worth naming because neither commit describes it alone. After this pin,
the
per-target token may write `.github/workflows/`, sync PRs arm themselves
at
creation, and every target's `base` ruleset requires zero approving
reviews
(verified live). Human checkpoints on the path from a standards merge to
workflow content landing in a target therefore go from two (merge the
standards
PR, merge each target's sync PR) to one. `ci-workflows`, which hosts the
engine,
is itself a sync target, so the loop closes on itself.
This is inert today only because no component maps under
`.github/workflows/` —
and Phase 3a exists to change exactly that. Recorded in
`components/runner-policy/README.md` so the next person to add such a
mapping
meets it. Not a blocker for this PR; a decision the rollout owner should
make
consciously rather than inherit.
### What merging this PR sets off
Merging is a push to `main`, which fires a REAL non-dry-run sync
(`inputs` is
empty on push, so `dry-run` evaluates false). Two consequences the
merger should
expect:
- **The `attest` job is the re-proof of App/manifest set equality, and
that
proof is currently stale.** The last successful non-dry-run attest was
`2026-07-27T15:16Z` (run 30279157140); installation 144867070's
`updated_at`
is `2026-07-29T01:56Z` — roughly 34 hours later, with no read-only way
to
re-verify in between. Most likely benign: a permissions-only grant,
consistent
with ci-workflows#284 merging minutes afterward. If the post-merge
attest goes
red, that is **fail-closed behavior working** — attestation runs before
any
mutation, so nothing is corrupted, and it self-clears once the grant and
the
manifest target set agree. Do not patch around it.
- **The six targets without an open sync PR get created-and-armed PRs on
that
same run.** There is no staging step.
### Deferred, not fixed here (out of scope)
`.github/workflows/standards-sync-stuck-automerge-alert.yml` pins
`43bc8d0`,
which predates `42329ef` (#234) — the fix that splits the stuck-PR scan
into a
cheap page fetch plus a per-candidate merge-state probe with retry. The
watchdog
functions at its current pin; it is simply one reliability fix behind.
Trigger to
revisit: the next time the watchdog misses or flakes on a stuck armed
PR, or the
Phase 3g fleet re-pin sweep.
## Test plan
- `actionlint .github/workflows/sync.yml` — clean.
- Input/secret conformance proved against the engine blob **at
`ac223bb`**, not
against ci-workflows `main`: the caller passes `runner`, `dry-run`,
`targets`,
`app-client-id`, `app-private-key`; all five are declared at that SHA,
both
secrets remain `required: false`, and no input gained `required: true`.
(A
caller passing an undeclared input hard-fails `workflow_call` before the
job
starts.)
- `gh workflow run sync.yml --ref ci/repin-standards-sync-engine -f
dry-run=true`
— **run
[30419339590](https://github.com/melodic-software/standards/actions/runs/30419339590),
`sync / plan` success.** This resolves the `workflow_call` against
`ac223bb`
for real and exercises the plan job, which is the direct test of the
input
contract. Limit stated honestly: a dry-run skips the apply job (`sync /
sync`
and `sync / attest` both `skipped`), so it does **not** exercise the
`permission-workflows: write` mint or the auto-merge arming. The live
installation read in the Summary is the evidence for the mint half. It
also
gives **zero attestation signal** — `attest` and `sync` both carry
`if: ${{ !inputs.dry-run }}`, so neither runs under dispatch. What it
does
prove, beyond the input contract: the engine file PARSES at `ac223bb`,
which
is what clears the anchor/alias refactor — an unresolvable `*install-yq`
would
be a `startup_failure` with no job running at all.
- `npm run test:runner-policy` (238 pass, 0 fail) and `npm run
lint:runner-policy`
("Runner policy passed.") after the allowlist entry was added.
- Repository CI (`ci-status` and the required gates), including the
`pin-comment-convention` component, which mechanically validates the
`# <short-sha> <date>` provenance comment against the pin it annotates.
## Related
- ci-workflows#284 — the engine change this pin adopts (`ac223bb`).
- ci-workflows#213 — the auto-merge arming this pin also adopts
(`dd45dac`).
- ci-workflows#205 — the yq anchor/alias refactor (`e8e5f3f`),
behavior-neutral.
No linked issue.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ents (#286) ## Summary Adds two components — `claude-review-caller` and `claude-security-review-caller` — sourcing thin workflow callers for the ci-workflows reusable Claude review lanes at `.github/workflows/claude-review.yml` and `.github/workflows/claude-security-review.yml`, pinned at the **v0.9.1** release SHA (`c136b27f404dd32ce3873f39a6f3443891d1c16e`). This is Phase 3a of the ci-workflows claude-review-lanes plan (`docs/topics/claude-review-lanes/PLAN.md`). > **Sections below marked "REVISED" supersede the original text.** Four > commits landed after this body was first written; where they conflict, the > REVISED text is authoritative. ### REVISED — pin is v0.9.1, not v0.9.0 v0.9.0 (`cf666f67`) was tagged BEFORE `paths-file` merged. The security caller passes `paths-file`, which v0.9.0's reusable does not declare — Actions hard-fails a `workflow_call` that passes an undeclared input, and on the security lane that wedges a required check permanently. Verified by reading each reusable's `workflow_call` block at the pinned SHA (`git show <sha>:<path>`, not `main`, not the tag) and diffing the caller's `with:` / `secrets:` keys against it in both directions, plus every `needs.<job>.outputs.<name>` read against the reusable's declared outputs. Result at v0.9.1: 0 undeclared inputs, 0 undeclared secrets, 0 omitted required inputs, 0 undeclared output reads, across all four `uses:` including both `select-runner.yml` calls. Negative control at v0.9.0 reproduces the defect. ### REVISED — these components are PRIVATE-ONLY; the security caller is PARKED The callers resolve the runner through the governed `select-runner` indirection, and `runner-policy` admits that selector only for a private self-hosted consumer (`routingEnabled = visibility === "private" && selfHostedCi`). The `!routingEnabled` branch consults neither `exceptions` nor `localRoutingGrants`, so a PUBLIC target has no configuration escape. Auditing each component against a public consumer config using the shipped `policy.json` yields four `public-self-hosted-routing` findings; the same audit passes clean for a private self-hosted consumer. Consequences: - `claude-review-caller` is `managed` for the four PRIVATE targets that run the lane: dotfiles, github-iac, medley, provisioning. - `melodic-software/claude-code-plugins` is PUBLIC and is now `locally-owned` for both callers, keeping its hand-written hosted-only callers. Had the original targeting shipped, its own `runner-policy` lane — and with it `ci-status` — would have gone red, wedging the one repo whose ruleset requires `security-review / security-review`. - `claude-security-review-caller` therefore has **no managed target** and is recorded as PARKED, not scoped: both repos running a security lane today (claude-code-plugins, ci-workflows) are public. It is retained rather than deleted because its bytes are the reviewed shape for the one lane whose check can be a required context. It unparks when a private repo adopts the security lane, or when the runner indirection moves inside the ci-workflows reusable so one component serves both visibilities. Three tests in `components/runner-policy/runner-policy.test.mjs` hold this and were each proven non-vacuous by reintroducing the defect: a selector-routed caller may not be `managed` for a public target; every caller component must audit clean for a private self-hosted consumer; a selector-routed caller is expected to be rejected outright on a public one. ### REVISED — accepted loss: `synchronize` All four managed targets' live callers carry `synchronize`; the component drops it. That is the plan's deliberate cadence cut (review on open/ready/reopen; re-run the job for a fresh review), not drift — but it is the largest behavior change the component makes and is now recorded as an accepted loss for every target, alongside medley's `paths-ignore`. The security lane KEEPS `synchronize`: its check certifies execution against the latest head. ### REVISED — validation coverage gap closed `components/claude-lanes/` holds workflow bytes, but every workflow-shaped lane in this repo discovers files under `.github/workflows` — so actionlint, zizmor, concurrency-policy, and pin-comment-convention were all blind to these components, and `lint:runner-policy` scans the repo's own workflows, not `components/`. Nothing validated them. Three lanes now cover them in CI, each proven non-vacuous: - **pin-comment-convention** takes an explicit file list and scans `components/claude-lanes/*.yml` (a mismatched SHA claim fails it). - **zizmor** names both components alongside `.`; its `paths` input is whitespace-separated. Verified that a bare `.` never reached them, that they are audited now, and that the finding total, severity breakdown, and exit code are identical to baseline — so what the lane gates on is unchanged. - **actionlint**, through a new materialization contract test (`components/claude-lanes/claude-lanes.test.sh`, wired into the `actionlint` job). It runs the entrypoint a consumer runs rather than linting the component in place: `sync-manifest.sh apply` into a scratch checkout carrying the target's origin identity, then actionlint over the result. Every managed target is covered, plus each component's bytes standalone at their destination path so the PARKED security caller — which no target manages, so no target loop reaches it — is linted too. A control run with the suppression config removed still reports the `concurrency.queue` message, so the case cannot go vacuous unnoticed when rhysd/actionlint#654 ships upstream. **Scope of that coverage, stated precisely:** the test is hermetic and makes no network call, so it lints each target's materialized bytes under THIS repository's canonical actionlint config — not under the target's own. For `github-iac` and `medley`, which own `actionlint` locally and so receive no config from the manifest, the canonical config always substitutes. If either deleted its own queue suppression, this test would still pass. What the test owns is that the shipped bytes lint clean under a conforming config at the destination path the manifest maps. That the four live configs actually conform is gate item 1 below — a live check, re-verified independently, and it is what covers this gap. Still blind, deliberately: `concurrency-policy` fails both components on three rules whose values are load-bearing (lane-scoped group names; the security caller's deliberate `cancel-in-progress: false`). No target repo runs `concurrency-policy`, so nothing fails today; the conflict is documented at the component sources so a future adopter exempts rather than normalizes. Caller shape derives from the reusables' canonical-caller headers (the SSOT): - Job ids `review` / `security-review` — required-check name continuity (`security-review / security-review` on claude-code-plugins' ruleset). - Review triggers `[opened, ready_for_review, reopened]`; security triggers add `synchronize`; no workflow-level path filtering on the security caller (a non-triggering required check wedges forever). - Permissions per the canonical headers (`contents: read`, `pull-requests: write`, `id-token: write` on the lane job). - Governed `select-runner` indirection (selector job, `needs`, runner input) with the runner-policy recovery fallback `'ubuntu-24.04'` — never a self-hosted label, never `vars.CI_HOSTED_RUNNER`. - Security caller passes `paths-file: .github/claude-security-paths` (shipped in ci-workflows#282). - `CLAUDE_CODE_OAUTH_TOKEN` passed explicitly; never `secrets: inherit`. - Targets are the repos that run each lane **today** — SUPERSEDED by the private-only revision above; the shipped targeting is `claude-review-caller` in dotfiles, github-iac, medley, provisioning, with claude-code-plugins `locally-owned` for both callers and `claude-security-review-caller` parked. `.github` and ci-runner stay exempt (plan approval record item 1); knowledge-corpus and songwriting are commented follow-ups gated on the sync App access grant. `distribution/README.md`'s three workflow-caller-exclusion statements gain carve-outs pointing at a new authoritative section: hand-written lane callers empirically drifted (medley's missing `reopened` trigger, divergent skip-actors lists, pin skew v0.6.1 / e295107). AGENTS.md and governance-process.md were grepped for restatements of the exclusion — none exist. Cross-doc reconciliation self-review performed per `distribution/governance-process.md`: the three README statements plus the new section are the complete reconciliation surface, and no other normative doc was left contradicting the change. ### DO NOT MERGE — gate status (REVISED) 1. **standards#284 (Phase 3c0 actionlint suppression) — SATISFIED.** Merged 2026-07-27T14:24Z. Every consumer lints its own workflows with pinned actionlint 1.7.12, which rejects the review caller's `concurrency.queue` key (rhysd/actionlint#654); without the distributed suppression, each caller sync PR would fail its consumer's required `ci-status` check. Verified live: all four managed targets' `.github/actionlint.yaml` suppress the message for `claude-review.yml` (dotfiles and provisioning by glob, github-iac and medley by explicit path). 2. **standards-sync App `workflows: write` (Phase 3a0) — SATISFIED.** Verified live: `gh api orgs/melodic-software/installations` reports installation `144867070` (`melodic-standards-sync`) holding `{contents: write, issues: write, metadata: read, pull_requests: write, workflows: write}`. Writing `.github/workflows/` files in target repos needs that permission; without it every sync PR from this component would fail. 3. **#290 (`automerge: false` rollout window) — SATISFIED.** Merged 2026-07-29T13:05Z and merged into this branch, resolving the one conflict at the `claude-code-plugins` target. 4. **#289 (re-pin the sync engine at `ac223bb`) — OPEN.** #290's stated merge order is #290 → #289 → #286. The App grant above is inert until the engine re-pin lands, because `main` still pins `ci-workflows@0b45b9f`, which predates the `permission-workflows: write` mint. Beyond that ordering, the only failing check on this PR is `do-not-merge / do-not-merge` — the intentional label gate, which the label owner lifts. ### Concurrency decision record Shipped the 2e-documented shape from the reusables' headers: review caller = workflow-level per-PR cancel group plus a separate job-level `queue: max` repo-wide group; security caller = per-PR group with `cancel-in-progress: false` and **no** queue. github-iac's live caller deliberately omits caller-level concurrency, claiming (i) caller-level cancel reintroduces skipped-actor cancellation and (ii) a group-name collision with the reusable's job group. Inspection at v0.9.0: - **Collision claim: disproven.** The inner job group is `claude-review-<PR>-<headSHA>` (claude-review.yml:261 at v0.9.0); the caller groups are `claude-review-<PR>` (cancel) and `claude-review-<owner/repo>` (queue). No name equality, so the historical caller/inner deadlock — real when the inner group was `claude-review-<pr-number>` exactly; provisioning's caller comment documents the observed "deadlock was detected" error from that era — cannot recur. github-iac's and provisioning's comments describe a pre-v0.9.0 inner-group shape. - **Skipped-actor cancellation: real but bounded, accepted.** The caller workflow-level group does evaluate before any job `if`, so a skip-actor event on the same PR cancels an in-flight review. With no `synchronize` trigger the same-PR event surface is `opened` / `ready_for_review` / `reopened` — rare and human-driven. The reusable's 2e header documents this exact caller value as canonical and per-lane deliberate. The component sources record both rationales inline; the 3c smoke exercises this exact shape. ### Paths-file seeding disposition The manifest has **no seed-once mechanism** (schema v2: components are unconditional source-to-dest maps), and `.github/claude-security-paths` is repo-owned tuning that must NOT become managed bytes. Disposition: - Recorded the gap in the manifest comment and README section: a new adopter commits its starter list via a repo-local PR at adoption time. - **claude-code-plugins migration ordering: MOOT.** That repo is now `locally-owned` for both callers and receives no sync PR, so no migration ordering applies. (Its `.github/claude-security-paths` already exists — 733 bytes, live — and its hand-written caller keeps its inline `paths:` list.) - Proposed mechanism if seeding is wanted later: a `seed` file class in schema v3 — materialized only when absent at the target, never reconciled — which preserves repo ownership after first sync. ### Deviations / notes for the 3c smoke - `skip-actors` is not passed by the callers: the reusable's default at the pinned `c136b27` is already the normalized self-trigger-ban list (`dependabot[bot],claude[bot],melodic-ai[bot],melodic-standards-sync[bot]`, `claude-review.yml:150`); existing callers passed it only because the pre-v0.9.0 default was narrower. Ownership therefore moves from the caller to the reusable, and `conventions/review/ai-review-bot-composition.md` is reconciled to say so — it previously attributed the `melodic-standards-sync[bot]` exclusion to the caller's `skip-actors` input, wiring this component no longer has. - Runner fallback normalized to `'ubuntu-24.04'` (dotfiles and github-iac currently use `melodic-ubuntu-24.04-x64`, which violates the runner-policy recovery contract in `distribution/README.md` and would queue forever on a public repo). - Selector-failure surfacing (`class=runner` marker) is a comment, not a `TODO(#issue)`: no dedicated issue exists; the incident-aggregator acceptance test (ci-workflows#238) exercises it in Phase 4. - The 3c smoke must confirm the required-check shape when the security caller's selector job fails or skips (a caller-job skip is a new state for the `security-review / security-review` required context on claude-code-plugins; on that public repo the selector routes hosted-only, so the exposure is infra-failure only). - medley's `paths-ignore` tuning is an accepted loss (plan approval record item 10), noted at its manifest target entry. ## Test plan REVISED — as run at `a4d7ee6`: - `npm run test:runner-policy` — 242/242 pass, including four new gates (each proven non-vacuous by reintroducing its defect in a scratch copy): a selector-routed caller may not be `managed` for a public target; every caller component must audit clean for a private self-hosted consumer; a selector-routed caller is rejected outright on a public one; and every managed target of any caller must be one runner-policy admits (written as a property, so legitimate unparking via a private consumer passes). - `npm run lint:runner-policy`, `npm run lint:md`, `bash distribution/sync-manifest.sh validate` (`Manifest valid: 34 components, 8 targets`) all pass. - Scripted input-conformance proof, both directions, all four `uses:` read at the pinned SHA: 0 failures, 0 warnings. Negative control at v0.9.0 reproduces the `paths-file` defect. - Governance simulation with the shipped `policy.json`: private self-hosted consumer passes clean; public consumer produces four `public-self-hosted-routing` findings. Negative control against the pre-`fa2e7a5` policy produces eight findings, proving the new policy entries load-bearing. - Selector byte-identity claim confirmed by object hash: `c136b27` and `e77f0126` both resolve `select-runner.yml` to blob `6ba7d60c`. - zizmor 1.26.1 over both components: no findings; totals identical to baseline. Extended pin-comment-convention scan: exit 0. - End-to-end regression test against each managed target's REAL live state — its `.github/runner-policy.json` and its full live workflow set fetched via the API, then the synced caller dropped in: all four (dotfiles, github-iac, medley, provisioning) report `Runner policy passed.` both before and after, so the caller introduces no finding at any target. - CI on the tip: **42 of 43 checks SUCCESS**, including `ci-status`, `Runner policy`, `distribution`, `actionlint`, `zizmor`, `concurrency-policy`, and `pin-comment-convention`. The sole failure is `do-not-merge / do-not-merge`, which is the intentional label gate. REVISED — after merging `main` (#290) and closing the five review threads: - Conflict with #290 resolved at the `claude-code-plugins` target, the one place both parents insert after `- typos`. Purely additive over each parent: `git diff origin/main -- distribution/sync-manifest.yml | grep '^-'` and the same against `af85ae0` each emit only the `---` header, zero deletion lines, so #290's comment above `targets:` and every component list survive. - `yq`: 8 of 8 targets carry `automerge` as a `!!bool` `false`; the count of targets whose value is not boolean `false` is `0`. `sync-manifest.sh matrix` emits it as a JSON boolean for all 8 (a quoted string would be truthy). - Per-target `managed` / `locally-owned` lists byte-identical to `af85ae0` (95 entries); components block unchanged. - `bash distribution/sync-manifest.sh validate` → `Manifest valid: 34 components, 8 targets`, exit 0. - `bash distribution/sync-manifest.test.sh` → 147 passed, 0 failed. - `npm run test:runner-policy` → 242/242 against the merged manifest; `npm run lint:runner-policy` → `Runner policy passed.` - `bash harness/shell/run-tests.sh components/claude-lanes/claude-lanes.test.sh components/actionlint/actionlint.test.sh` → 2 files, 2 passed, 0 failed. - The lane-caller suite was mutation-tested rather than trusted for being green. Two independent ways it could have shrunk silently — the managed-list query returning nothing, and the per-component membership test ceasing to match — were each reintroduced in throwaway copies outside the worktree and both now surface as FAIL with a non-zero exit, where the second previously dropped four assertions and still exited 0. Per-target assertion counts are asserted, not assumed. - shellcheck (`--rcfile .shellcheckrc -x`), actionlint 1.7.12 over the repo, zizmor 1.26.1 over the repo plus both components (`No findings to report`), markdownlint, typos, and editorconfig-checker all clean on the changed set. Original pre-revision plan: - `bash distribution/sync-manifest.sh validate` reported `Manifest valid: 33 components, 8 targets`. - `yq eval -o=json distribution/sync-manifest.yml | node distribution/validate-sync-manifest.mjs` passes. - `sync-manifest.sh plan` over all five affected targets shows exactly the intended additions (claude-review-caller in all five; the security caller in claude-code-plugins only; automerge unchanged). - actionlint 1.7.12 over both callers materialized at their destination layout WITH ci-workflows' approved queue-suppression config exits 0; the negative control without the config reports exactly the one expected `concurrency.queue` syntax-check finding on the review caller. - markdownlint plus lefthook pre-commit gates (typos, editorconfig, gitleaks, markdownlint) green on commit. ## Related - Phase 3a of `melodic-software/ci-workflows` `docs/topics/claude-review-lanes/PLAN.md` (approval record items 1, 9, 10). - Gate 1: #284 (3c0 actionlint suppression). - Gate 2: Phase 3a0 sync-App `workflows: write` grant (org-owner action, lands via github-iac). - Canonical caller headers: ci-workflows `claude-review.yml` / `claude-security-review.yml` at v0.9.1 (`c136b27f404dd32ce3873f39a6f3443891d1c16e`). - ci-workflows#282 (`paths-file` input), ci-workflows#278 (empty selector output — motivates the hosted fallback), ci-workflows#238 (Phase 4 acceptance test covering selector-failure surfacing). No linked issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
) ## 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>

Summary
Adds a new managed
actionlintcomponent distributing the Phase-0d-approved.github/actionlint.yamlsuppression — scoped to actionlint'sunexpected key "queue" for "concurrency" sectionfalse positive(rhysd/actionlint#654; GitHub accepts and honors the GA
concurrency.queuekey, live-probe-verified) — and materializes it into dotfiles,
provisioning, and claude-code-plugins only. The source doubles as
this repository's own live config (
.github/actionlint.yaml):standardslints its own workflows via the same pinned ci-workflows actionlint
composite and will carry a repo-local Claude review caller.
This is Phase 3c0 of the claude-review-lanes plan (ci-workflows
docs/topics/claude-review-lanes/PLAN.md): a fleet precondition. Callerworkflow components use the
queue: maxconcurrency key; without thissuppression landing first, every caller sync PR fails the consumer's own
actionlint lane feeding its required
ci-statuscheck, and the rolloutwedges.
Probe evidence (empirical, fleet-wide)
via the ci-workflows shared composite — no version skew; the
pathsconfig key is accepted (shipped upstream in v1.7.4).
queue: maxworkflow present, config present → exit 0; config removed →exit 1 (recorded in the plan's Phase 0d block).
medley's config produced 12 label errors, and ci-runner's local waivers
would be clobbered — hence the component targets ONLY repos with no
existing actionlint config.
Deliberate exclusions
Recorded twice, deliberately: explanatory comments at the component entry,
plus machine-readable
locally-ownedmarkings under medley, ci-runner, ci-workflows (pre-existing locally-owned copy), andgithub-iac. The marking is what the manifest's ownership model prescribes
for a repo-specific opt-out on an otherwise relevant component: the
synchronizer never reads, changes, or deletes the file, and the periodic
Reconcile obligation (canonical-minimum-content check, not a byte diff)
becomes the drift control for exactly the moment queue-bearing callers land
in those repos and their local configs must carry the suppression.
self-hosted-runnerlabels including the deliberateci-runner-selection-failedsentinel, load-bearing in 12 workflows.GovernanceTopologyTests pin the config content.
itself (this PR). No ownership label, per the README's own-originals rule.
Test plan
distribution/sync-manifest.sh validate—Manifest valid: 32 components, 8 targets(sources staged, matching indexed Git blobs).yq -o=json distribution/sync-manifest.yml | node distribution/validate-sync-manifest.mjs— Draft 2020-12 schema-valid.distribution/sync-manifest.sh planover all six affected targets —exit 0; dotfiles, provisioning, and claude-code-plugins each plan exactly
the one new
100644 .github/actionlint.yaml -> .github/actionlint.yamlmapping; medley, ci-runner, and github-iac show
locally-owned actionlint (not modified)— no materialization from themarking.
components/actionlint/actionlint.test.sh(new slice, run in the actionlintCI job with the composite-installed engine) — 7/7 assertions with actionlint
1.7.12: queue workflow clean with the config, unrelated violation still
fails, configless control reproduces the suppressed message (removal
tripwire for concurrency: Add support for
queuekey rhysd/actionlint#654).files.
Related
docs/topics/claude-review-lanes/PLAN.md— fleet actionlint precondition;suppression approved 2026-07-26 (Approval record item 3; fleet
distribution rides the same approval).
queuekey rhysd/actionlint#654. REMOVALTRIGGER recorded at the suppression site: delete the ignore when the fix
ships in the pinned actionlint version.
.github/actionlint.yaml(Phase 0d).No linked issue.
🤖 Generated with Claude Code