Skip to content

Commit 3fe7bd2

Browse files
Add release-tag consistency guards to CI and release workflows (#250)
* Add release-tag consistency guards to CI and release workflows Close the gap that let main claim v0.14.0 as the latest release for a day before the tag existed (README's @v0.14.0 Action pin 404'd for anyone copying it; ROADMAP said "Latest release: v0.14.0" while PyPI was at 0.13.0). Public surfaces already move in lockstep with pyproject.toml (tests/test_public_surface_contract.py), but nothing checked the lockstep claim against release reality. Two guards: - ci.yml release-tag-consistency job (pushes to main only, never PRs): the tag v{pyproject version} must exist on origin, so a merged version bump without a pushed tag turns main red with the exact command to cut the release. - release.yml fail-fast step: the pushed tag must equal v{pyproject version} before the test/build/publish pipeline runs — PyPI uploads are immutable, so this is the last cheap moment to catch a mistagged release. ROADMAP's latest-release line now links to the GitHub /releases/latest page (no version in the URL, so nothing new to bump) and names the CI job that enforces it. Phrasing is unchanged so the existing VERSION_LITERAL_TARGETS pin in test_public_surface_contract.py still matches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release-tag-consistency: document the retest path and tag-only scope Review finding on #250: the failure text told maintainers to push the missing tag, but a tag push triggers release.yml — it does not retest the failed main run, which stays red until re-run or the next main push. Name the working recovery explicitly ("Re-run failed jobs" re-checks origin's live tag list) in both the error output and the job comment, and explain why a tag trigger on this workflow would not help (new run on the tag ref, badge stays red, duplicates the full test job release.yml already runs on tags). Also make the guard's tag-only scope explicit: the tag triggers release.yml, the canonical publisher, which fails loudly if the PyPI upload or GitHub Release step breaks — so tag-exists plus a green release run covers /releases/latest and PyPI. Querying PyPI here would add an external dependency that lags publish by minutes and can false-fail right after a legitimate release.
1 parent 7b6150c commit 3fe7bd2

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,61 @@ jobs:
8686
with:
8787
name: sbom
8888
path: sbom.json
89+
90+
release-tag-consistency:
91+
# Public surfaces (ROADMAP.md's latest-release line, the README
92+
# Action pin, install snippets) all name `v{pyproject version}` and
93+
# move in lockstep — tests/test_public_surface_contract.py enforces
94+
# that. What no test could catch: the whole repo moving in lockstep
95+
# to a version that was never actually released. That happened with
96+
# v0.14.0 (pyproject and the README `@v0.14.0` Action pin landed on
97+
# main a day before the tag existed, so the copy-paste workflow
98+
# 404'd). This job checks the claim against release reality: the
99+
# tag must exist on origin.
100+
#
101+
# Runs on pushes to main only (the `push` trigger above is scoped
102+
# to main), never on pull_request — so a version-bump PR is never
103+
# blocked, but a merged bump without a pushed tag turns main red
104+
# until the tag is pushed or the claim is reverted.
105+
#
106+
# Recovery: pushing the tag triggers release.yml, not this run, so
107+
# a failed run does not clear itself on tag push. It re-checks
108+
# origin's live tag list on re-run — after pushing the tag, use
109+
# "Re-run failed jobs" on this run (or wait for the next push to
110+
# main). A tag trigger on this workflow would not help: it would
111+
# start a *new* run on the tag ref, leave the failed branch run
112+
# red on the CI badge, and re-run the full test job that
113+
# release.yml already runs on the tag.
114+
#
115+
# Deliberately tag-only: the tag is what triggers release.yml, the
116+
# canonical publisher, which itself fails loudly if the PyPI upload
117+
# or GitHub Release step breaks — so "tag exists + release.yml
118+
# green" covers /releases/latest and PyPI. Querying PyPI here would
119+
# add an external dependency that lags publish by minutes (the
120+
# /json index is CDN-cached) and can false-fail right after a
121+
# legitimate release.
122+
if: github.event_name == 'push'
123+
runs-on: ubuntu-latest
124+
timeout-minutes: 5
125+
126+
steps:
127+
- name: Checkout
128+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
129+
130+
- name: Verify the claimed latest release tag exists on origin
131+
run: |
132+
version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)"
133+
if [ -z "${version}" ]; then
134+
echo "::error::Could not read version from pyproject.toml."
135+
exit 1
136+
fi
137+
echo "pyproject.toml version: ${version}"
138+
if git ls-remote --exit-code --tags origin "refs/tags/v${version}" > /dev/null; then
139+
echo "OK: tag v${version} exists on origin."
140+
else
141+
echo "::error::Public surfaces claim v${version} as the latest release, but tag v${version} does not exist on origin."
142+
echo "Cut the release: git tag -a v${version} <release-sha> && git push origin v${version}"
143+
echo "then use 'Re-run failed jobs' on this run — pushing the tag triggers release.yml, not a retest of this run, but a re-run re-checks origin's live tag list. The check also clears on the next push to main."
144+
echo "…or revert the version claim until the release is actually cut."
145+
exit 1
146+
fi

.github/workflows/release.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ jobs:
1919
- name: Checkout
2020
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
2121

22+
- name: Verify tag matches package version
23+
# Fail fast before the test/build/publish pipeline if the tag
24+
# was cut on a commit whose pyproject.toml disagrees (e.g. a
25+
# v0.15.0 tag pushed while main still says 0.14.0). PyPI
26+
# uploads are immutable, so this is the last cheap moment to
27+
# catch a mistagged release.
28+
run: |
29+
version="$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1)"
30+
if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then
31+
echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}; refusing to release."
32+
exit 1
33+
fi
34+
echo "OK: ${GITHUB_REF_NAME} matches pyproject.toml (${version})."
35+
2236
- name: Set up Python
2337
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
2438
with:

ROADMAP.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
> **Naming.** This project is **Agents Shipgate** (display name) / `agents-shipgate` (package, CLI, repo). See [`AGENTS.md` § Naming (canonical)](AGENTS.md#naming-canonical) for the full convention.
44
5-
**Latest release: `v0.14.0`** — the **agent-native contract cleanup** cycle.
5+
**Latest release: `v0.14.0`**
6+
([release page](https://github.com/ThreeMoonsLab/agents-shipgate/releases/latest))
7+
— the **agent-native contract cleanup** cycle. This line is checked against the
8+
actual release tag by the `release-tag-consistency` job in
9+
[`ci.yml`](.github/workflows/ci.yml) on every push to `main`.
610

711
## What Agents Shipgate is
812

0 commit comments

Comments
 (0)