Skip to content

chore(ci): pin every workflow action to a commit SHA - #130

Open
aliasfoxkde wants to merge 3 commits into
mainfrom
chore/pin-workflow-actions
Open

aliasfoxkde wants to merge 3 commits into
mainfrom
chore/pin-workflow-actions

Conversation

@aliasfoxkde

@aliasfoxkde aliasfoxkde commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

What this does. Hardens the release workflows (ticket T27-57, issue #97) in three parts: every GitHub Action is pinned to a commit SHA and the rule fails closed in CI; a release tag is classified before it can cut anything, and the two accepted shapes are a production release and a rehearsal; and the fail-closed aggregate gate moves out of inline YAML into a script that is exercised by its own self-test.

Why. The release workflow builds the published artifacts and creates the draft release, and it named its actions by mutable reference — actions/checkout@v4, dtolnay/rust-toolchain@stable, actions/cache@v4, actions/upload-artifact@v4, actions/download-artifact@v4, cloudflare/wrangler-action@v3. A tag can be moved and a branch moves on its own, so the release path could execute code that changed with no diff to review here.

The same workflow also decided nowhere what a tag meant. It derived every artifact name from the tag, created every release as an ordinary draft, and had no notion of a platform at all — a skipped build was indistinguishable from a build nobody asked for. The rehearsal recorded in issue #99 (v0.2.5-dryrun.3) produced a draft whose title was 0.2.5-dryrun.3 and whose isPrerelease flag was false: nothing in the tooling marked it as a rehearsal.

The change — pinning (decision 0099).

  • All 19 action references in ci.yml, release.yml and deploy-site.yml name a 40-hex commit SHA; the version is kept in a trailing comment so the pin is auditable without a network call.
  • scripts/validate_workflow_pins.py (stdlib-only) walks the workflow directory and fails with the file and line of every unpinned reference. It runs in the backup CI's changes job — the job every push and pull request executes, and the one fmt and gate depend on — so an unpinned action fails the workflow before anything builds or publishes.
  • scripts/validate_workflow_pins-selftest.sh demonstrates each failure mode with one deliberately broken input (a mutable tag, a mutable branch, a docker:// image, a short SHA) and the two inputs the validator must accept (a pinned SHA, a local action).
  • deploy-site.yml no longer triggers on a push that touches the workflow file itself: the trigger is the content it publishes (site/**, wrangler.toml), and a deliberate redeploy is workflow_dispatch. Editing the deploy workflow used to publish the marketing site.

The change — release mode and a tested gate (decision 0100).

  • scripts/classify_release_mode.sh maps vX.Y.Z to a production release and vX.Y.Z-dryrun.N to a rehearsal, and refuses every other shape: a two-component version, a tag without the v prefix, an unknown prerelease suffix (-rc.1), a -dryrun without a number, a non-numeric number, a trailing hyphen, and a tag that names another version than the tree builds. The release job fails closed when the classifier refuses.
  • The release workflow gained a workflow_dispatch trigger that may only rehearse: a manual run whose tag classifies as a production release is refused, because cutting a production release requires a tag push.
  • packaging/release-assets.sh takes a mode. A production release is a draft titled by the version and described by its tracked release notes under docs/releases/ — a checksum dump is not a release description. A rehearsal is a prerelease titled TEST <version> whose notes carry a banner saying it must not be published.
  • A rehearsal's disposable tag is created at the exact commit the build ran on, so the artifacts and the tag bind to one SHA (without it, gh release create would create the tag at the default branch), and an existing tag is never overwritten.
  • The artifacts are named by the version the build produced, not by the tag: the tag says what is being released, the version says what was built. The build job derives the version once from cargo metadata.
  • scripts/validate_release_aggregate.sh holds the aggregate gate. It now also rejects an artifact the release does not publish (a stale file from an earlier version rode along unnoticed), and requires every required platform (x86_64-unknown-linux-gnu, wasm32-unknown-unknown) to be represented in the release manifest.

Verification. All five run locally on this tree and in the backup CI's changes job:

python3 scripts/validate_workflow_pins.py .github/workflows   → ✓ 19 action reference(s) … are pinned to commit SHAs
bash scripts/validate_workflow_pins-selftest.sh              → all 6 self-test cases passed
bash scripts/classify_release_mode-selftest.sh               → all 15 self-test cases passed
bash scripts/validate_release_aggregate-selftest.sh          → all 12 self-test cases passed
bash tests/release-assets-selftest.sh                        → all 12 self-test cases passed

Every failure mode is demonstrated with one deliberately broken input, including the discriminating case for the platform check — an artifact set where every file is present and every digest agrees, which still fails because nothing carries the wasm platform's target triple.

Not in this change. The rehearsal path has not been run end to end through workflow_dispatch: it needs this workflow on main first, and the manual rehearsal in issue #99 predates the TEST title and the prerelease flag, so it does not yet demonstrate this mode. The checks are in the GitHub backup CI; mirroring them into the GitForge pipeline waits for the :2 pipeline shape (PR #123, PR #129).

Decisions 0099 and 0100; ticket T27-57.

🤖 Generated with Claude Code

The release workflow builds the artifacts and creates the draft release,
and it named its actions by mutable reference: `actions/checkout@v4`,
`dtolnay/rust-toolchain@stable`, `actions/cache@v4`,
`actions/upload-artifact@v4`, `actions/download-artifact@v4` and
`cloudflare/wrangler-action@v3`. A tag can be moved and a branch moves on
its own, so the release path executed code that could change with no diff
to review here. All 19 action references now name a 40-hex commit SHA,
with the version kept in a trailing comment.

The rule is enforced, not reviewed: `scripts/validate_workflow_pins.py`
walks the workflow directory and fails with the file and line of every
unpinned reference, and it runs in the backup CI's `changes` job — the job
every push and pull request executes, and the one `fmt` and `gate` depend
on. `scripts/validate_workflow_pins-selftest.sh` demonstrates each failure
mode with one deliberately broken input (a mutable tag, a mutable branch,
a `docker://` image, a short SHA) and the two inputs the validator must
accept.

The deploy workflow triggered on a push that touched the workflow file
itself, so every edit to it published the marketing site. The trigger is
now the content it publishes — `site/**` and `wrangler.toml` — and a
deliberate redeploy is `workflow_dispatch`.

Decision 0099; ticket T27-57.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 311afd17-cee8-4383-8eec-803f9f66235f
Commits

Reviewing files that changed from the base of the PR and between e39705e and 002eb56.

Files selected for processing: 19

  • .github/workflows/ci.yml
  • .github/workflows/deploy-site.yml
  • .github/workflows/release.yml
  • docs/HANDOFF.md
  • docs/RELEASING.md
  • docs/TASKS.md
  • docs/decisions/0099-workflow-actions-are-pinned.md
  • docs/decisions/0100-release-mode-classification-and-a-tested-release-gate.md
  • docs/release/EXECUTION-BOARD.md
  • docs/tasks/T27-57-workflow-hardening.md
  • packaging/release-assets.sh
  • scripts/classify_release_mode-selftest.sh
  • scripts/classify_release_mode.sh
  • scripts/validate_release_aggregate-selftest.sh
  • scripts/validate_release_aggregate.sh
  • scripts/validate_workflow_pins-selftest.sh
  • scripts/validate_workflow_pins.py
  • tests/release-assets-selftest.sh
  • xtask/tests/release_workflow.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The release workflow decided what a tag meant nowhere: it derived the
artifact names from the tag, created every release as an ordinary draft,
and had no notion of a platform. The aggregate validation that stands
between a build and a release was a hundred lines of inline YAML that ran
once, on a tag, with nothing around it to tell it apart from a run that
was merely quiet.

Three seams are now scripts with self-tests, and all three run in the
backup CI's `changes` job:

* `scripts/classify_release_mode.sh` maps `vX.Y.Z` to a production
  release and `vX.Y.Z-dryrun.N` to a rehearsal, and refuses every other
  shape — a two-component version, a missing `v` prefix, an unknown
  suffix, a `-dryrun` without a number, a non-numeric number, a trailing
  hyphen, and a tag that names another version. A manual
  `workflow_dispatch` run may only rehearse; cutting a production
  release still requires a tag push.
* `scripts/validate_release_aggregate.sh` holds the fail-closed gate. It
  now also rejects an artifact the release does not publish, and requires
  every required platform to be represented in the release manifest — a
  skipped build was indistinguishable from a build nobody asked for.
* `packaging/release-assets.sh` takes a mode. A production release is a
  draft titled by the version and described by its tracked release notes;
  a rehearsal is a prerelease titled `TEST <version>` whose notes carry a
  banner saying it must not be published.

The rehearsal's disposable tag is created at the exact commit the build
ran on, so the artifacts and the tag bind to one SHA, and an existing tag
is never overwritten. The artifacts are named by the version the build
produced, not by the tag: the tag says what is being released, the
version says what was built.

Decision 0100 records the reasoning; the ticket, the execution board, the
task ledger, RELEASING.md and HANDOFF.md carry the new shape.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The release gate now refuses a production release whose description is
not a tracked `docs/releases/<tag>.md` note, but the release-workflow
tests still staged a bare `notes.md` — so the three tests that drive a
production `gh release create` failed on the new rule. The fixtures
now stage the notes where a real release keeps them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant