Support manual PyPI releases via workflow_dispatch - #292
Conversation
build_wheels.yml could only release from a pushed tag. Add a dispatch path so a release can be started from the Actions UI, optionally incrementing VERSION itself, without changing how tag-driven releases work. The workflow is now two jobs: - prepare (workflow_dispatch only) resolves the version, optionally bumps VERSION, commits it to the default branch and pushes the tag as the mlc-automations GitHub App. An App-token push re-triggers workflows, so that tag push starts publish. - publish (v* tag push only) builds, uploads to PyPI and cuts the GitHub Release, exactly as before. Wheels therefore still only ever reach PyPI from a tag ref, built by one job, however the release was started. Retrying a failed release is GitHub's "Re-run jobs" on the failed run, not a second entry point. Inputs: bump (none/patch/minor/major) and dry_run. Version resolution moves to .github/scripts/resolve_release_version.py so the decisions are testable outside CI. PyPI versions are immutable, so every case that would resolve to an already-published version is refused up front, before anything is built, committed, tagged or uploaded; there is no override. prepare likewise refuses to create a tag that already exists and never force-updates one. Requires mlc-automations to be a bypass actor on the main ruleset for the VERSION bump push. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅ |
🤖 AI PR Review SummaryAdds a new Python script to resolve and verify release versions against PyPI, enforcing immutability of published versions and automating version bumps. Updates the GitHub Actions workflow to support both tag-driven and dispatch-driven release flows, with detailed documentation and inputs for version bumping and dry runs. The script and workflow improve release safety by preventing re-use of published versions and automating tagging and version management. Risks include ensuring the script's network calls to PyPI are reliable and that the workflow permissions and token usage are correctly configured to allow commits and tag pushes by the automation. The design is clear and well-documented, but the complexity of the release flow requires careful maintenance and testing. |
Two of the five automated review comments were actionable: - Send a descriptive User-Agent (and Accept) on the PyPI metadata request instead of the default `Python-urllib/3.x`, which PyPI asks API consumers not to rely on. - Replace VERSION through a sibling temp file and os.replace() rather than truncating it in place, so a failed or interrupted write leaves the original intact instead of handing a truncated VERSION to the commit that follows. The write is fsync'd, the temp file is cleaned up on failure, and the result is read back and re-parsed before the release proceeds. A write that cannot even be started now reports a ::error:: annotation like every other refusal, rather than a raw traceback. The other three were declined: GITHUB_OUTPUT needs no locking (steps in a job are sequential), a PyPI-consistency grace period in verify would wait for a version that is supposed to be absent, and the bump=none already-published guard the bot asked for is already there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two workflow_dispatch runs that overlap both read VERSION, PyPI and the tag list before either has pushed, both resolve to the same version, and both clear the tag guard. Nothing corrupt lands even without this — git refuses the second writer (non-fast-forward on the VERSION push, "already exists" on the tag push) and the tag step carries an implicit success(), so a failed VERSION push cannot leave an orphan tag behind. But the losing run fails on a raw git error rather than on one of the release script's own refusals, and which of the two wins is arbitrary. `prepare` now takes a single repo-wide concurrency group, so the second dispatch resolves against the state the first one left and is refused by the ordinary guards: bump=none hits the tag guard, a bump level hits "VERSION is already ahead of the latest release". `publish` is serialized per tag, so re-running a release cannot overlap the run it retries and race it on the PyPI upload and the GitHub Release. Keyed on the ref rather than a constant because distinct tags publish distinct versions and share no mutable state. Neither group cancels in progress: interrupting a release between the VERSION push and the tag push is worse than queueing behind it. This is the release-level race; $GITHUB_OUTPUT itself needs no locking, as it lives in $RUNNER_TEMP and concurrent runs get separate runners. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release workflow carried more comment than code — 131 of its 314
lines. Step-by-step narration ("Step 2: Set up Python"), restatements of
the line below, and multi-paragraph rationale are gone; what remains is
the small set that stops someone reintroducing a bug: the errexit
exemption behind the ls-remote assignment, why ref_type == 'tag' is not
redundant with on.push.tags, why `build` alone suffices, and one- or
two-line notes on each concurrency group.
Now 48 comment lines of 231. Verified comment-only: parsing both the old
and new YAML and diffing the structures shows no functional difference.
Same pass over resolve_release_version.py, mostly the module docstring
and the two docstrings added in the last two commits. The env/output
tables stay — they document an interface nothing else records.
Recorded the rule in AGENTS.md ("Comment style") with the repo's own
before/after examples, and as a one-liner in .claude/skill.md's Don'ts.
Re-ran the full resolver matrix against live PyPI plus the write-failure
paths; unchanged. actionlint and autopep8 -a both clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both release paths, the guards and the point where each one stops mutating are easier to see at a glance than to read. Referenced from the PR description, which drops the prose the diagram replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New release flow:
Diagram source:
.github/images/release_flow.pngprepareturns a dispatch into a tag;publishis unchanged and still the only job that uploads. An App-token push re-triggers workflows, soprepare's tag push startspublish— the same job a terminalgit tag -a v1.2.5 && git push origin v1.2.5starts. Wheels therefore only ever reach PyPI from a tag ref, built by one job, however the release was started.Inputs
bump—none(releaseVERSIONas it stands) /patch/minor/majordry_run— resolve, validate and build only; nothing committed, tagged or publishedVersion resolution lives in
.github/scripts/resolve_release_version.pyso the decisions are testable outside CI, which is how the matrix above was verified.Concurrency
preparetakes one repo-wide group;publishone per tag. Overlapping dispatches would otherwise both resolve against the pre-release state and collide on the push.mlc-automationsmust be a bypass actor on themainruleset (bypass_actors[].actor_type: Integration), or abump != nonedispatch will fail at the push tomain. The ruleset currently has an empty bypass list. Tag-driven releases are unaffected and work without this.🤖 Generated with Claude Code