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
2 changes: 1 addition & 1 deletion .claude/agents/release-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ Report the version, the changelog text, the CI state, and the exact command for

## Rules

No `git push`, `gh pr merge`, `gh release` writes, `gh workflow run`, no tags, no version edits, no changelog edits. The release guard denies them here anyway. Report only what you verified; a check you could not run is reported as not run, never assumed.
No push to `main`, no `gh pr merge`, `gh release` writes or `gh workflow run`, no tags, no version edits, no changelog edits. The release guard denies those here anyway. Report only what you verified; a check you could not run is reported as not run, never assumed.
74 changes: 59 additions & 15 deletions .claude/hooks/release-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,25 @@
# release-please; merging the Release PR tags vX.Y.Z and publishes a GitHub Release
# with the Chrome Web Store zip attached; gh release and gh workflow run can create,
# edit or rebuild that package. Each of those is a human decision (docs/RELEASING.md):
# • interactive session → permissionDecision "ask" (the prompt is the gate)
# • inside a subagent → "deny" (agent_id or agent_type is present in the event)
# • bypassPermissions / dontAsk → "ask" cannot prompt there, so it becomes "deny"
# • --deny → forces "deny" regardless of context
# • interactive session → permissionDecision "ask" (the prompt is the gate)
# • inside a subagent → "deny" (agent_id or agent_type is present in the event)
# • --deny → forces "deny" regardless of context
# The permission mode is not special-cased, as in Paul's other repos: in bypassPermissions
# Claude Code auto-approves a hook's "ask" (verified 2026-10-26 with a dry-run push that
# named main), so there the guard is a reminder and the subagent "deny" is the only hard
# stop; the gate is CLAUDE.md's rule that nothing is pushed to main, merged, released or
# dispatched unless Paul asked for it. (Until 2026-10-26 this hook turned "ask" into "deny"
# in bypassPermissions, which blocked every push from the sessions Paul actually runs.)
#
# Caught: git push (any ref or flag order, including -C, HEAD:main, --force); gh pr merge;
# gh api .../pulls/*/merge; gh release create|edit|upload|delete|delete-asset; gh api
# .../releases with a write method or fields; gh workflow run; gh api
# .../actions/workflows/*/dispatches; gh run rerun. Everything else passes silently,
# including pnpm -C app test|build, git commit, gh pr create, gh release view|download
# and gh run list.
# A plain branch push (git push -u origin <branch>, --force-with-lease included) is the
# first half of a pull request, not a deploy, and passes silently. Caught: a push that
# names main, a tag, HEAD, or no branch at all (so the current branch could be main), or
# that uses --force, --delete, --mirror, --all, --tags or a +refspec, in any flag order
# including -C and HEAD:main; gh pr merge; gh api .../pulls/*/merge; gh release
# create|edit|upload|delete|delete-asset; gh api .../releases with a write method or
# fields; gh workflow run; gh api .../actions/workflows/*/dispatches; gh run rerun.
# Everything else passes silently, including pnpm -C app test|build, git commit,
# gh pr create, gh release view|download and gh run list.
#
# Input: the hook event as JSON on stdin. Output: JSON on stdout, or nothing. Always exits 0.
set -u
Expand All @@ -37,7 +45,6 @@ hook_read_input

AGENT_ID="$(hook_json_get agent_id)"
AGENT_TYPE="$(hook_json_get agent_type)"
MODE="$(hook_json_get permission_mode)"
IN_SUBAGENT=0
if [ -n "$AGENT_ID" ] || [ -n "$AGENT_TYPE" ]; then IN_SUBAGENT=1; fi

Expand All @@ -48,8 +55,47 @@ B='(^|[[:space:];&|(`])'
WRITE_METHOD='(-X[[:space:]]*|--method[[:space:]=]+)(POST|PUT|PATCH|DELETE)|(^|[[:space:]])(-f|-F|--field|--raw-field|--input)([[:space:]]|=)'
WHAT=""

# A push is classified by its arguments (everything after "push" up to the next shell
# operator): the first bare word is the remote, the rest are refspecs.
PUSH_GUARDED=0
PUSH_WHY=""
if matches "${B}git([[:space:]]+(-C|-c)[[:space:]]+[^[:space:]]+|[[:space:]]+--?[^[:space:]]+)*[[:space:]]+push([[:space:]]|$)"; then
WHAT="git push: a push to main deploys site/ to GitHub Pages and feeds release-please, and a branch push is the first half of every merge"
PUSH_ARGS="$(printf '%s' "$TOOL_COMMAND" | tr '\n' ' ' | sed -E 's/^.*[[:space:]]push([[:space:]]+|$)//; s/(&&|\|\||;|\|).*$//')"
REMOTE_SEEN=0
REFSPECS=0
PUSH_WHY="no branch is named, so the current branch (which may be main) would be pushed"
for tok in $PUSH_ARGS; do
case "$tok" in
--force|-f|--mirror|--all|--tags|--delete|-d|--prune)
PUSH_GUARDED=1
PUSH_WHY="$tok forces, deletes or mass-updates remote refs"
;;
-*) ;;
*)
if [ "$REMOTE_SEEN" = 0 ]; then
REMOTE_SEEN=1
else
REFSPECS=1
DST="${tok#*:}"
case "$tok" in
+*) PUSH_GUARDED=1; PUSH_WHY="a + refspec force-updates the remote ref" ;;
:*) PUSH_GUARDED=1; PUSH_WHY="an empty source deletes the remote ref" ;;
esac
case "$DST" in
main|refs/heads/main|HEAD|refs/tags/*|v[0-9]*)
PUSH_GUARDED=1
PUSH_WHY="the push targets $DST, which reaches main, a tag or an unnamed branch"
;;
esac
fi
;;
esac
done
[ "$REFSPECS" = 1 ] || PUSH_GUARDED=1
fi

if [ "$PUSH_GUARDED" = 1 ]; then
WHAT="git push: $PUSH_WHY; a push to main deploys site/ to GitHub Pages and feeds release-please (a plain branch push, git push -u origin <branch>, passes without asking)"
elif matches "${B}gh[[:space:]]+pr[[:space:]]+merge([[:space:]]|$)" || matches "${B}gh[[:space:]]+api[[:space:]].*pulls/[^[:space:]/]+/merge"; then
WHAT="merging a pull request lands it on main; merging the Release PR tags vX.Y.Z and publishes a GitHub Release with the Chrome Web Store zip"
elif matches "${B}gh[[:space:]]+release[[:space:]]+(create|edit|upload|delete|delete-asset)([[:space:]]|$)" || { matches "${B}gh[[:space:]]+api[[:space:]].*/releases" && matches "$WRITE_METHOD"; }; then
Expand All @@ -63,9 +109,7 @@ fi
[ -n "$WHAT" ] || exit 0

if [ "$FORCE_DENY" = 1 ] || [ "$IN_SUBAGENT" = 1 ]; then
hook_pretooluse_decision deny "Release guard (subagent): $WHAT. Subagents never push, merge, release, or dispatch workflows. Put the exact command and the verification checklist in your report; the human runs it from the main session."
elif [ "$MODE" = bypassPermissions ] || [ "$MODE" = dontAsk ]; then
hook_pretooluse_decision deny "Release guard: $WHAT. This session runs in $MODE, where a hook cannot prompt; run the command from a session in default or auto mode, or Paul runs it by hand."
hook_pretooluse_decision deny "Release guard (subagent): $WHAT. Subagents never push to main, merge, release, or dispatch workflows. Put the exact command and the verification checklist in your report; the human runs it from the main session."
else
hook_pretooluse_decision ask "Release guard: $WHAT. main is the deploy and release branch (docs/RELEASING.md), so this prompt is the gate. Approve only if this is the intended step."
fi
Expand Down
2 changes: 1 addition & 1 deletion .claude/hooks/session-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,5 @@ echo "- Latest GitHub Release: $LATEST_REL$REL_NOTE"
echo "- Open Release PR: $RELEASE_PR"
echo "- Last CI on main: $CI_RUN. Last Deploy Site run: $PAGES_RUN"
echo "- Open issues: $ISSUES"
echo "- Merging the Release PR is the release gate (tags, GitHub Release, store zip); the Chrome Web Store upload is manual under paul@pmds.info. The release guard asks before git push, gh pr merge, gh release writes, gh workflow run and gh run rerun, and denies them in subagents."
echo "- Merging the Release PR is the release gate (tags, GitHub Release, store zip); the Chrome Web Store upload is manual under paul@pmds.info. The release guard asks before a push to main (a plain branch push passes), gh pr merge, gh release writes, gh workflow run and gh run rerun, and denies them in subagents."
exit 0
2 changes: 1 addition & 1 deletion .claude/rules/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ paths:
- The Chrome Web Store upload is manual: Paul downloads the zip and uploads it in the Developer Dashboard under paul@pmds.info (item `gnlidlpidaoalbbmekofjednjkhhmehn`). The store requires `manifest.json` at the archive root and a version greater than the published one. `/store-upload-check [tag]` verifies the package first.
- `pages.yml` deploys `site/` on a push to `main` touching `site/**`. `ci.yml` runs on every PR and push to `main`: Lint & Format, Type Check, Test, Build, Extension E2E (MV3) under xvfb, and Claude Code conformance & hook tests (`node scripts/claude-code-conformance.js --root . --strict`, `node --test scripts/__tests__/*.test.js`). Branch protection on `main` requires these checks on an up-to-date branch; administrators can bypass in an emergency.
- Workflows pin `actions/checkout@v7`, `pnpm/action-setup@v6` (pnpm 10), `actions/setup-node@v6` (Node 22). Dependabot (`.github/dependabot.yml`) proposes action updates weekly with a `ci` prefix.
- Never hand-edit a version or `CHANGELOG.md`, never `gh release create`, never push a tag. The release guard asks before `git push`, `gh pr merge`, `gh release` writes, `gh workflow run` and `gh run rerun`, and denies them in subagents.
- Never hand-edit a version or `CHANGELOG.md`, never `gh release create`, never push a tag. The release guard asks before a push that reaches `main` (a plain branch push passes), `gh pr merge`, `gh release` writes, `gh workflow run` and `gh run rerun`, and denies them in subagents.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The root `package.json` exists for release-please's version stamp and has no scr

- A push to `main` that touches `site/**` deploys the site to GitHub Pages (`pages.yml`). Every push to `main` runs release-please, which maintains one Release PR. **Merging the Release PR is the human release gate**: it stamps the version into `package.json`, `app/package.json` and `app/manifest.json`, updates `CHANGELOG.md`, tags `vX.Y.Z`, publishes a GitHub Release and attaches `optia-X.Y.Z.zip` with the manifest `key` stripped. Paul uploads that zip in the Chrome Web Store Developer Dashboard under paul@pmds.info; the store needs a version greater than the published one. `docs/RELEASING.md` has the detail. Never hand-edit a version.
- CI (`ci.yml`) on every PR and push to `main`: lint, typecheck, test, build, the MV3 e2e under xvfb, and Claude Code conformance with the hook tests. Branch protection on `main` requires those checks.
- The `release-guard` hook asks before `git push`, `gh pr merge`, `gh release create|edit|upload|delete`, `gh workflow run` and `gh run rerun`, and denies them inside subagents. Do not look for a way around it; hand Paul the command.
- The `release-guard` hook asks before a push that reaches `main` (a plain branch push, `git push -u origin <branch>`, passes), `gh pr merge`, `gh release create|edit|upload|delete`, `gh workflow run` and `gh run rerun`, and denies them inside subagents. Do not look for a way around it; hand Paul the command.

## Working conventions

Expand Down
35 changes: 23 additions & 12 deletions scripts/__tests__/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,20 @@ describe("release-guard (PreToolUse, Bash)", () => {
"git push origin main": /git push/,
"gh pr merge 123": /merging a pull request/,
"gh release create v1.2.0": /gh release writes/,
"git push": /git push/,
"git push -u origin chore/claude-code-conformance": /git push/,
"git push origin HEAD:main": /git push/,
"git push --force-with-lease origin fix/x": /git push/,
"git -C /tmp/x push": /git push/,
"git --no-pager push origin main": /git push/,
"cd app && git push": /git push/,
"git push": /no branch is named/,
"git push origin HEAD:main": /targets main/,
"git push origin HEAD": /targets HEAD/,
"git -C /tmp/x push": /no branch is named/,
"git --no-pager push origin main": /targets main/,
"cd app && git push": /no branch is named/,
"git push --force origin fix/x": /--force forces/,
"git push origin --delete feat/old": /--delete forces/,
"git push origin :feat/old": /empty source deletes/,
"git push --tags": /--tags forces/,
"git push origin v1.2.0": /targets v1\.2\.0/,
"git push origin +fix/x": /force-updates/,
"git push origin refs/heads/main": /targets refs\/heads\/main/,
"git push -u origin chore/x && git push origin main": /targets main/,
"gh pr merge 82 --squash --subject 'chore(main): release 1.2.0'": /merging a pull request/,
"gh api -X PUT repos/PMDevSolutions/Optia/pulls/82/merge": /merging a pull request/,
"gh release edit v1.1.3 --notes 'x'": /gh release writes/,
Expand Down Expand Up @@ -88,7 +95,7 @@ describe("release-guard (PreToolUse, Bash)", () => {
for (const cmd of ["git push origin main", "gh pr merge 123", "gh release create v1.2.0"]) {
const res = guard(cmd, { agent_id: "a1b2", agent_type: "release-manager" });
assert.equal(res.permissionDecision, "deny", cmd);
assert.match(res.permissionDecisionReason, /Subagents never push, merge, release/);
assert.match(res.permissionDecisionReason, /Subagents never push to main, merge, release/);
}
});

Expand All @@ -102,11 +109,10 @@ describe("release-guard (PreToolUse, Bash)", () => {
assert.equal(res.permissionDecision, "deny");
});

test("downgrades ask to deny where a hook cannot prompt", () => {
test("asks in bypassPermissions and dontAsk too (Claude Code decides what ask means there)", () => {
for (const permission_mode of ["bypassPermissions", "dontAsk"]) {
const res = guard("git push origin main", { permission_mode });
assert.equal(res.permissionDecision, "deny", permission_mode);
assert.match(res.permissionDecisionReason, new RegExp(permission_mode));
assert.equal(res.permissionDecision, "ask", permission_mode);
}
});

Expand All @@ -121,6 +127,11 @@ describe("release-guard (PreToolUse, Bash)", () => {
"git diff main",
'git commit -m "fix: push fix for the badge"',
"git -c core.pager=cat log --grep push",
"git push -u origin chore/claude-code-conformance",
"git push --force-with-lease origin fix/x",
"git push -u origin chore/x && gh pr create --fill",
"git push origin feature/one feature/two",
"git push --dry-run -u origin chore/x",
"git fetch origin && git pull --rebase",
"git checkout -b chore/x origin/main",
"gh pr view 12",
Expand Down Expand Up @@ -164,7 +175,7 @@ describe("session-context (SessionStart)", () => {
assert.match(out, /- Latest GitHub Release: unavailable/);
assert.match(out, /- Open Release PR: unavailable/);
assert.match(out, /- Open issues: unavailable/);
assert.match(out, /release guard asks before git push/);
assert.match(out, /release guard asks before a push to main/);
});

test("reads the Release PR, latest release, CI, Pages and issues from gh", () => {
Expand Down
Loading