Skip to content

fix(ci): refuse a release tag that is not the version Cargo.toml declares - #304

Merged
argszero merged 1 commit into
mainfrom
fix/release-tag-must-match-the-manifest
Sep 26, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/release-tag-must-match-the-manifest

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The published image's tag and its contents state the same fact — the release version — and nothing tied them together.

  • The tag comes from the git ref: docker-publish.yml feeds type=ref,event=tag to docker/metadata-action.
  • The contents report the manifest: the binary answers /healthz with env!("CARGO_PKG_VERSION") (src/routes/mod.rs:187).

grep -rn "ref_name\|GITHUB_REF\|github.ref" .github/ returned nothing, so pushing a tag that does not match Cargo.toml (or back-filling a tag for an older release) published an image under a version its own binary does not report. That is a public surface: both READMEs teach docker pull ghcr.io/argszero/aitokenpool:latest (lines 39/42), and the tag is exactly what a release is supposed to pin.

This adds one step to the publish job that refuses such a tag before any build work starts — it fails closed, so it prefers a red run over a mislabelled image. The check is conditioned on startsWith(github.ref, 'refs/tags/'), because workflow_dispatch's GITHUB_REF_NAME is a branch name and comparing it there would kill manual re-publishes.

The expected value is read from Cargo.toml itself: no copy of the version is added to the workflow and no third-party action is introduced. src/deploy_gate.rs (R78) already guards the copies in docker-compose.yml / Dockerfile; this guards the release chain's input end, which that gate cannot see (its FILES set does not include workflows).

No drift has happened yet — the last 10 tags each match the manifest at their commit (v0.7.19 … v0.7.28) — so this is a latent gap, not an incident. It is the same shape as the declared MSRV that had no enforcer (#302): a declaration with a consumer and nothing checking it.

Related Issue

(no linked issue — not filed as one because it is a one-line-shaped fix with no ambiguity about the intended behaviour; say the word and I will open one and rebase the reference in)

Changes

  • docker-publish.yml: new step Refuse a tag that is not the version Cargo.toml declares, placed after actions/checkout and before Set up Docker Buildx, i.e. before anything is built, logged in to, or pushed.
  • No configuration/data-structure change; no new file; no new dependency.

Tests

  • cargo test — 399 passed / 0 failed (unchanged from the baseline; no Rust source is touched)
  • cargo fmt --check — rc=0
  • cargo clippy --all-targets -- -D warnings — rc=0
  • New tests: N/A — this repository has no harness that executes workflow YAML (the workflows are hand-written; ci.yml's MSRV assertion has no test either). The replacement evidence is the honest boundary below.

What was measured, and what could not be

This step is reachable only on a tag ref, which is exactly why it cannot be exercised by the very PR that adds it — the Docker Publish workflow does not run on main pushes or pull requests (the comment at the top of that file says so deliberately, from rant 2026-08-22T07:14:15). So the evidence is split, and I would rather state the split than imply a green check proves it:

Measured locally, on the exact bytes that ship. The run: block was extracted from the workflow file itself (YAML-parsed, not re-typed) and executed under the runner's shell flags (bash -e -o pipefail), against the real Cargo.toml:

GITHUB_REF_NAME rc result
v0.7.28 (matches) 0 passes, Cargo.toml version = 0.7.28; release tag = v0.7.28
v0.7.29 1 ::error:: — release tag does not match
latest 1 ::error::
`` (empty) 1 ::error:: — fails closed rather than passing on a missing variable
v0.7.28-rc1 1 ::error::

A negative control (a copy of Cargo.toml whose [package] version was changed to 9.9.9) shows the extractor follows the manifest rather than a cached value: the same block then accepts v9.9.9 and rejects v0.7.28. The extractor's output agrees with two independent readers (grep -m1 '^version' and a section-scoped parse) on the real file.

The version is read with a single-process awk deliberately: an earlier draft used sed … | head -n1, and under the runner's -o pipefail a SIGPIPE race on that pipe would have failed the release, not the check.

Not measured / residual risk, stated plainly:

  • GitHub's own if: evaluation and GITHUB_REF_NAME for a real tag push are not exercised here (I did not push a tag — that would publish a real image). The condition is the standard startsWith(github.ref, 'refs/tags/'), and a dispatch on a branch ref evaluates to false ⇒ step skipped, so manual re-publish keeps working.
  • The job was not run end-to-end (no docker build here; this machine has no running Docker daemon). What the step does is fail before that point, so the paths it can affect are only "stop" or "continue".
  • If the extraction ever returns nothing, the comparison compares "v" against the tag and fails — fail-closed by construction, not silently passing.

I am happy to prove it live instead: if you push (or let me push) a tag that intentionally mismatches in a scratch fork, the run should fail at this step. I did not do that against this repository because a tag push here also publishes to GHCR and moves latest.

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits (fix(ci): …), author argszero <argszero@gmail.com>
  • Single responsibility, minimal diff (1 file, +20 lines, no production code)

…ares

The image tag comes from the git ref (docker/metadata-action's
type=ref,event=tag) while the binary inside the image reports the version
Cargo.toml declares (routes/mod.rs /healthz -> env!("CARGO_PKG_VERSION")).
Nothing tied the two together: pushing a tag that does not match the
manifest (or back-filling a tag for an old release) published an image
under a version its own binary does not report - and README teaches
ghcr.io/<repo>:latest as the public pull path.

Add a step that runs before any build work and only on tag refs
(workflow_dispatch's GITHUB_REF_NAME is a branch name, so the check is
conditioned on refs/tags/). The expected value is read from Cargo.toml;
no copy of the version is added to the workflow and no third-party action
is introduced. deploy_gate.rs guards the copies in docker-compose.yml /
Dockerfile; this guards the release chain's input end.
@argszero
argszero merged commit a9bbf72 into main Sep 26, 2026
2 checks passed
@argszero
argszero deleted the fix/release-tag-must-match-the-manifest branch September 26, 2026 20:58
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