Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 18 additions & 21 deletions .claude/hooks/content-filter-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,34 +24,31 @@ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
# Extract just the filename for matching
FILENAME=$(basename "$FILE_PATH")

# Helper: emit current Claude Code PreToolUse deny shape (2026+).
# Legacy {"decision":"block",...} is silently ignored by Claude Code, so we
# use the hookSpecificOutput / permissionDecision schema. See:
# https://code.claude.com/docs/en/hooks
deny() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}

# HIGH-risk files: BLOCK the write
case "$FILENAME" in
CODE_OF_CONDUCT.md|CODE_OF_CONDUCT.MD)
cat << 'EOF'
{
"decision": "block",
"reason": "CODE_OF_CONDUCT.md is HIGH risk for content filter errors (HTTP 400). Fetch from the canonical URL instead:\n\ncurl -sL \"https://www.contributor-covenant.org/version/3/0/code_of_conduct/code_of_conduct.md\" -o CODE_OF_CONDUCT.md\n\nThen use Edit to replace [INSERT CONTACT METHOD] with the project's contact details."
}
EOF
exit 1
deny "CODE_OF_CONDUCT.md is HIGH risk for content filter errors (HTTP 400). Fetch from the canonical URL instead:\n\ncurl -sL \"https://www.contributor-covenant.org/version/3/0/code_of_conduct/code_of_conduct.md\" -o CODE_OF_CONDUCT.md\n\nThen use Edit to replace [INSERT CONTACT METHOD] with the project's contact details."
;;
LICENSE|LICENSE.md|LICENSE.txt|LICENCE|LICENCE.md|LICENCE.txt)
cat << 'EOF'
{
"decision": "block",
"reason": "LICENSE is HIGH risk for content filter errors (HTTP 400). Fetch from SPDX instead:\n\ncurl -sL \"https://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt\" -o LICENSE\n\nReplace MIT with the appropriate SPDX identifier. Then use Edit to fill in [year] and [fullname]."
}
EOF
exit 1
deny "LICENSE is HIGH risk for content filter errors (HTTP 400). Fetch from SPDX instead:\n\ncurl -sL \"https://raw.githubusercontent.com/spdx/license-list-data/main/text/MIT.txt\" -o LICENSE\n\nReplace MIT with the appropriate SPDX identifier. Then use Edit to fill in [year] and [fullname]."
;;
SECURITY.md|SECURITY.MD)
cat << 'EOF'
{
"decision": "block",
"reason": "SECURITY.md is MEDIUM-HIGH risk for content filter errors (HTTP 400). Fetch a template first:\n\ncurl -sL \"https://raw.githubusercontent.com/github/.github/main/SECURITY.md\" -o SECURITY.md\n\nNote: This fetches GitHub's own security policy. Use Edit to replace all GitHub-specific references with the project's details, including reporting method, response timeline, and supported versions."
}
EOF
exit 1
deny "SECURITY.md is MEDIUM-HIGH risk for content filter errors (HTTP 400). Fetch a template first:\n\ncurl -sL \"https://raw.githubusercontent.com/github/.github/main/SECURITY.md\" -o SECURITY.md\n\nNote: This fetches GitHub's own security policy. Use Edit to replace all GitHub-specific references with the project's details, including reporting method, response timeline, and supported versions."
;;
esac

Expand Down
26 changes: 19 additions & 7 deletions .claude/hooks/release-guard-mcp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ set -euo pipefail

INPUT=$(cat)

# Helper: emit the current Claude Code PreToolUse deny shape (2026+).
# Legacy {"decision":"block",...} is silently ignored, so we use the
# hookSpecificOutput / permissionDecision schema. See:
# https://code.claude.com/docs/en/hooks
deny() {
local reason="$1"
jq -n --arg r "$reason" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}

# ── merge_pull_request — allow dev, block master/main ────────────

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
Expand All @@ -21,14 +37,12 @@ if [ "$TOOL_NAME" = "mcp__github__merge_pull_request" ]; then
exit 0
fi
fi
echo '{"decision":"block","reason":"🛑 RELEASE GUARD: PR merging to master/main via GitHub MCP is blocked.\n\nOnly merges to dev are allowed via Claude Code. Master merges must be done manually by Nathan."}'
exit 0
deny "🛑 RELEASE GUARD: PR merging to master/main via GitHub MCP is blocked.\n\nOnly merges to dev are allowed via Claude Code. Master merges must be done manually by Nathan."
fi

# Fallback: detect merge by input fields (block if not already handled above)
if echo "$INPUT" | jq -e '.tool_input.pull_number // .tool_input.merge_method' > /dev/null 2>&1; then
echo '{"decision":"block","reason":"🛑 RELEASE GUARD: PR merging via GitHub MCP is blocked.\n\nUse gh pr merge <number> for dev-targeting PRs, or merge manually in GitHub UI."}'
exit 0
deny "🛑 RELEASE GUARD: PR merging via GitHub MCP is blocked.\n\nUse gh pr merge <number> for dev-targeting PRs, or merge manually in GitHub UI."
fi

# ── push_files / create_or_update_file / delete_file — check branch ──
Expand All @@ -37,9 +51,7 @@ BRANCH=$(echo "$INPUT" | jq -r '.tool_input.branch // ""' 2>/dev/null)

if [ "$BRANCH" = "master" ] || [ "$BRANCH" = "main" ] || [ -z "$BRANCH" ]; then
DISPLAY="${BRANCH:-default}"
jq -n --arg reason "🛑 RELEASE GUARD: GitHub MCP write to '${DISPLAY}' branch is blocked.\n\nSpecify a feature branch or 'dev' branch instead of master/main." \
'{"decision": "block", "reason": $reason}'
exit 0
deny "🛑 RELEASE GUARD: GitHub MCP write to '${DISPLAY}' branch is blocked.\n\nSpecify a feature branch or 'dev' branch instead of master/main."
fi

# Feature branch — allow
Expand Down
20 changes: 16 additions & 4 deletions .claude/hooks/release-guard-protect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,26 @@ INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
[ -z "$FILE_PATH" ] && echo '{}' && exit 0

# Helper: emit the current Claude Code PreToolUse deny shape (2026+).
# Legacy {"decision":"block",...} is silently ignored. See:
# https://code.claude.com/docs/en/hooks
deny() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0
}

case "$FILE_PATH" in
*/release-guard.sh | */release-guard-protect.sh | */release-guard-mcp.sh)
jq -n '{"decision":"block","reason":"🛑 RELEASE GUARD: This file is protected.\n\nRelease guard hooks can only be edited manually by Nathan.\nProtected: .claude/hooks/release-guard*.sh"}'
exit 0
deny "🛑 RELEASE GUARD: This file is protected.\n\nRelease guard hooks can only be edited manually by Nathan.\nProtected: .claude/hooks/release-guard*.sh"
;;
*/.claude/hooks.json)
jq -n '{"decision":"block","reason":"🛑 RELEASE GUARD: .claude/hooks.json is protected.\n\nHook configuration must be edited manually by Nathan to prevent removal of release guard hooks."}'
exit 0
deny "🛑 RELEASE GUARD: .claude/hooks.json is protected.\n\nHook configuration must be edited manually by Nathan to prevent removal of release guard hooks."
;;
esac

Expand Down
22 changes: 20 additions & 2 deletions .claude/hooks/release-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,28 @@ if echo "$COMMAND" | grep -qF 'hooks.json' && \
fi

# ── Output ───────────────────────────────────────────────────────
#
# Claude Code PreToolUse hook output schema (current as of 2026-04-15):
# {
# "hookSpecificOutput": {
# "hookEventName": "PreToolUse",
# "permissionDecision": "deny" | "ask" | "allow",
# "permissionDecisionReason": "<text>"
# }
# }
# The legacy {"decision":"block","reason":...} shape is silently ignored, so
# blocks return as no-ops. See https://code.claude.com/docs/en/hooks for the
# spec.

if [ "$BLOCKED" = true ]; then
jq -n --arg reason "$(printf '🛑 RELEASE GUARD: %s\n\nFeature branch and dev branch pushes are allowed. Only master/main, tags, releases, and PR merges are blocked.\n\nTo push a feature branch: git push -u origin <branch>\nTo create a PR to dev: gh pr create --base dev --title "..." --body "..."\nFor master/tags/releases: Nathan runs these manually.' "$REASON")" \
'{"decision": "block", "reason": $reason}'
REASON_FULL=$(printf '🛑 RELEASE GUARD: %s\n\nFeature branch and dev branch pushes are allowed. Only master/main, tags, releases, and PR merges are blocked.\n\nTo push a feature branch: git push -u origin <branch>\nTo create a PR to dev: gh pr create --base dev --title "..." --body "..."\nFor master/tags/releases: Nathan runs these manually.' "$REASON")
jq -n --arg r "$REASON_FULL" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
else
echo '{}'
fi
59 changes: 42 additions & 17 deletions .claude/skills/release-coordination/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,29 +285,54 @@ systemctl --user restart untether
- rc versions do **NOT** require changelog entries (`validate_release.py` skips them)
- Commit message: `chore: staging X.Y.ZrcN`

## Phase 7: Tag and publish
## Phase 7: Merge to master (single-gate release)

```bash
# Commit release changes (version bump, changelog, lockfile)
git add pyproject.toml CHANGELOG.md uv.lock
git commit -m "chore: release vX.Y.Z"
The release flow uses a single approval gate: **the master PR review IS the release approval**. Once Nathan squash-merges a PR with a stable version (e.g. `0.35.2`, no rc/a/b/dev suffix) to master, everything else is automatic.

# Tag
git tag vX.Y.Z
### Claude Code's role

# Push commit and tag
git push origin master --tags
```
- Prepare the version bump on a feature branch (`pyproject.toml`, `CHANGELOG.md`, `uv.lock`)
- Open a PR from `dev` → `master` with a release summary
- Wait for CI to go green
- Hand off to Nathan with a one-line instruction: "merge PR #N when ready"

### Nathan's role

- Review the PR on GitHub
- Squash-merge to master in the GitHub UI

That's it. No tag creation, no PyPI environment approval. The git tag and PyPI publish happen automatically:

1. **`auto-tag-on-master.yml`** fires on the master push, reads `pyproject.toml`, and pushes a `vX.Y.Z` tag (skips pre-release versions like `0.35.2rc1`)
2. **`release.yml`** fires on the tag push:
- Validates the tag matches `pyproject.toml` version
- Runs the full pytest suite (Python 3.12/3.13/3.14)
- Builds wheel + sdist via `uv build`, validates with `twine check` and `check-wheel-contents`
- Publishes to PyPI via OIDC trusted publishing (no manual approval — the PR merge was the approval)
- Creates a GitHub Release with auto-generated notes and uploads the dist artifacts

The `release.yml` workflow triggers automatically on `v*` tags:
### Why the single gate is safe

1. Validates tag matches `pyproject.toml` version
2. Runs full pytest suite
3. Builds wheel + sdist via `uv build`, validates with `twine check` and `check-wheel-contents`
4. Publishes to PyPI via trusted publishing (OIDC) — requires reviewer approval on the `pypi` GitHub Environment
5. Creates a GitHub Release with auto-generated notes and uploads dist artifacts
The defenses that the legacy `pypi` environment reviewer was providing are already covered upstream:

- Branch protection on master: only Nathan can merge via PR
- `validate_release.py` runs in CI on version-bump PRs (changelog format, issue links, date)
- All CI checks must pass before the PR can merge
- `release.yml` re-validates tag-vs-version match
- PyPI trusted publishing via OIDC (no static API token to leak)
- The release-guard hooks block Claude Code from creating tags or pushing master directly

### Manual override (rare)

If `auto-tag-on-master.yml` fails or you need to tag manually:

```bash
git checkout master && git pull
git tag vX.Y.Z
git push origin vX.Y.Z # triggers release.yml directly
```

**Do not push the tag until the commit is on `master`.**
Both Claude Code and Nathan can do this. The release-guard hook blocks Claude Code from `git tag v*`, so this path is Nathan-only by design.

## Post-release verification

Expand Down
95 changes: 95 additions & 0 deletions .github/workflows/auto-tag-on-master.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Auto-tag on master

# When a push lands on master that bumps the version in pyproject.toml,
# automatically create and push the matching `vX.Y.Z` tag. The tag push
# triggers `release.yml`, which builds, validates, and publishes to PyPI.
#
# This collapses the previous two-gate release flow (PR merge + manual tag
# creation + PyPI environment approval) into a single gate: the master PR
# review. See docs/reference/release-process.md for the full rationale.
#
# Only fires when:
# 1. The push is to master
# 2. pyproject.toml's version is NOT a pre-release (no rc/a/b/dev suffix)
# 3. A `vX.Y.Z` tag for that version doesn't already exist
#
# Security note: this workflow only consumes its own step outputs and the
# repo's pyproject.toml — it never interpolates untrusted user input
# (commit messages, PR titles, issue bodies). All shell variables are
# passed via `env:` and quoted properly.

on:
push:
branches:
- master

permissions: {}

jobs:
auto-tag:
name: Detect version bump and tag
runs-on: ubuntu-latest
permissions:
contents: write # create + push tags
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # need full history to check existing tags

- name: Read pyproject.toml version
id: version
run: |
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import re
import tomllib
from pathlib import Path

data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
version = data["project"]["version"]

# PEP 440 pre-release detection: rc, a, b, dev, post (any suffix
# after the X.Y.Z core). A "stable" version is exactly N.N.N.
stable = bool(re.fullmatch(r"\d+\.\d+\.\d+", version))

print(f"version={version}")
print(f"tag=v{version}")
print(f"stable={'true' if stable else 'false'}")
PY

- name: Skip pre-release versions
if: steps.version.outputs.stable != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "::notice::Version $VERSION is a pre-release (rc/a/b/dev/post). No tag will be created."
exit 0

- name: Check whether tag already exists
if: steps.version.outputs.stable == 'true'
id: existing
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::Tag $TAG already exists locally. No action."
elif git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::Tag $TAG already exists on origin. No action."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Create and push tag
if: steps.version.outputs.stable == 'true' && steps.existing.outputs.exists == 'false'
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
# Tag the merge commit (HEAD on master). Annotated tag so it
# carries the release version for audit purposes.
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "::notice::Created and pushed $TAG. release.yml will fire next."
Loading
Loading