Skip to content

ci: tag the release from the release PR merge - #238

Merged
matheuswhite merged 1 commit into
mainfrom
ci/tag-on-release-merge
Aug 3, 2026
Merged

ci: tag the release from the release PR merge#238
matheuswhite merged 1 commit into
mainfrom
ci/tag-on-release-merge

Conversation

@matheuswhite

Copy link
Copy Markdown
Owner

Releasing took two steps: merge the version-bump PR, then remember to push the matching vX.Y.Z tag by hand. That manual tag is the weak point — it is the actual release trigger, it can be forgotten, mistyped or pushed from a dirty checkout, and it sits outside the reviewed, gated PR flow.

release-tag.yml closes the gap: a version bump landing on main cuts the tag, so review + merge is the whole release. No manual tagging step.

The two constraints that shaped this

Both are non-obvious and are the reason the workflow looks the way it does:

  1. A GITHUB_TOKEN push does not trigger further workflow runs. If the tag were pushed with the built-in token, release.yml and publish-crates.yml would never fire and the automation would be pointless. So the tag is pushed with a maintainer PAT (RELEASE_TAG_TOKEN). Tag creation is also admin-only per the release-tags ruleset, which the owner's PAT satisfies — and it keeps github.actor on the tag push equal to the owner, so publish-crates.yml's owner guard needed no loosening.
  2. The trigger must be push, never pull_request. For a push, GitHub always takes the workflow definition from the pushed branch (main, already behind code-owner review). A pull_request run uses the PR branch's copy of the definition — which would let a pull request rewrite the very file that holds the tagging token. This one is pinned by a test.

Guards

Nothing here weakens the existing model; it adds gates:

  • the pusher must be the repository owner (github.actor guard);
  • the commit must belong to a merged PR carrying the release label — the same opt-in version-guard already demands before allowing a bump, so a single label now drives the entire release;
  • the version must actually have changed (paths: Cargo.toml also fires for dependency edits);
  • an existing tag is never moved (idempotent re-runs);
  • the workflow's own GITHUB_TOKEN stays contents: read — it publishes nothing itself, it only creates the tag;
  • if the PAT is missing it fails loudly with a pointer to the docs, rather than silently skipping a release.

Verification

  • tests/release_security.rs grows from 6 to 8 tests, pinning the pull_request-free trigger and the owner/label/environment guards. Both new tests were verified to fail when the trigger or the guard is weakened — they are not decorative.
  • The decision script was exercised against a scratch git repo with gh and git ls-remote stubbed, covering all six paths: legitimate bump (cut=true), missing release label, pre-existing tag, dependency-only Cargo.toml edit, and both github.event.before fallbacks (force-push and an unknown SHA). All behave as intended.
  • Confirmed fail-closed: if the gh api label lookup errors, pipefail routes it to the skip path — a failure never tags.
  • cargo fmt --check clean.

⚠️ One-time setup required before the next release

This needs a release-tag environment holding a RELEASE_TAG_TOKEN secret (a fine-grained PAT, this repo only, Contents: read and write, deployment branches limited to main). Full steps are in CONTRIBUTING.md → Release security → One-time setup for automatic tagging. Until it exists, the tagging job fails with an explicit error instead of skipping quietly.

The trade-off is stated plainly: a long-lived PAT becomes a sensitive asset in the repo. That is unavoidable given constraint (1) above; it is mitigated by minimal scope, a dedicated environment, and a rotation note. The alternative — a GitHub App token — would make github.actor app[bot] and force changes to publish-crates.yml's owner guard and the test protecting it: more churn on the security surface, not less.

Docs updated alongside: CONTRIBUTING.md, CLAUDE.md, and the header comments of version-guard.yml and publish-crates.yml, which still described the manual-tag flow.

🤖 Generated with Claude Code

Releasing took two steps: merge the version-bump PR, then remember to push
the matching vX.Y.Z tag by hand. That manual tag is the weak point — it is
the actual release trigger, it can be forgotten, mistyped or pushed from a
dirty checkout, and it sits outside the reviewed, gated PR flow.

release-tag.yml closes the gap: a version bump landing on main tags vX.Y.Z,
so review + merge is the whole release.

Security-relevant choices:

- It triggers on `push`, never `pull_request`. A push run always takes the
  workflow definition from main, whereas a pull_request run uses the PR
  branch's copy — which would let a PR rewrite the very file that holds the
  tagging token.
- The tag is pushed with a maintainer PAT rather than GITHUB_TOKEN: a
  GITHUB_TOKEN push does not trigger further workflow runs (release.yml
  would never fire), tag creation is admin-only per the release-tags
  ruleset, and the owner's PAT keeps github.actor on the tag push equal to
  the owner, so publish-crates.yml's owner guard still holds.
- Guards: the pusher must be the repository owner, the commit must belong to
  a merged PR labelled `release` (the same opt-in version-guard already
  requires for a bump, so one label drives the whole release), the version
  must actually have changed (`paths: Cargo.toml` also fires for dependency
  edits), and an existing tag is never moved. The workflow's own
  GITHUB_TOKEN stays `contents: read`; it publishes nothing itself.

Two new tests in release_security.rs pin the pull_request-free trigger and
the owner/label/environment guards; both were verified to fail when the
trigger or the guard is weakened. The decision script was also exercised
against a scratch repo with gh and ls-remote stubbed, covering a legitimate
bump, a missing label, an existing tag, a dependency-only Cargo.toml edit,
and both github.event.before fallbacks.

Needs one-time repo setup: the `release-tag` environment and its
RELEASE_TAG_TOKEN secret, documented in CONTRIBUTING.md ("Release
security").

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Matheus T. dos Santos <[email protected]>
@matheuswhite matheuswhite added the ci Continuous integration label Jul 30, 2026
@matheuswhite
matheuswhite merged commit 275f243 into main Aug 3, 2026
14 checks passed
@matheuswhite
matheuswhite deleted the ci/tag-on-release-merge branch August 3, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci Continuous integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant