Skip to content

Refuse to publish anything but a tagged release to PyPI - #560

Merged
hexsecs merged 1 commit into
mainfrom
claude/issue-559-publish-ref-guard
Oct 1, 2026
Merged

hexsecs merged 1 commit into
mainfrom
claude/issue-559-publish-ref-guard

Conversation

@hexsecs

@hexsecs hexsecs commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Summary

publish.yml built and uploaded whatever ref it was dispatched from. The GitHub UI's "Use workflow from" control defaults to main, so while cutting 0.10.0 the workflow was dispatched from main and uploaded 0.10.1.dev0 to production PyPI in place of the release (run 36806107125). PyPI never accepts a version twice, so that upload can only be yanked.

This makes the workflow refuse any PyPI publish that isn't from the matching release tag, before anything is built.

Closes #559

Changes

  • scripts/check_release_ref.py, a new stdlib-only guard. When the target is pypi it fails unless all three hold:

    • the run was dispatched from a tag, not a branch
    • the tag is exactly v<__version__>
    • the version is a final X.Y.Z, with no .dev, rc/a/b, .post, or +local segment

    The version is read from src/canarchy/__init__.py, which is the same file hatch builds the package version from. Failures print as ::error:: annotations that name the problem and the fix.

  • publish.yml: the guard is a new step in build, ahead of uv build. Both publish jobs need: build, so a refused run uploads nothing.

  • testpypi is unrestricted. The runbook uses it to rehearse workflow changes, which may need to run from a branch.

The workflow input reaches the script through env, not ${{ }} interpolation into the shell line. It's a choice input today, but this way it stays safe if that changes.

What it says on the dispatch that caused this

::error::dispatched from branch 'main', not a tag; publishing to pypi must run from the release tag -- select it under 'Use workflow from' > Tags, not a branch
::error::package version '0.10.1.dev0' is not a final release (X.Y.Z); development, pre-release and local versions must not be published to pypi

Test plan

  • uv run pytest -q: 1833 passed (18 new), 1 skipped, 1 deselected, 306 subtests
  • uv run ruff check / ruff format --check: clean
  • publish.yml parses as YAML

tests/test_release_ref.py covers:

  • the exact incident (branch/main/0.10.1.dev0) → refused with both reasons
  • the matching tag with a final version → allowed
  • a branch with a final version, a mismatched tag, and an unprefixed 0.10.0 tag → each refused
  • .dev, rc, a, b, .post and +local versions → refused even from a matching tag
  • testpypi from main → allowed
  • read_version agrees with canarchy.__version__
  • the CLI: the failure path in a subprocess, the success path in-process against a release-shaped version file
  • the workflow wiring: the guard step exists, runs before the build, and both publish jobs depend on build. Removing the step from publish.yml makes this fail ('Verify release ref' is not in list), so the protection can't be dropped silently.

Documentation

  • CHANGELOG.md: [Unreleased] / Fixed entry
  • docs/release.md: step 8 now says to publish from the tag, "How to use it" says to pick the tag under Use workflow from > Tags and gives the gh workflow run --ref equivalent, "Workflow behavior" documents the enforced rule, and a note records why
  • docs/design/ / docs/tests/: none cover publishing, and AGENTS.md reserves new specs for command-surface, data-model or module-boundary changes. A CI guard is none of those.
  • AGENTS.md / docs/agents.md: no agent-facing surface changed

Limitation

GitHub runs the workflow file from the dispatched ref, so the guard applies to refs that contain it: main after this merges, and every future release tag. Dispatching from an older ref, such as the v0.10.0 tag, runs the old unguarded workflow. That's fine for publishing 0.10.0 itself, and every earlier tag is already on PyPI.

Safety

No active-bus behaviour. This adds a refusal path to the release workflow and changes nothing else about a correctly dispatched publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_0195Fkk63xics9zBvjFgYz8T


Generated by Claude Code

publish.yml built and uploaded whatever ref it was dispatched from, and the
GitHub UI defaults 'Use workflow from' to main. While cutting 0.10.0 it was
dispatched from main and uploaded 0.10.1.dev0 to PyPI in place of the
release. PyPI never accepts a version twice, so the bad upload can only be
yanked.

A new first step in the build job runs scripts/check_release_ref.py, which
fails the run when publishing to pypi unless it was dispatched from a tag,
that tag is exactly v<__version__>, and the version is a final X.Y.Z with
no .dev, pre-release, post-release or local segment. The version is read
from src/canarchy/__init__.py, the same file hatch builds from. Both publish
jobs need build, so a refused run uploads nothing. testpypi is unrestricted,
since the runbook uses it to rehearse workflow changes.

The input reaches the script through env rather than being interpolated into
the shell line. Logic lives in a stdlib-only script so it is unit-tested,
including the exact main/0.10.1.dev0 dispatch that caused this; a workflow
test fails if the step is removed or moved after the build.

fixes #559

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195Fkk63xics9zBvjFgYz8T
@hexsecs
hexsecs marked this pull request as ready for review October 1, 2026 02:40
@hexsecs
hexsecs merged commit 5226ca9 into main Oct 1, 2026
10 checks passed
@hexsecs hexsecs mentioned this pull request Oct 1, 2026
10 tasks
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.

publish workflow uploads to PyPI from any ref, including dev versions on main

2 participants