Skip to content

ci: move coverage comment out of the reusable ci workflow#293

Merged
laazyj merged 1 commit into
mainfrom
fix/ci-coverage-comment-permissions
Jul 10, 2026
Merged

ci: move coverage comment out of the reusable ci workflow#293
laazyj merged 1 commit into
mainfrom
fix/ci-coverage-comment-permissions

Conversation

@laazyj

@laazyj laazyj commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Problem

The v0.9.0 release run (29030687121) failed workflow validation before a single job started:

Error calling workflow ci.yml. The nested job coverage is requesting pull-requests: write, but is only allowed pull-requests: none.

Root cause

Permissions only ever narrow down a reusable-workflow chain — release.ymldeploy-test.ymlci.yml. Walk it and watch the scope collapse:

  1. release.yml declares a top-level permissions: block listing only contents: write and id-token: write. Writing an explicit block sets every unlisted scope to none — not to its default. So pull-requests: none from the top.
  2. Its deploy-test job narrows further to contents: read, id-token: write.
  3. deploy-test.yml's ci job has no permissions: block, so it inherits that ceiling verbatim.
  4. ci.yml's coverage job asks for pull-requests: write → exceeds the ceiling → the file is rejected.

The trap: job-level permissions: is static, read at parse time. The posting step was already gated on if: github.event_name == 'pull_request' and could never have run on a tag push — but the declaration alone fails validation. It's a parse-time error, not a runtime one, so the entire release run dies before anything executes.

Fix

Split the write scope out of the reusable workflow rather than propagating it down the chain. Granting pull-requests: write to a release run that provably never posts a comment would have worked, but it's the wrong direction for least privilege.

  • ci.ymlcoverage job drops pull-requests: write and the sticky-comment step. On pull_request events it uploads coverage-summary.md + the PR number as a coverage-summary artifact. The $GITHUB_STEP_SUMMARY output is unchanged, so the table still lands on the Actions run page. The workflow now holds contents: read only, and is safe to call from anywhere.
  • coverage-comment.yml (new) — a workflow_run listener on CI that downloads the artifact and posts the sticky comment (still keyed header: coverage, so it updates in place). Sole holder of pull-requests: write, and never in the release call graph.
  • docs/ci.md — pipeline diagram + Coverage reporting section updated, recording why the split exists so the comment step doesn't get folded back into ci.yml and re-break release.

Side effect: fork PRs now get a coverage comment

docs/ci.md previously called this out as an accepted limitation — fork PRs get a read-only GITHUB_TOKEN, so the in-line step was skipped. workflow_run runs in the base-repo context with a writable token, so it works there now.

The usual workflow_run privilege-escalation path stays closed: the listener never checks out or executes PR code. It reads the uploaded markdown and the PR number, and validates the number is numeric before passing it to the comment action.

Verification

  • All four workflow files parse; ci.yml contains no write scope outside a comment.
  • npm run lint and npm run format:check clean.
  • Rebased onto current main (picks up 3521cb2 ci: pin npm 11, which also touched ci.yml).

Not verifiable from here: the release chain's validity can only be confirmed by GitHub on the next tag push. Note also that workflow_run always dispatches the default-branch copy of a listener — coverage-comment.yml will not fire until this merges, so this PR will not post a coverage comment of its own.

🤖 Generated with Claude Code

release.yml -> deploy-test.yml -> ci.yml failed workflow validation:
the nested `coverage` job requested `pull-requests: write`, but
permissions only ever narrow down a reusable-workflow chain. release.yml
grants `contents: write` + `id-token: write` at the top level, which
implicitly sets every unlisted scope — including pull-requests — to
`none`, and its deploy-test job narrows further to `contents: read`.

Job-level `permissions:` is evaluated statically at parse time, so the
`if: github.event_name == 'pull_request'` guard on the posting step did
not save it: the declaration alone was enough to reject the file and
fail the whole release run before any job started.

Keep ci.yml at `contents: read` so it stays callable from anywhere. The
coverage job now uploads the summary table and PR number as an artifact;
a new workflow_run listener, coverage-comment.yml, downloads it and
posts the sticky comment. That listener is the only holder of
`pull-requests: write` and is never in the release call graph.

Side effect: fork PRs now get a coverage comment. workflow_run runs in
the base-repo context with a writable token, which the in-line step
could not obtain. The listener never checks out or executes PR code — it
reads the uploaded markdown and validates the PR number is numeric.
@laazyj laazyj merged commit d21e62b into main Jul 10, 2026
21 checks passed
@laazyj laazyj deleted the fix/ci-coverage-comment-permissions branch July 10, 2026 08:56
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.

1 participant