fix(ci): refuse a release tag that is not an ancestor of main #16

Merged
jlxq0 merged 3 commits from fix/tag-ancestry-gate into main 2026-08-27 03:00:30 +00:00
Owner

Closes half of #14. v0.2.7 and v0.2.8 were cut from branches that were never merged and both published an image. The only gate on a tag build was that the tag matches Cargo.toml; ancestry was never consulted, in either pipeline.

What changed

.forgejo/workflows/ci.yml gains a tag-ancestry job that docker now needs. It runs on every event rather than being skipped on non-tag builds, because a skipped job reports success — the exact failure mode this repository already documents for docker itself, and the reason CI / docker is not a required status context.

.github/workflows/release.yml gains the equivalent step before it publishes to GHCR. Both pipelines had the same hole.

This also makes the buildcache comment at ci.yml:135 true rather than hoped for: "a tag build is the same commit as the main build that preceded it" only holds if the tag is on main.

Watched it fail

Logic first, against real data, before wiring anything:

v0.2.7  (b5b1f41, not on main)  -> exit 1   ERROR: ... is not an ancestor of main
v0.2.14 (41d6c8f0, on main)     -> exit 0   ok: ... is an ancestor of main
refs/heads/main                 -> exit 0   not a tag build; nothing to check

Then in CI on the real runner. This branch tip is itself not an ancestor of main, so tagging it is the negative case with the gate already present at the tagged commit:

tag v0.0.0-ancestry-probe -> 859399b
  cargo         = success      <- the build was fine
  tag-ancestry  = failure      <- the gate is what stopped it
  docker        = never created
  image         = none published

cargo passing is what makes that attributable: the block came from the gate, not from a broken build. Probe tag deleted.

Mutation-tested the peel

git rev-parse on an annotated tag returns the tag object, so I peeled with ^{commit}. Checking whether that was load-bearing: git merge-base --is-ancestor peels on its own, and both forms give identical verdicts on v0.2.7 (fail) and v0.2.14 (pass). So the peel does not change the gate outcome and is kept only so the sha in the error message names the commit. Recorded honestly in AGENTS.md rather than claimed as a fix.

Where it does bite is comparison, and that is measured:

git rev-parse v0.2.14           = 22b4507c   (the tag object)
git rev-parse v0.2.14^{commit}  = 41d6c8f0   (the commit)
[ "$(git rev-parse v0.2.14)" = "$(git rev-parse 41d6c8f0)" ]  ->  NO MATCH

A confident wrong answer for a tag that does point at that commit.

Not closed by this

v0.2.5 and v0.2.6 are images with no git tag at all. An ancestry gate cannot reach them, because a tag can be deleted after its image is published. Left open on #14.

Codex reviewed the diff for bypasses: shallow clones, detached tag checkout, missing origin/main, lightweight vs annotated tags, forks, and whether docker can run when tag-ancestry is skipped or fails. Answer: None.

Refs #14

Closes half of #14. `v0.2.7` and `v0.2.8` were cut from branches that were never merged and both published an image. The only gate on a tag build was that the tag matches `Cargo.toml`; ancestry was never consulted, in either pipeline. ## What changed `.forgejo/workflows/ci.yml` gains a `tag-ancestry` job that `docker` now `needs`. It runs on **every** event rather than being skipped on non-tag builds, because a skipped job reports `success` — the exact failure mode this repository already documents for `docker` itself, and the reason `CI / docker` is not a required status context. `.github/workflows/release.yml` gains the equivalent step before it publishes to GHCR. Both pipelines had the same hole. This also makes the buildcache comment at `ci.yml:135` true rather than hoped for: *"a tag build is the same commit as the `main` build that preceded it"* only holds if the tag is on `main`. ## Watched it fail Logic first, against real data, before wiring anything: ``` v0.2.7 (b5b1f41, not on main) -> exit 1 ERROR: ... is not an ancestor of main v0.2.14 (41d6c8f0, on main) -> exit 0 ok: ... is an ancestor of main refs/heads/main -> exit 0 not a tag build; nothing to check ``` Then in CI on the real runner. This branch tip is itself not an ancestor of `main`, so tagging it is the negative case with the gate already present at the tagged commit: ``` tag v0.0.0-ancestry-probe -> 859399b cargo = success <- the build was fine tag-ancestry = failure <- the gate is what stopped it docker = never created image = none published ``` `cargo` passing is what makes that attributable: the block came from the gate, not from a broken build. Probe tag deleted. ## Mutation-tested the peel `git rev-parse` on an annotated tag returns the tag object, so I peeled with `^{commit}`. Checking whether that was load-bearing: `git merge-base --is-ancestor` peels on its own, and both forms give identical verdicts on `v0.2.7` (fail) and `v0.2.14` (pass). So the peel does **not** change the gate outcome and is kept only so the sha in the error message names the commit. Recorded honestly in `AGENTS.md` rather than claimed as a fix. Where it does bite is comparison, and that is measured: ``` git rev-parse v0.2.14 = 22b4507c (the tag object) git rev-parse v0.2.14^{commit} = 41d6c8f0 (the commit) [ "$(git rev-parse v0.2.14)" = "$(git rev-parse 41d6c8f0)" ] -> NO MATCH ``` A confident wrong answer for a tag that does point at that commit. ## Not closed by this `v0.2.5` and `v0.2.6` are images with no git tag at all. An ancestry gate cannot reach them, because a tag can be deleted after its image is published. Left open on #14. Codex reviewed the diff for bypasses: shallow clones, detached tag checkout, missing `origin/main`, lightweight vs annotated tags, forks, and whether `docker` can run when `tag-ancestry` is skipped or fails. Answer: `None`. Refs #14
fix(ci): refuse a release tag that is not an ancestor of main
Some checks failed
CI / tag-ancestry (push) Failing after 4s
CI / cargo (push) Successful in 1m12s
CI / docker (push) Has been skipped
CI / tag-ancestry (pull_request) Successful in 3s
CI / cargo (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
859399b4f2
v0.2.7 and v0.2.8 were cut from branches that were never merged and both
published an image. The only gate on a tag build was that the tag matches
Cargo.toml's version; ancestry was never consulted, in either pipeline.

The Forgejo workflow gains a `tag-ancestry` job that `docker` now needs. It
runs on every event rather than being skipped on non-tag builds, because a
skipped job reports success -- the exact failure mode this repository already
documents for `docker` itself. The GitHub release workflow gains the
equivalent step before it publishes to GHCR.

This also makes the buildcache comment in the `docker` job true rather than
hoped for: "a tag build is the same commit as the `main` build that preceded
it" only holds if the tag is on `main`.

Verified against real data before wiring: v0.2.7 (b5b1f41, not on main) exits
1, v0.2.14 (41d6c8f0, on main) exits 0, a branch push is a no-op.

Also records in AGENTS.md that `git rev-parse` on an annotated tag returns the
tag object. Mutation-tested: `merge-base --is-ancestor` peels on its own, so
the peel is not load-bearing there, but a string comparison on `rev-parse`
output reports NO MATCH for a tag that does point at the commit.

Refs #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(agents): a pending required context is not always a queue
Some checks failed
CI / tag-ancestry (pull_request) Successful in 3s
CI / cargo (pull_request) Successful in 1m53s
CI / docker (pull_request) Has been cancelled
31ede06259
PR #16 opened with three pending pull_request contexts and no workflow run
ever scheduled; the merge API returned 405 indefinitely. Records that, and
the three ways a gate's state stops being about the code that the on: block
answers and past commit statuses do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(agents): correct the pending-context entry, it was a queue
All checks were successful
CI / cargo (pull_request) Successful in 1m9s
CI / tag-ancestry (pull_request) Successful in 3s
CI / docker (pull_request) Successful in 16s
2affe85cf8
The previous commit claimed no run was ever scheduled for PR #16. The task
timestamps say otherwise: the PR opened 02:27:23 and its run was created
02:38:53, 11m30s later, on a runner shared fleet-wide at capacity 1. A queued
run is invisible in the tasks API because created_at is when it starts.

Worse, the second commit I pushed to 're-trigger' cancelled that queued run
under ref-scoped concurrency and requeued from the back; the replacement
started 02:46:26. The push cost nine minutes rather than saving them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jlxq0 merged commit 236f9a6192 into main 2026-08-27 03:00:30 +00:00
jlxq0 deleted branch fix/tag-ancestry-gate 2026-08-27 03:00:30 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/jmap-mcp!16
No description provided.