From 7ae0ae9153a7cc3351dd2301f260df0693312f0d Mon Sep 17 00:00:00 2001 From: bitWarrior <164793+bitWarrior@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:53:29 -0700 Subject: [PATCH] Refuse to publish a tag that is not on main Branch protection governs main; it says nothing about tags. The release build checked out the tagged commit and verified only that the tag matched _version.py -- a number, not a history. Anyone able to push a tag and publish a GitHub Release could therefore ship a commit that never passed review, and the deployment approval was the sole remaining control. Reproduced: a commit made off main with a backdoor added, tagged v1.2.2 with a matching _version.py, passes the version guard unchanged. The build now fetches main and refuses unless the tagged commit is reachable from it, before setting up Python or building anything -- an unreviewed commit should not produce an artifact at all. Verified the gate rejects the off-main tag and accepts both real tags, v1.2.1 and v1.3.0. Two supporting controls, applied to the repository rather than this file: - A ruleset blocking deletion and non-fast-forward updates of v* tags, so a published tag cannot be quietly repointed at different code. Note that tag rulesets cannot express "must be on main" -- there is no such rule type -- which is why the ancestry check lives in the workflow. - can_admins_bypass is now false on the pypi environment, so the approval wait applies to the owner too rather than being skippable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114gUUe4CxYr8W8oC95ffmD --- .github/workflows/release.yml | 16 ++++++++++++++++ CHANGELOG.md | 10 ++++++++++ docs/RELEASING.md | 10 +++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1312a24..5f437a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,23 @@ jobs: with: # Build the tag being released, never whatever the default branch is at. ref: ${{ github.event.release.tag_name || inputs.tag }} + # Full history so the tagged commit can be checked against main below. + fetch-depth: 0 persist-credentials: false + - name: Refuse to publish a commit that is not on main + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + # Branch protection governs main, not tags. Without this, anyone who can + # push a tag and publish a Release can ship a commit that never passed + # review -- the version guard below only checks the number, not the history. + git fetch --no-tags --quiet origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::$TAG points at $(git rev-parse --short HEAD), which is not reachable from main. Refusing to publish an unreviewed commit." + exit 1 + fi + echo "$TAG is on main ($(git rev-parse --short HEAD))" + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/CHANGELOG.md b/CHANGELOG.md index d6d219b..9eaaa09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to CodeSnake are documented here. The format follows ## [Unreleased] +### Security + +- **The release workflow refuses a tag that is not reachable from `main`.** Branch + protection governs `main`, not tags, so anyone able to push a tag and publish a + GitHub Release could previously ship a commit that never passed review — the + existing guard compared the tag to `_version.py` and said nothing about history. + Paired with a repository ruleset that blocks deleting or force-updating `v*` tags, + and `can_admins_bypass` turned off on the `pypi` environment so the approval applies + to the owner as well. + ## [1.3.0] - 2026-09-03 A security fix for anyone using CodeSnake as a CI gate. Upgrade if you run it against diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5dca413..409913b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -48,7 +48,15 @@ The build job refuses to publish when the release tag and `_version.py` disagree ## What protects the release path - **Trusted Publishing** — no long-lived credential exists to steal. -- **The `pypi` environment** — deployments are restricted to `v*` tags, and the publish job is the only thing granted `id-token: write`. +- **The tagged commit must be on `main`.** Branch protection governs `main`, not tags, so + without this anyone able to push a tag and publish a Release could ship a commit that + never passed review — the version guard only checks the number, not the history. The + build refuses a tag that is not reachable from `main`. +- **Release tags are immutable** — a ruleset blocks deleting or force-updating `v*`, so a + published tag cannot be repointed at different code after the fact. +- **The `pypi` environment** — deployments are restricted to `v*` tags, the publish job is + the only thing granted `id-token: write`, and approval is required with + `can_admins_bypass` off, so the wait applies to the owner too. - **Split jobs** — the build job has `contents: read` and no OIDC; the publish job has OIDC and never checks out the repository. Code from the repo and the ability to publish never sit in the same job. - **Pinned actions** — every action is pinned to a commit SHA, not a mutable tag.