Skip to content

ci(release): release only tags whose commit is on the default branch - #94

Merged
magicdrive merged 2 commits into
mainfrom
ci/release-tag-provenance
Oct 27, 2026
Merged

magicdrive merged 2 commits into
mainfrom
ci/release-tag-provenance

Conversation

@magicdrive

Copy link
Copy Markdown
Owner

Problem

The release workflow runs on any pushed v* tag and publishes whatever commit the tag names. Nothing checks that this commit is in the history of the default branch (main): a v* tag pushed on a feature branch commit, or on a commit that is on no branch at all, would be built, attested and published (and, for a stable version, sent to the Homebrew tap). The tag ruleset blocks moving and deleting v* tags, but not creating one on an arbitrary commit; only the local hephaestus publish checks the branch, and a tag can be pushed without it.

Root Cause

The workflow trusts the tag push event. GITHUB_SHA is whatever the tag points to; no job relates it to an allowed release branch.

Solution

A new provenance job runs .github/release/verify-tag-provenance.sh in a full clone (fetch-depth: 0, persist-credentials: false); build and release need it, so nothing is built or published unless it passes. The script:

  • accepts only a tag ref (GITHUB_REF=refs/tags/<tag>), a full lowercase SHA (GITHUB_SHA) and exact branch names (no patterns); the allowed list in this workflow is main only;
  • fails on a shallow clone (git rev-parse --is-shallow-repository must print false) and unless HEAD is GITHUB_SHA;
  • fetches the tag and every allowed branch from the remote in one git fetch (one consistent ref advertisement) into a private namespace (refs/release-provenance/*), so neither the checkout's local refs nor a tag moved after the run started are trusted;
  • peels the tag (^{commit}, annotated or lightweight) and requires it to be GITHUB_SHA;
  • requires git merge-base --is-ancestor GITHUB_SHA <branch> to exit 0 for an allowed branch. Exit 1 means "not in this branch"; any other status (128, ...) fails the check and is never read as "no" or "yes". git branch --contains output is not parsed.

Anything it cannot establish (failed fetch, missing branch or tag, git error) fails closed. To release from a maintenance branch later, add its exact name after main in the run: line.

The publish step itself already refuses a tag that no longer points to the built commit (Hephaestus release re-reads the tag through the API), and the tag ruleset forbids moving a v* tag, so a move after this job is caught there.

Security Impact

Closes the gap where a v* tag created on a commit outside the default branch's history is released. No new token, permission or secret: the job has contents: read and fetches anonymously (the repository is public).

Compatibility

Every release tag published by this workflow so far is in main's history (checked for all tags with git merge-base --is-ancestor); the older tags v0.0.1 to v3.2.0 (released before Hephaestus, from a line main does not contain) are not, but they were published by earlier workflows and are never re-released by a new run. No release has been cut from a release/* branch (v6.5.x is on main), so none is allowed. Re-runs of runs started before this change use the workflow of their original commit and are unaffected. The release adds one short job (a full clone).

Tests

  • .github/release/verify-tag-provenance_test.sh builds local repositories (no network) and checks 22 cases: default branch lightweight/annotated/older tag (pass); detached commit, branch not allowed, maintenance branch not allowed (fail); allowed maintenance branch (pass); shallow clone (fail); SHA not the tag's commit, HEAD not the SHA, tag moved or deleted after the run started, stale local tag (fail); missing allowed branch, unreachable remote, merge-base exiting 128, merge-base saying no, not a repository (fail); non-tag ref, short SHA, branch pattern (fail); usage error (exit 2).
  • RED: against a no-op check (today's workflow) 19 of 22 cases fail. Each of 8 mutants of the script (128 read as "no", no shallow check, local tag trusted, no SHA comparison, no HEAD check, no peel, patterns allowed, fetch failure ignored) is caught by at least one case.
  • actionlint, shellcheck, hephaestus check --workflows (the release workflow conventions) pass. release_workflow_test.go now pins the provenance job (build and release need it, fetch-depth: 0, the exact allowed list, no condition) with 5 new mutations, each caught; with the previous release.yml the test fails (RED).

CI

The CI workflow runs shellcheck and the test script (step "Release tag provenance check"), on pull requests and in the release's ci job.

Migration

None. Releasing from a maintenance branch requires adding it to the allowed list in release.yml first (deliberate, reviewed change).

Risks

  • A future release from a branch other than main fails until the branch is added: intended (fail closed).
  • If the repository became private, the anonymous fetch would fail and the release would stop (fail closed); the job would then need a read token.

https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg

A new provenance job runs .github/release/verify-tag-provenance.sh in a
full clone before anything is built: it fetches the tag and the allowed
release branch afresh in one fetch, requires the peeled tag to be
GITHUB_SHA (and HEAD), and requires git merge-base --is-ancestor to exit 0
for an allowed branch (exit 1 is "no", any other status fails). A shallow
clone, a missing ref or any git error fails closed. build and release need
the job. The CI workflow runs shellcheck and the script's tests.

Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg
Resolve release.yml: keep the provenance needs and the pinned
ubuntu-24.04 runners, and pin the provenance job's runner the same way.

Claude-Session: https://claude.ai/code/session_01RbGbwRHvCEhsKLZfbvbbRg
@magicdrive
magicdrive merged commit 6f62bc6 into main Oct 27, 2026
28 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.

1 participant