Skip to content

ci: lockfile gate, release tag guard, and SHA-pinned actions#3

Merged
stevenwcarter merged 4 commits into
mainfrom
chore/ci-release-followups
Jul 26, 2026
Merged

ci: lockfile gate, release tag guard, and SHA-pinned actions#3
stevenwcarter merged 4 commits into
mainfrom
chore/ci-release-followups

Conversation

@stevenwcarter

Copy link
Copy Markdown
Owner

Clears the four CI/release follow-ups left over from the 2026-07-25 branch reviews.

What's here

1. Fail a PR whose Cargo.lock is stale
release.yml builds with --locked; nothing else did, so a stale lock passed every PR check and first failed at tag-push time — the most expensive moment to find out. New just check-lock recipe covers all six committed lockfiles (workspace + each of the five excluded plugins/*, which the root cargo commands never see), wired into ci.yml's test job.

Kept as its own recipe rather than bolting --locked onto test/lint, so a local just test keeps auto-refreshing the lock as it does today. Uses cargo metadata, which resolves without compiling — about a second.

2. Refuse a tag that disagrees with the built binary
Tarballs are named from GITHUB_REF_NAME while the binary reports CARGO_PKG_VERSION, and nothing connected the two — a v0.2.0 tag would happily ship a binary calling itself 0.1.0. The guard asks the freshly built binary (rustline --version) rather than re-reading Cargo.toml, so it checks the artifact actually being shipped, at no cost (Package already runs that binary for completions).

Gated on github.ref_type == 'tag' — a workflow_dispatch dry run is on a branch, whose name is not a version, so an ungated check would fail every dry run.

3. SHA-pin the actions + Dependabot
All seven actions pinned to full commit SHAs in both workflows with # vX.Y.Z comments, plus .github/dependabot.yml so the pins get refreshed by PR instead of rotting into frozen unpatched versions. Dependabot is deliberately github-actions only — Rust bumps go through /upgrade-packages, and cargo PRs would bury the action refreshes.

4. macos-13 runner label — already resolved by 3ac7c08, which dropped the Intel Mac leg outright rather than keep depending on that label. No change needed here.

Two things worth knowing about the pins

  • dtolnay/rust-toolchain's ref selects the toolchain. Each branch (stable, nightly, 1.90.0) ships an action.yml whose toolchain input defaults to that branch's own name. Its pin is the tip of the stable branch — verified by reading action.yml at that SHA. Repointing it at another branch's commit would silently change the Rust version with nothing in the diff to say so. It publishes no release tags, so Dependabot can't bump that one.
  • This pins action code, not the Rust version. stable still resolves at job time, so a new stable release can still redden CI with no repo change. Fixing that needs a rust-toolchain.toml, which CLAUDE.md rules out (it would change every developer's local toolchain resolution).

Also: Swatinem/rust-cache is pinned to the v2.9.1 release commit rather than what the floating v2 tag resolved to — v2 sat exactly one changelog-only commit ahead, so this is functionally identical and makes the comment truthful.

Verification

  • just check-lock proven to catch something, not merely to pass: temporarily adding an unlocked dependency to a workspace member and, separately, to plugins/counter makes it fail in both cases.
  • The release guard can't be exercised without pushing a tag, so its shell body was run locally against fabricated refs — matching tag passes, mismatched tag fails with the ::error:: annotation, and a branch-shaped ref fails (which is what makes the ref_type gate load-bearing rather than decorative).
  • Every pinned SHA checked against the version its comment claims via the GitHub API.
  • just check-lock, just lint, and just test all pass — 937 tests, 13 binaries, 0 failures.
  • Both workflows and dependabot.yml parse as YAML. actionlint was not available in this environment and was not run.

No Rust source is touched on this branch.

https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG

release.yml builds with `--locked`, but `just test`/`just lint` do not, so a
stale lock passed every PR check and first failed at tag-push time -- the one
moment it is most expensive to discover.

Add a `just check-lock` recipe covering all six committed lockfiles (the
workspace plus each of the five excluded plugins/*, which the root cargo
commands never see) and run it in ci.yml's test job ahead of `just test`.

Kept as its own recipe rather than bolting `--locked` onto test/lint so a
local `just test` keeps auto-refreshing the lock as it does today. It uses
`cargo metadata`, which resolves without compiling, so the gate costs about a
second.

Verified it actually catches something, not just that it passes: temporarily
adding an unlocked dependency to a workspace member and, separately, to
plugins/counter makes the recipe fail in both cases.

Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
Tarballs are named from GITHUB_REF_NAME while the binary reports
CARGO_PKG_VERSION, and nothing connected the two -- a `v0.2.0` tag would
happily ship a binary that identifies itself as 0.1.0, under a filename
claiming otherwise.

Add a guard between Build and Package on every leg. It asks the freshly built
binary (`rustline --version`) instead of re-reading Cargo.toml, so it checks
the artifact actually being shipped; that costs nothing, since Package already
runs the same binary to emit shell completions and every target is native to
its runner.

Gated on `github.ref_type == 'tag'`. A workflow_dispatch dry run is on a
branch, whose name is not a version, so an ungated check would fail every dry
run -- confirmed by running the step's shell body locally against a
branch-shaped ref, alongside the matching and mismatching tag cases.

Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
Every action was on a mutable major tag (@v4, @v2) and dtolnay/rust-toolchain
on a *branch*, so upstream could change under us -- and release.yml runs with
`contents: write`, which is where that matters.

Pin all seven to full commit SHAs in both workflows, each carrying a trailing
`# vX.Y.Z` comment, and add .github/dependabot.yml so the pins get refreshed by
PR rather than silently rotting into frozen unpatched versions. Dependabot is
deliberately github-actions only: Rust bumps go through /upgrade-packages, and
cargo PRs would bury the action refreshes this exists to surface.

Two things worth recording about the pins themselves:

dtolnay/rust-toolchain's ref is not merely a version, it selects the toolchain
-- each branch (stable, nightly, 1.90.0) ships an action.yml whose `toolchain`
input defaults to that branch's own name. Its pin is therefore the tip of the
`stable` branch, verified by reading action.yml at that SHA and confirming the
default is `stable`. Repointing it at another branch's commit would silently
change the Rust version with nothing in the diff to say so. It publishes no
release tags, so dependabot cannot bump that one.

Swatinem/rust-cache is pinned to the v2.9.1 release commit rather than to what
the floating v2 tag resolved to; v2 sat exactly one changelog-only commit ahead
of the release, so this is functionally identical and makes the comment true.

Note this pins the action *code*, not the Rust version: `stable` still resolves
at job time, so a new stable release can still redden CI with no repo change.
Addressing that would need a rust-toolchain.toml, which CLAUDE.md rules out
because it would change every developer's local toolchain resolution.

Each pinned SHA was verified against the version its comment claims via the
GitHub API.

Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
CLAUDE.md's Development section described the CI/release workflows in enough
detail that all three changes on this branch would have left it stale.

- `just check-lock` added to the recipe list and to the "commit Cargo.lock
  alongside any dependency change" bullet, which now has a gate behind it.
- The CI paragraph notes the test job runs check-lock ahead of just test.
- A new bullet covers the SHA pins, dependabot's github-actions-only scope,
  the dtolnay/rust-toolchain ref-selects-the-toolchain wrinkle, and the fact
  that pinning fixes action code but not the Rust version.
- The release roadmap entry documents the tag/version guard and why it is
  gated on ref_type == 'tag'.

README gains check-lock in its recipe list, framed around what it catches.

Claude-Session: https://claude.ai/code/session_01QUGuvDUj6gG5xQPiN3X2bG
@stevenwcarter
stevenwcarter merged commit 128cd8a into main Jul 26, 2026
4 checks passed
@stevenwcarter
stevenwcarter deleted the chore/ci-release-followups branch July 26, 2026 14:17
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