Skip to content

chore: simplify release flow + fix release-guard hook output format#314

Merged
Nathan Schram (nathanschram) merged 1 commit intodevfrom
fix/release-process-improvements
Apr 15, 2026
Merged

chore: simplify release flow + fix release-guard hook output format#314
Nathan Schram (nathanschram) merged 1 commit intodevfrom
fix/release-process-improvements

Conversation

@nathanschram
Copy link
Copy Markdown
Member

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.1 and git push origin v0.35.1 with no resistance. Only the PyPI environment gate was 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 — git push/tag, gh release, gh pr merge
  • release-guard-mcp.sh — GitHub MCP merge_pull_request and writes
  • release-guard-protect.sh — Edit/Write to guard scripts
  • content-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):

# Input Expected Result
1 git tag v0.99.0 → release-guard.sh DENY
2 git status → release-guard.sh ALLOW
3 gh pr merge 999 → release-guard.sh DENY (non-dev)
4 mcp__github__merge_pull_request → release-guard-mcp.sh DENY
5 Edit on guard script → release-guard-protect.sh DENY
6 Edit on src/main.py → release-guard-protect.sh ALLOW
7 Write LICENSE → content-filter-guard.sh DENY

2. 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.yml

Fires on push to master, reads pyproject.toml, and creates a vX.Y.Z tag if both:

  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 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 via env: and quoted.

Why the single gate is safe

The defenses the legacy pypi environment reviewer was providing are already covered upstream:

Threat Defense
Random push to master Branch protection: only Nathan can merge via PR
Wrong version validate_release.py runs in CI on version-bump PRs
Bad code shipped All CI checks must pass before PR can merge
Tag/version mismatch release.yml validates tag matches pyproject.toml
Compromised CI runner PyPI trusted publishing via OIDC (no static API token)
Accidental Claude Code tag/merge Branch protection + (now-fixed) release-guard hooks

Two follow-ups for Nathan (manual GitHub Settings changes)

These can't be done in code — both require GitHub UI access:

a. Remove pypi environment reviewer

  1. Open https://github.com/littlebearapps/untether/settings/environments/pypi
  2. Under Deployment protection rules, remove Required reviewers
  3. Optionally add a Wait timer of 5 minutes as a soft escape valve

Until this is done, releases will still pause at the pypi environment for manual approval.

b. Disable delete_branch_on_merge (or exclude dev)

The dev branch 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:

  • Option A: Disable branch auto-delete repo-wide: Settings → General → Pull Requests → uncheck "Automatically delete head branches"
  • Option B: Keep auto-delete on but recreate dev after every release. A simple workflow could do this.

I'd recommend Option A for simplicity — dev is a long-lived branch and shouldn't be auto-cleaned.


Test plan

  • Manual hook tests (all 7 cases above)
  • uv run ruff format --check src/ tests/ — clean
  • uv run ruff check src/ — clean
  • uv lock --check — in sync
  • Auto-tag workflow YAML lints clean (verified locally with Python parsing of the version-detection script against pyproject.toml 0.35.1 → stable=True)
  • CI on this PR — should be green; no Python source changed so pytest is irrelevant
  • Live test: Next stable release (e.g. v0.35.2) — should auto-tag and publish without manual intervention after Nathan removes the pypi reviewer

🤖 Generated with Claude Code

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]>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 51db4014-a7b2-42da-aa40-3be64ca7556c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/release-process-improvements

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nathanschram Nathan Schram (nathanschram) merged commit b09a62b into dev Apr 15, 2026
21 checks passed
@nathanschram Nathan Schram (nathanschram) deleted the fix/release-process-improvements branch April 15, 2026 07:24
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.

1 participant