feat(concurrency-policy): add workflow concurrency analyzer component - #152
Conversation
## Summary
Adds a synced analyzer component that enforces the canonical top-level
concurrency block on pull-request-triggered workflows:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
A superseding push to a pull request cancels the in-flight run before it
consumes a runner slot, while push and schedule runs fall back to the unique
github.run_id and are never cancelled.
## Why
Part of the volume-control workstream (melodic-software/github-iac#78). The
pull-request number, not github.head_ref, is deliberate: head_ref is a
fork-controllable branch name that collides across same-named branches from
different head repositories on pull_request_target, whereas the number is
unique and trusted. Both satisfy the "never cancel push/schedule" invariant
(head_ref is empty on those events); the number additionally avoids the
cross-fork collision. This is the pull_request.number || run_id variant the
fleet already uses, correcting issue #151's stated head_ref form.
Workflow concurrency is an embedded fragment, not a byte-syncable file, so
this mirrors runner-policy: a byte-identical analyzer + schema materialize
into consumers while each repository owns its exceptions locally.
## What
- Read-only Node analyzer with a strict Draft 2020-12 exception schema, a
locked ajv/yaml runtime, and a fail-closed delegated-job-level exception
seam.
- Behavioral test suite (21 cases) and threat model.
- Registered in distribution/sync-manifest.yml (unenrolled; per-consumer
rollout is separate integration work).
- Dogfooded by a new concurrency-policy CI job that audits this repository's
own workflows; the claude-review.yml job-level delegation is the one
recorded exception.
## Test plan
- [x] npm run test:concurrency-policy — 21/21 passing
- [x] npm run lint:concurrency-policy — passes against this repository
- [x] npm run lint:runner-policy — still passes with the new CI job present
- [x] biome, markdownlint, and sync-manifest validate — clean
Refs #151.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3QehVwmWzkBLpKokNCkkt
|
Claude finished @kyle-sexton's task in 4m 9s —— View job Code Review
Overall this is a well-constructed, security-aware component. No correctness bugs, no security issues. The fail-closed design, strict schema, dual-parse duplicate-key preflight, and sym-link rejection all hold up. A few things below worth discussing, ranging from a genuine gap to nits. 1. Unreachable deduplication block — dead code
const uniqueFindings = new Map();
for (const item of findings) {
const key = [item.file, item.rule, item.message].join("\0");
if (!uniqueFindings.has(key)) {
uniqueFindings.set(key, item);
}
}In the current code paths, duplicates cannot occur:
The dedup block is defensive-for-future-proofing but adds cognitive overhead: a reader seeing it will wonder "when does this fire?" and won't find an answer. Either document the invariant that keeps it from firing and keep the code as an explicit guarantee, or remove it and add a brief comment explaining why duplicates can't occur. If the intent is resilience against future logic changes, a terse comment stating that would clarify the purpose. 2. Missing test coverage: CLI argument parsing and
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 31c3d48f9e
ℹ️ 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".
A delegated-job-level exception previously skipped every concurrency check for the workflow, so an excepted workflow that also carried a present but non-canonical top-level block left that block unvalidated. The exception now waives only the missing-block case: a present block is validated regardless of the exception, and the now-unnecessary exception is reported. An exception never licenses an unsafe or non-canonical block. Adds a regression test and addresses the Codex review finding on #152. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3QehVwmWzkBLpKokNCkkt
|
Addressed the Codex P2 in 28979c8: the |
|
Claude finished @kyle-sexton's task in 2m 29s —— View job Code Review (follow-up —
|
| Excepted workflow state | Before | After |
|---|---|---|
| No top-level block | Pass (exception waives) | Pass (exception waives) — unchanged |
| Canonical block | exception-inventory-drift |
exception-inventory-drift + no concurrency findings — unchanged |
| Present but non-canonical block | Pass (silent bypass — the bug) | exception-inventory-drift + specific concurrency finding(s) — fixed |
| Malformed block | Pass (silent bypass) | exception-inventory-drift + concurrency-malformed — fixed |
The condition that drives the fix — topLevelConcurrency(record.workflow).present — is the right predicate. It lets the exception waive exactly what it should waive (absent block) while guaranteeing that any block that is present is held to the same standard as non-excepted workflows.
conformsToCanonical removal is clean. The grep confirms no remaining references; its only call site was the branch now replaced by the .present check. The predicate function's responsibility has dissolved into the call to concurrencyFindings, which already computes canonical conformance.
Test correctness. The new test ("an exception does not license a present non-canonical block") uses github.ref as the stale group. It expects concurrency-group-drift + exception-inventory-drift via rules() (which sorts alphabetically — c before e, so concurrency-group-drift comes first). The push order in the code is exception-inventory-drift first, then concurrencyFindings; the .sort() in rules() reconciles this. The assertion is correct.
Existing test preserved. The "already-conformant" case (ci.yml with canonical block + exception) continues to produce exactly [exception-inventory-drift] and nothing else, because concurrencyFindings returns an empty array for a canonical block. That test is still valid and passes.
One minor observation (not blocking)
The updated exception-inventory-drift message — "declares a concurrency-policy exception but carries a top-level concurrency block; remove the exception or the block" — is now shared between two distinct situations: (a) a canonical block that no longer needs the exception, and (b) a non-canonical block that also has an exception. For case (a), "remove the exception or the block" is slightly odd because removing the block is clearly wrong; the old message ("remove the unnecessary exception") was more precise. This is a copy nit — the finding fires correctly in both cases and the accompanying concurrency findings in case (b) make the action clear.
Status on original review findings
- feat(powershell): PowerShell static-analysis module #1 (dedup dead code), feat(markdown): Markdown (GFM) static-analysis module #2 (CLI/
--jsontest coverage), feat(hygiene): add editorconfig base + checker module #3 (symlink rule naming), feat(typos): add typos spell-check module #4 (redundant--jsonpre-check) — still open, unchanged by this commit. All were marked non-blocking in the original review and remain so. - test(typos): cover all four inline ignore directive forms #5 (schema enum observation) — confirmed intentional, no action needed.
The P2 fix is correct and complete. The PR is in good shape.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28979c866e
ℹ️ 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".
The mapping form previously accepted any extra key as long as group and cancel-in-progress were canonical, so a workflow could add `queue: max` and still pass. GitHub allows `queue`, but a pending queue is meaningless once cancel-in-progress supersedes the in-flight run, so the standard is the exact two-key block. Any other key is now reported as concurrency-extra-keys. Adds a regression test; suite is 23/23. Addresses the Codex review finding on #152. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3QehVwmWzkBLpKokNCkkt
|
Claude finished @kyle-sexton's task in 1m 23s —— View job Code Review (follow-up —
|
| Concurrency form | keys value |
extra-key check |
|---|---|---|
Absent (undefined) |
[] |
Never reached (present: false → early return) |
| String shorthand | [] |
Empty filter — no finding (correct: a YAML scalar has no keys) |
| Mapping with extra key | ["group", "cancel-in-progress", "queue"] |
extraKeys = ["queue"] → concurrency-extra-keys |
| Array / null (malformed) | [] |
Never reached (malformed: true → early return) |
The keys propagation is minimal and placed in exactly the right spot in each of topLevelConcurrency's four return branches.
Extra-key check fires independently of group/cancel drift. All three checks (L207–241) are sequential if blocks, not else if, so a mapping with a bad group, cancel-in-progress: false, and an extra key produces all three findings. That is the correct behavior.
Test correctness. CANONICAL + " queue: max\n" produces a block with canonical group and cancel-in-progress: true plus queue: max. The expected result is exactly [".github/workflows/ci.yml:concurrency-extra-keys"] — no group-drift or cancel-missing because those values are canonical. The assertion is correct.
README is updated to document concurrency-extra-keys, consistent with the existing rule entries. The example queue is the best illustration (it is the key GitHub specifically supports, making the "why we still reject it" reasoning directly actionable for a reader who would be confused).
Status on prior review findings
- feat(powershell): PowerShell static-analysis module #1 (dedup dead code), feat(markdown): Markdown (GFM) static-analysis module #2 (CLI/
--jsontest coverage), feat(hygiene): add editorconfig base + checker module #3 (symlink rule naming), feat(typos): add typos spell-check module #4 (redundant--jsonpre-check) — unchanged by this commit. All remain non-blocking. - Codex P2 (extra keys) — fixed and correct.
- Codex P2 (exception bypass,
28979c8) — fixed and correct (confirmed in prior review). - test(typos): cover all four inline ignore directive forms #5 (schema enum — observation) — confirmed intentional, no action needed.
The PR is in good shape. All blocking issues have been addressed.
…#153) ## Summary Adds `dependabot-policy`, a synced analyzer component that enforces the organization Dependabot policy on every `.github/dependabot.yml` `updates` entry: the weekly schedule, a cooldown soak of at least 7 days, a `groups` block so related bumps batch into one pull request, and an `open-pull-requests-limit` of at most 5. Part of the volume-control workstream (melodic-software/github-iac#78). A `dependabot.yml` varies per repository (different ecosystems and directories) and cannot be byte-synced, so this mirrors `runner-policy`: a byte-identical analyzer, `policy.json`, and schema materialize into consumers while each repository owns its config and a locally-owned exception file. The canonical values `standards` already dogfoods are the target each repository converges to. ## Shared-component admission evidence Per `docs/component-lifecycle.md`: - **Live consumer** — `standards` itself. The new `dependabot-policy` CI job runs the analyzer (`node components/dependabot-policy/dependabot-policy.mjs --root .`) against this repository's own `dependabot.yml` and is aggregated into the required `ci-status` gate. - **Owner and outcome** — Owner: standards maintainers. Problem: bot-pull-request bursts were a compound cause of the 2026-07-16 job backlog (github-iac#78). Acceptance: every `updates` entry batches, soaks, and caps its pull requests, or records a reasoned exception; the gate blocks drift. Rollback: remove the component entry and the CI job. - **Delivery boundary** — Exact materialization of a byte-identical analyzer + `policy.json` through this repository; the `dependabot.yml` it checks stays authoritative per consumer. The config cannot be byte-synced (per-repo ecosystems/directories), Dependabot has no `extends`/shared-config mechanism, and there is no organization control-plane setting for grouping/cooldown/limit — the analyzer is the only boundary that keeps one source of truth for the policy values. - **Alternatives and overlap** — A prose convention was rejected: the convention tier is adopt-by-copy, outside the sync loop, so it cannot satisfy the issue's "roll out via sync" and gives no gate. Folding into `runner-policy` was rejected for cohesion; there is no finding overlap (runner-policy never inspects `dependabot.yml`). Complements the demand-shaping work in ci-workflows#122 and extends the W4 Dependabot batching decision (github-iac#82) with an enforcement gate rather than duplicating it. - **Operational fit** — Static file parse; no network, credentials, or privileged access. Runs in well under a second. Deterministic pass/fail, fail-closed on parse errors and unknown config. OS-independent on Node >= 24. False-positive surface is bounded to four entry rules plus two file-level rules, with an exception seam for the two documented deviation classes. - **Upstream health** — Runtime dependencies `ajv@8.20.0` and `yaml@2.9.0`, the exact pins already vetted for `runner-policy` (byte-identical lockfile tree, 6 packages). No other dependencies. - **Legal and security** — `ajv` and `yaml` are MIT-licensed. The analyzer is read-only with no data or credential access. Boundaries, fail-closed behavior, and review triggers are in `components/dependabot-policy/THREAT-MODEL.md`. - **Update path** — The new npm dependency root is registered in `.github/dependabot.yml` (`/components/dependabot-policy`: weekly, cooldown 7, limit 5, grouped), itself conforming to the policy this component enforces. - **Verification** — 19 behavioral cases (`dependabot-policy.test.mjs`) covering conformant pass, each rule's violation, the `directories` plural form, exception waivers and inventory drift, fail-closed schema, and malformed YAML/JSON. CI runs the same `--root .` entrypoint consumers use. **Enforcement rollout.** Blocking from the start: the sole live consumer (`standards`) already conforms with no exceptions, so no observation period is needed. Downstream consumers move to blocking in their own integration PRs after a clean run. ## Dependabot drift (org-wide audit, informational) Audited `.github/dependabot.yml` across all 12 active repos (raw `gh api`). The org is already ~90% converged; `standards` is the reference. Remaining drift the standard will catch: - **No `dependabot.yml` at all**: `knowledge-corpus`, `songwriting` (neither has required CI or PR workflows, so both are low priority and out of the enforced set). - **`medley` — broad config drift**: `nuget` runs `daily` with no cooldown and no groups; `docker`, `npm` (root), `npm /tests/e2e`, `pip`, and both `uv` roots lack `cooldown` and `groups`; no explicit pull-request limit; commit prefix `chore` vs the org's `build`. Only its `github-actions` entry conforms. - **`ci-runner-canary`**: the `github-actions` entry has no `groups` block. - **Explicitness-only (functionally fine)**: `open-pull-requests-limit` is omitted in `provisioning`, `dotfiles`, `medley`, `github-iac`; GitHub's default is already 5, so behavior matches — the analyzer accepts an omitted limit and only flags a limit above 5. - **Legitimate documented exceptions (not drift)**: `claude-code-plugins` npm-root `daily` + no cooldown (tracks Claude Code releases) and `github-iac` `dotnet-sdk` `daily` + no cooldown (SDK freshness) map to the `tracks-upstream-release` exception; single-tool `pip`/`uv` entries map to `single-tool-ecosystem`. These are handled by the locally-owned exception seam, not flagged. ## Per-consumer rollout (follow-up integration work) The 9 requires-ci repositories (each has an active `required_status_checks` ruleset rule). `standards` is wired in this PR; each of the other eight needs a separate integration PR: manifest enrollment (materializes the analyzer + policy), a locally-owned `.github/dependabot-policy.json` for its documented exceptions, the CI job, and a dependabot entry for the analyzer lockfile. `medley` and `ci-runner-canary` also need their `dependabot.yml` brought into conformance in that same change. - [x] melodic-software/standards — dogfooded in this PR - [ ] melodic-software/.github - [ ] melodic-software/ci-runner - [ ] melodic-software/ci-workflows - [ ] melodic-software/claude-code-plugins — exception: npm-root `tracks-upstream-release` - [ ] melodic-software/dotfiles - [ ] melodic-software/github-iac — exception: `dotnet-sdk` `tracks-upstream-release` - [ ] melodic-software/medley — config remediation + likely `single-tool-ecosystem` exceptions - [ ] melodic-software/provisioning The component is registered in `distribution/sync-manifest.yml` but enrolled in no target yet, so nothing materializes until each integration PR adds it to that target's `managed` set. `knowledge-corpus`, `songwriting`, and `ci-runner-canary` have no required CI (the last has no PR workflows); a `dependabot.yml` for the first two is optional low-priority follow-up. ## Note for the reviewer / merge order This PR and #152 (concurrency-policy) were authored in parallel off `origin/main` and both touch the same shared integration lines — the `ci.yml` biome `paths`, the `ci-status` `needs` list and `RESULTS` aggregation, `package.json` scripts, `.github/dependabot.yml`, and the `distribution/sync-manifest.yml` component block. Whichever merges second will need a trivial rebase to add its job/entry alongside the other's rather than replacing it. The two components are otherwise independent. ## Test plan - [x] `npm run test:dependabot-policy` — 19/19 passing - [x] `npm run lint:dependabot-policy` — passes against this repository (no exceptions needed) - [x] `npm run lint:runner-policy` — still passes with the new CI job present - [x] biome, markdownlint, and `sync-manifest.sh validate` — clean Closes #150. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds
concurrency-policy, a synced analyzer component that enforces the canonical top-levelconcurrencyblock on every pull-request-triggered workflow, so a superseding push cancels the in-flight run before it consumes a runner slot while pushes to the default branch and scheduled runs are never cancelled. Part of the volume-control workstream (melodic-software/github-iac#78).Corrected canonical. This encodes
pull_request.number || run_id, not thehead_ref || run_idform in the issue text. The rationale — verified against official GitHub docs — is recorded as a comment on the issue (#151 (comment)) rather than a silent body edit:head_refis a fork-controllable branch name that collides across same-named branches from different head repositories onpull_request_target; the pull-request number is unique and trusted; both keep the identical never-cancel-push/schedule invariant (head_refis empty on those events). The fleet already uses this variant on 14 workflows.A concurrency block is an embedded workflow fragment, not a byte-syncable file, so this mirrors
runner-policy: a byte-identical analyzer + schema materialize into consumers while each repository owns its exceptions locally.Shared-component admission evidence
Per
docs/component-lifecycle.md:standardsitself. The newconcurrency-policyCI job runs the analyzer (node components/concurrency-policy/concurrency-policy.mjs --root .) against this repository's own workflows and is aggregated into the requiredci-statusgate.runner-policywas rejected for cohesion: that component's purpose is runner selection and it never inspects concurrency, so there is no finding overlap.ajv@8.20.0andyaml@2.9.0, the exact pins already vetted forrunner-policy(byte-identical lockfile tree, 6 packages). No other dependencies.ajvandyamlare MIT-licensed. The analyzer is read-only with no data or credential access. Boundaries, fail-closed behavior, and review triggers are incomponents/concurrency-policy/THREAT-MODEL.md..github/dependabot.yml(/components/concurrency-policy: weekly, cooldown 7, limit 5, grouped), itself conforming to the organization Dependabot policy.concurrency-policy.test.mjs) covering canonical pass, group and cancel drift, thehead_refvariant, exception consumption and inventory drift, fail-closed schema, and YAML edge cases (quoted/unquoted, array/mappingon:, symlinks, duplicate keys). CI runs the same--root .entrypoint consumers use.Enforcement rollout. Blocking from the start: the sole live consumer (
standards) is already clean — one recordeddelegated-job-levelexception forclaude-review.yml, whose reusable enforces concurrency at job level — so no observation period is needed. Downstream consumers move to blocking in their own integration PRs after a clean run.Concurrency drift (org-wide audit, informational)
Audited 86 workflow files across 10 repos; 34 are pull-request-scoped (the target set). Zero workflows use the dangerous
head_ref-with-no-fallback pattern. 20 already conform to intent (14 via thepull_request.numbervariant + 6claude-reviewjob-level delegations). Drift the standard will catch:github.ref-based groups on workflows that also run on push/schedule, so default-branch or scheduled runs can supersede-cancel each other:provisioning/ci.yml,dotfiles/ci.yml,medley/ci-status.yml,medley/onboard-drift.yml,medley/actions-lint.yml,medley/osv-scanner.yml. Highest concern:medley/ci-status.yml(the aggregating required-check gateway — a second push to main cancels the in-flight one) andmedley/onboard-drift.yml(a main push and the scheduled run share…-refs/heads/main).medley/comment-review-gate.yml(multi-event; needs a scoped block).Per the workstream split, this PR delivers the standard and the gate only. The
medleyand consolidation-lane workflow remediations are not patched here — they are owned by ci-workflows#122.Per-consumer rollout (follow-up integration work)
The 9 requires-ci repositories (each has an active
required_status_checksruleset rule).standardsis wired in this PR; each of the other eight needs a separate integration PR: manifest enrollment (materializes the analyzer), a locally-owned.github/concurrency-policy.jsonif it needs any exception, the CI job, and a dependabot entry for the analyzer lockfile.The component is registered in
distribution/sync-manifest.ymlbut enrolled in no target yet, so nothing materializes until each integration PR adds it to that target'smanagedset.knowledge-corpus,songwriting, andci-runner-canaryhave no required CI and no pull-request workflows, so they are out of scope.Test plan
npm run test:concurrency-policy— 21/21 passingnpm run lint:concurrency-policy— passes against this repository (with the oneclaude-review.ymlexception)npm run lint:runner-policy— still passes with the new CI job presentsync-manifest.sh validate— cleanCloses #151.