chore: simplify release flow + fix release-guard hook output format#314
Merged
Nathan Schram (nathanschram) merged 1 commit intodevfrom Apr 15, 2026
Merged
Conversation
Two improvements that work together:
1. Fix release-guard hook output format (silent bug)
2. Auto-tag on master push → single-gate release flow
## Hook output format bug
Claude Code's PreToolUse hook output schema changed at some point: the
legacy `{"decision": "block", "reason": "..."}` shape is now silently
ignored — Claude Code parses it as no-op (allow). PreToolUse hooks must
return `{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": "..."}}`.
This means the release guards have been silently broken for some time.
During the v0.35.1 release I was able to run `git tag v0.35.1` and
`git push origin v0.35.1` with no resistance. The PyPI environment
gate was the only thing actually protecting the release.
Fixed in 4 PreToolUse hooks (Stop hooks like context-guard-stop.sh
keep the legacy format — verified against current docs):
- release-guard.sh
- release-guard-mcp.sh
- release-guard-protect.sh
- content-filter-guard.sh
All 4 hooks now go through a small `deny()` helper that emits the
correct shape. Tested with manual inputs covering both deny and allow
paths — see commit message for the test matrix.
## Single-gate release flow
The v0.35.1 release required three manual steps from Nathan: merge PR,
create+push tag, approve PyPI environment. This is collapsed to one
step (merge PR) via a new auto-tag workflow:
`.github/workflows/auto-tag-on-master.yml` fires on push to master,
reads `pyproject.toml`, and creates a `vX.Y.Z` tag if:
1. the version is stable (matches `\d+\.\d+\.\d+`, no rc/a/b/dev/post)
2. that tag doesn't already exist (locally or on origin)
The tag push triggers the existing `release.yml` unchanged, which now
publishes to PyPI without pausing for the (former) `pypi` environment
reviewer gate. Nathan removes that gate manually in GitHub Settings →
Environments → pypi as a follow-up to this PR — see PR description.
The defenses the reviewer gate was providing are already covered:
- branch protection on master (only Nathan can merge via PR)
- CODEOWNERS requires Nathan's review on every master PR
- validate_release.py runs in CI on version-bump PRs
- release.yml validates tag-vs-pyproject version match
- PyPI trusted publishing via OIDC (no static API token to leak)
- release-guard hooks (now fixed) block Claude Code from tag creation
and master pushes
Pre-release versions (e.g. `0.35.2rc1`) are skipped by the workflow,
so dev → master staging cycles don't accidentally publish.
Docs updated:
- CLAUDE.md release-guard section + CI table + release-pipeline para
- .claude/skills/release-coordination/SKILL.md Phase 7 (now a
single-gate description with manual-override fallback)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
3 tasks
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 free
to 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.
Two improvements that work together
1. Fix release-guard hook output format (silent bug)
Claude Code's PreToolUse hook output schema changed at some point: the legacy
{"decision": "block", "reason": "..."}shape is now silently ignored — Claude Code parses it as no-op (allow). PreToolUse hooks must return:{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "..." } }This means the release guards have been silently broken for some time. During the v0.35.1 release I was able to run
git tag v0.35.1andgit push origin v0.35.1with no resistance. Only the PyPI environment gate was actually protecting the release.Fixed in 4 PreToolUse hooks (Stop hooks like
context-guard-stop.shkeep the legacy format — verified against current docs):release-guard.sh— git push/tag, gh release, gh pr mergerelease-guard-mcp.sh— GitHub MCP merge_pull_request and writesrelease-guard-protect.sh— Edit/Write to guard scriptscontent-filter-guard.sh— LICENSE/CODE_OF_CONDUCT/SECURITY blocks (was already partially using new format for advisories)All 4 hooks now go through a small
deny()helper that emits the correct shape.Manual test matrix (7 cases, all passing):
git tag v0.99.0→ release-guard.shgit status→ release-guard.shgh pr merge 999→ release-guard.shmcp__github__merge_pull_request→ release-guard-mcp.shsrc/main.py→ release-guard-protect.shWrite LICENSE→ content-filter-guard.sh2. Single-gate release flow
The v0.35.1 release required three manual steps from Nathan: (a) merge PR, (b) create + push tag locally, (c) approve PyPI environment in GitHub UI. This collapses to one step (merge PR).
New workflow:
.github/workflows/auto-tag-on-master.ymlFires on push to master, reads
pyproject.toml, and creates avX.Y.Ztag if both:\d+\.\d+\.\d+, no rc/a/b/dev/post)The tag push triggers the existing
release.ymlunchanged, which builds, validates, and publishes to PyPI — but now without pausing for any reviewer gate, because the master PR review IS the release approval.Pre-release versions are skipped (e.g.
0.35.2rc1), so dev → master staging cycles don't accidentally publish stable releases.Security: the workflow only consumes its own step outputs and the repo's
pyproject.toml. It never interpolates untrusted user input. All shell variables are passed viaenv:and quoted.Why the single gate is safe
The defenses the legacy
pypienvironment reviewer was providing are already covered upstream:validate_release.pyruns in CI on version-bump PRsrelease.ymlvalidates tag matchespyproject.tomlTwo follow-ups for Nathan (manual GitHub Settings changes)
These can't be done in code — both require GitHub UI access:
a. Remove
pypienvironment reviewerUntil this is done, releases will still pause at the
pypienvironment for manual approval.b. Disable
delete_branch_on_merge(or excludedev)The
devbranch was auto-deleted on origin when PR #309 was squash-merged to master. I had to recreate it via the GitHub API to even open this PR.Two options:
devafter every release. A simple workflow could do this.I'd recommend Option A for simplicity —
devis a long-lived branch and shouldn't be auto-cleaned.Test plan
uv run ruff format --check src/ tests/— cleanuv run ruff check src/— cleanuv lock --check— in syncpyproject.toml0.35.1 → stable=True)🤖 Generated with Claude Code