Gate the release workflow on the QA ladder - #110
Merged
Conversation
Pushing a v* tag ran uv build and uv publish with no dependency on the lint, typecheck or test jobs. Worse, the CI workflow triggers only on pushes and pull requests targeting master, so a tag push matched neither and ran no checks at all -- a tag against broken code published to PyPI unchallenged, and a published version can only be yanked, never replaced. Cross-workflow "needs" does not exist, so gating requires the release workflow to run the checks itself. Rather than duplicating the four jobs and letting the release gate drift from the pull-request gate, ci.yml gains a workflow_call trigger and release.yml invokes it against the tagged commit, with publish gated behind it. One definition serves both. The wiring is easy to break silently -- drop needs:, or workflow_call:, and releases keep working while the gate stops existing -- and it cannot be verified by running it, because publishing is irreversible. So tests/test_workflows.py asserts each half of it. Each assertion was checked against a mutated workflow to confirm it fails when the gate is actually broken; actionlint does not resolve local reusable-workflow references and passes all of those mutations. Closes#78 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt
Uh oh!
There was an error while loading. Please reload this page.
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes#78.
Pushing a
v*tag ranuv buildanduv publishwith no dependency onthe lint, typecheck or test jobs. The problem is worse than the issue
described:
ci.ymltriggers only onpush/pull_requesttargetingmaster, and a tag push matches neither, so a release ran nochecks at all — there was not even a CI run to depend on. A tag against
broken code published to PyPI unchallenged, and a published version can
only be yanked, never replaced.
Cross-workflow
needs:does not exist, so the release workflow has torun the checks itself. Rather than duplicating the four jobs — and
letting the release gate drift away from the pull-request gate —
ci.ymlgains a
workflow_calltrigger andrelease.ymlinvokes it against thetagged commit, with
publishgated behind it. One definition serves both.What's included
.github/workflows/ci.yml— adds aworkflow_call:trigger. Existingpush/PR triggers are unchanged.
.github/workflows/release.yml— acijob calling./.github/workflows/ci.yml, andneeds: cionpublish. Adds aworkflow-level
permissions: contents: read;publishkeeps itsid-token: writefor trusted publishing.tests/test_workflows.py— four assertions that the gate stays wired.CHANGELOG.md— entries under## [Unreleased].Verification
uv run ruff checkanduv run ruff format --checkare cleanuv run mypyis clean (strict)uv run pytestpasses locally — 702 tests, 4 newCHANGELOG.mdhas an entry under## [Unreleased]The gate cannot be verified by running it, because publishing is
irreversible. So each assertion was checked against a deliberately
broken workflow to confirm it fails when the gate is actually gone:
workflow_call:fromci.ymltest_ci_workflow_is_callable,test_every_local_reusable_reference_resolvescigate jobtest_release_calls_the_ci_workflow,test_publish_depends_on_the_ci_gateneeds: cifrompublishtest_publish_depends_on_the_ci_gateuses:at a missing workflowWorth recording:
actionlintpasses every one of those mutations.It does not resolve local reusable-workflow references, so it reports a
missing target file and a missing
workflow_calltrigger as clean. Itsgreen result on this PR validates syntax, not wiring — which is why the
tests exist.
Notes
Two deliberate non-changes:
uv buildstill runs twice (once in the CIbuildjob, once inpublish). Sharing an artifact across a reusable-workflow boundarycosts more complexity than a rebuild from the same commit is worth,
and publishing from the checkout it just built is the safer default.
the point — a tag can name any commit, including one that never landed
on master.
Follow-up filed as #111: a tag whose version disagrees with
pyproject.tomlis a separate hole this PR does not close, and one theQA ladder structurally cannot catch, since the code is fine and only the
tag is wrong.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QJnVMNGwTRDktC4rkABtgt