Skip to content

Harden CI: stop the build and publish jobs persisting the job credential - #5331

Merged
vivekchand merged 6 commits into
mainfrom
harden/no-persisted-credentials-build-and-publish
Aug 30, 2026
Merged

Harden CI: stop the build and publish jobs persisting the job credential#5331
vivekchand merged 6 commits into
mainfrom
harden/no-persisted-credentials-build-and-publish

Conversation

@vivekchand

@vivekchand vivekchand commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Product record: No-PRD: CI-only change confined to .github/, a PRD-exempt path.

Risk: Low. Six with: additions to actions/checkout steps, no logic and no permissions: change. Each of the six is a job that issues no git command after checkout, so the credential being dropped has no consumer. Undone by reverting one commit.

Summary

actions/checkout writes the job's GITHUB_TOKEN into .git/config as an http.extraheader and leaves it there for the rest of the job. Anything running afterwards — a build script, an installer signer, an installed dependency — can read it, and it is trivially captured into an uploaded artifact. zizmor reports this as artipacked. This continues the pass started in #5327 (read-only jobs) and #5330 (gate, nightly and audit jobs); this batch is the build and publish side.

The six sites, and why each is safe

Workflow Job(s) What it does after checkout
desktop-artifacts.yml macos, windows, linux Builds and signs installers. The file contains no git invocation at all. The release job — the one holding contents: write — has no checkout and attaches assets through the Releases API.
publish.yml publish Builds the wheel, uploads with twine using PYPI_API_TOKEN. Its id-token: write / attestations: write scopes are spent through the API.
moat-keystone-drive-nightly.yml drive Spends its issues: write scope through the gh CLI via the GH_TOKEN env var, which persist-credentials does not affect.
auto-deploy-cloud.yml pin-version The OSS checkout only. Every git command in that job runs after cd clawmetry-cloud, against the second checkout and its CLOUD_REPO_PAT. That checkout is deliberately left as-is, and an inline comment now says so, so a later pass does not "finish the job" and break the force-push.

The distinction this rests on

Four of these are workflows the release pipeline depends on, so it is worth being explicit: this changes no permissions: block. persist-credentials governs only whether the credential is left sitting in .git/config. A scope spent through the REST API, the gh CLI, an action's own token: input, or an explicitly constructed remote URL is untouched by it. Every write path in these four workflows is one of those kinds — which is precisely why these six credentials had no consumer to lose.

Test plan

Originally measured against main at 0bec119 (artipacked 23 → 17). #5330 has since merged, removing 10 sites from main, and main was merged into this branch twice by the mergeability sweep. The numbers below are re-measured on the current head 2114415 against main at 69682bc. The delta this PR is responsible for is unchanged at −6.

  • yaml.safe_load over all 35 workflow + composite-action files → all parse (re-verified after each merge)
  • zizmor --offline --persona regularartipacked drops 13 → 7 sites, with a diff of before/after site lists confirming no new site appears. The auto-deploy-cloud PAT checkout still reports, correctly, and only shifts line number
  • Effective diff against main re-checked after both merges: still exactly these 4 files / 18 insertions, byte-identical to the original commit
  • python3 scripts/check_action_refs.py → exit 0, 17 references, SHA-pinning ratchet still holding
  • pytest tests/test_workflow_yaml_valid.py tests/test_ci_workflow_invocations_are_real.py tests/test_e2e_nightly_workflow.py192 passed, 2 skipped
  • Grepped every candidate file for git/gh/push/peter-evans to establish the "no consumer" claim per job rather than per file

Not in this batch, deliberately

The remaining artipacked sites are checkouts whose job does push through the persisted credential — auto-quarantine.yml, release-on-merge.yml (tag + version-bump push), auto-deploy-cloud.yml's cloud checkout, and the two peter-evans/create-pull-request workflows. Those need a different fix (an explicit token on the push), not this one, and are worth their own change.


Generated by Claude Code

actions/checkout writes the job's GITHUB_TOKEN into .git/config as an
http.extraheader and leaves it there for the rest of the job. Everything
that runs afterwards -- a build script, an installer signer, an installed
dependency -- can read it, and it is trivially captured into an uploaded
artifact. zizmor reports this as `artipacked`.

Six sites across four workflows, all of them jobs that never touch the
remote after checkout:

  desktop-artifacts.yml   macos / windows / linux -- the file contains no
                          git invocation at all; the `release` job that
                          does hold contents: write has no checkout and
                          attaches assets through the Releases API.
  publish.yml             builds the wheel and uploads with twine using
                          PYPI_API_TOKEN. No git, no Contents write.
  moat-keystone-...       spends its `issues: write` scope through the gh
                          CLI via GH_TOKEN, which this does not affect.
  auto-deploy-cloud.yml   the OSS checkout only. Every git command in that
                          job runs after `cd clawmetry-cloud`, against the
                          second checkout and its CLOUD_REPO_PAT; that one
                          is deliberately left alone and now says so.

This changes no `permissions:` block. persist-credentials governs only
whether the credential is left in .git/config; a scope spent through the
API, the gh CLI, an action's `token:` input or an explicitly built remote
URL is untouched. The release pipeline's write paths are all of that kind,
which is why these six had no consumer to lose.

Verified: 35 workflow + composite-action files parse; artipacked drops from
23 sites to 17 with no new site introduced (the PAT checkout still reports,
correctly); the SHA-pinning ratchet still holds at 17 references; the repo's
workflow guards report 192 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oDZctSrHcpCUP8onVhJKR
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged latest main into branch to bring it up to date


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

E2E Gate (required) failed on d8bdee0 — repo-wide runner starvation, not this PR

The gate timed out after 1800s with 8 check groups still at 0/N legs complete. Those jobs did not fail — they never got a runner. They were created at 12:13:04 (when Syntax & Lint, their needs: parent, finished) and were still queued half an hour later.

Why this is not this PR's failure

  • The whole repo's Actions queue is starved. 28 CI runs are queued simultaneously, including a push run on main itself (33252157239). 16 E2E Gate runs are in progress at once, every one of them polling for jobs that cannot start. This PR's gate simply reached its ceiling first; Harden CI: stop the gate, nightly and audit jobs persisting the job credential #5330's gate is in the same state behind it.
  • Every job that actually got a runner passed on this head — 14 of them, including Syntax & Lint, OSS golden path, Cross-repo handoff, Action references resolve, CodeQL Analyze (actions/python/javascript-typescript), Python dependency audit, the product-record gate and drift-bot.
  • The identical diff was fully green 90 minutes earlier. On the previous head 735e88f, all 35 checks resolved 34 success + 1 skipped. The only change since is a merge of main into this branch, which left the effective diff against main byte-identical: still 4 files, 18 insertions.

Re-verified locally on the merged tree: 35 workflow + composite-action files parse; check_action_refs.py exits 0 at 17 references; artipacked still drops 23 → 17 with no new site (the auto-deploy-cloud PAT checkout only shifts line number, and stays deliberately unfixed); workflow guards report 192 passed, 2 skipped.

On #5332, and why I have not ported it

#5332 raises MAX_WAIT 1800 → 3600 for exactly this symptom, and its own comment names the cause: "runner starvation on days with many concurrent PRs." I am not porting it into this PR, for two reasons worth stating rather than leaving implicit:

  1. It would not fix this instance. The ceiling is not the root cause — the queue is. Doubling the timeout only helps if runners free up inside the hour, which is a bet on capacity rather than a fix. fix(e2e-gate): skip Drift Bot when unreported, raise MAX_WAIT to 3600s #5332's own CI is queued in the same backlog.
  2. It carries a semantic loosening that deserves its own review. skip_if_unreported=True on the Drift Bot spec makes an unreported drift-bot status pass the gate instead of blocking. That may well be right for frontend-only dependabot PRs, but it is a change to what a required check enforces, and it should not ride into main inside a credential-hygiene PR.

What happens next

I am holding the one permitted re-run rather than spending it now: a re-run started against a starved queue joins the same backlog and times out identically. Once the queue drains I will re-run E2E Gate (required) on this head, and this PR should go green without a push. I will keep checking back until it is green, merged or closed.

Worth a human's attention: nothing in this repository can merge while this holds — main's own CI run is queued behind the same backlog. If this is not transient contention from ~15 open PRs all re-running at once, it is worth checking the account's Actions runner quota / spending limit.


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged latest main into branch (branch was BEHIND)


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged main (14572bc) into branch to resolve BEHIND state


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged main into branch (was BEHIND at 14572bc)


Generated by Claude Code

@vivekchand
vivekchand merged commit bffd1dd into main Aug 30, 2026
41 of 56 checks passed
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.

2 participants