feat(deploy): deploy-commit.sh — allowlist surgical commit for .claude/deploy/
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho5EQCFTSvYamuRtVZpp2d
This commit is contained in:
parent
b210e8d6a8
commit
24e6b84add
55
lib/deploy-commit.sh
Normal file
55
lib/deploy-commit.sh
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy-commit.sh — surgical commit for the .claude/deploy/ runbook family.
|
||||||
|
# Allowlist scope = .claude/deploy/ ONLY (inverse of doc-commit's .claude exclusion).
|
||||||
|
set -u
|
||||||
|
|
||||||
|
_in_git_repo() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
_unsafe_state() { # 0 = unsafe
|
||||||
|
local g; g=$(git rev-parse --git-dir 2>/dev/null) || return 0
|
||||||
|
git symbolic-ref -q HEAD >/dev/null 2>&1 || return 0 # detached HEAD
|
||||||
|
[ -e "$g/MERGE_HEAD" ] || [ -d "$g/rebase-merge" ] || \
|
||||||
|
[ -d "$g/rebase-apply" ] || [ -e "$g/CHERRY_PICK_HEAD" ] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_out_of_scope() { # 0 = forbidden, 1 = in scope
|
||||||
|
case "$1" in
|
||||||
|
*..*) return 0 ;; # traversal — forbidden FIRST
|
||||||
|
.claude/deploy/*) return 1 ;; # allowed
|
||||||
|
*) return 0 ;; # everything else forbidden
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
_scope_violations() { local p; for p in "$@"; do _out_of_scope "$p" && printf '%s\n' "$p"; done; }
|
||||||
|
|
||||||
|
_changed_only() { # echo passed files that actually have changes
|
||||||
|
local p; for p in "$@"; do
|
||||||
|
[ -n "$(git status --porcelain -- "$p" 2>/dev/null)" ] && printf '%s\n' "$p"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"; shift || true
|
||||||
|
_in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; }
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
pending)
|
||||||
|
[ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; }
|
||||||
|
[ -n "$(_changed_only "$@")" ] && exit 0 || exit 1 ;;
|
||||||
|
commit)
|
||||||
|
msg="${1:-}"; shift || true
|
||||||
|
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
|
||||||
|
mapfile -t violations < <(_scope_violations "$@")
|
||||||
|
if [ "${#violations[@]}" -gt 0 ]; then
|
||||||
|
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
|
||||||
|
printf ' - %s\n' "${violations[@]}";
|
||||||
|
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
|
||||||
|
mapfile -t changed < <(_changed_only "$@")
|
||||||
|
[ "${#changed[@]}" -gt 0 ] || exit 1
|
||||||
|
git add -- "${changed[@]}"
|
||||||
|
git commit -q -m "$msg" -- "${changed[@]}" || { echo "deploy-commit: git commit failed" >&2; exit 1; }
|
||||||
|
git rev-parse --short HEAD ;;
|
||||||
|
*) echo "usage: deploy-commit.sh pending <file>... | commit \"<msg>\" <file>..." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
45
lib/tests/deploy-commit.test.sh
Normal file
45
lib/tests/deploy-commit.test.sh
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/tests/deploy-commit.test.sh
|
||||||
|
set -u
|
||||||
|
H="$(cd "$(dirname "$0")/.." && pwd)/deploy-commit.sh"
|
||||||
|
pass=0; fail=0
|
||||||
|
mkrepo() { local d; d=$(mktemp -d); git -C "$d" init -q; git -C "$d" config user.email t@t;
|
||||||
|
git -C "$d" config user.name t; mkdir -p "$d/.claude/deploy"; printf 'x\n' >"$d/seed";
|
||||||
|
git -C "$d" add seed; git -C "$d" commit -q -m seed; printf '%s' "$d"; }
|
||||||
|
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
|
||||||
|
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
|
||||||
|
|
||||||
|
d=$(mkrepo); printf 'run\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||||
|
out=$( cd "$d" && bash "$H" commit "docs(deploy): t" .claude/deploy/PROCEDURE.md ); rc=$?
|
||||||
|
check T1-rc "$rc" 0
|
||||||
|
check T1-committed-only "$(git -C "$d" show --name-only --format= HEAD)" ".claude/deploy/PROCEDURE.md"
|
||||||
|
check T1-hash-nonempty "$([ -n "$out" ] && echo y || echo n)" y
|
||||||
|
|
||||||
|
d=$(mkrepo); printf 'b\n' >"$d/src.txt"
|
||||||
|
( cd "$d" && bash "$H" commit "x" src.txt ) >/dev/null 2>&1; check T2-out-of-scope-rc "$?" 4
|
||||||
|
|
||||||
|
d=$(mkrepo)
|
||||||
|
( cd "$d" && bash "$H" commit "x" ".claude/deploy/../memory/secret" ) >/dev/null 2>&1
|
||||||
|
check T3-traversal-rc "$?" 4
|
||||||
|
|
||||||
|
d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"; printf 's\n' >"$d/src.txt"
|
||||||
|
( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md src.txt ) >/dev/null 2>&1
|
||||||
|
check T4-mixed-refuses-all "$?" 4
|
||||||
|
check T4-nothing-committed "$(git -C "$d" rev-list --count HEAD)" 1
|
||||||
|
|
||||||
|
d=$(mkrepo); git -C "$d" checkout -q --detach
|
||||||
|
printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||||
|
( cd "$d" && bash "$H" commit "x" .claude/deploy/PROCEDURE.md ) >/dev/null 2>&1
|
||||||
|
check T5-unsafe-rc "$?" 3
|
||||||
|
|
||||||
|
d=$(mkrepo)
|
||||||
|
( cd "$d" && bash "$H" pending .claude/deploy/PROCEDURE.md ); check T6-pending-clean-rc "$?" 1
|
||||||
|
|
||||||
|
d=$(mkrepo); printf 'p\n' >"$d/.claude/deploy/PROCEDURE.md"
|
||||||
|
printf 'i\n' >"$d/.claude/deploy/INCIDENTS.md"; printf '{}\n' >"$d/.claude/deploy/STATE.json"
|
||||||
|
( cd "$d" && bash "$H" commit "docs(deploy): learn" .claude/deploy/PROCEDURE.md \
|
||||||
|
.claude/deploy/INCIDENTS.md .claude/deploy/STATE.json ) >/dev/null 2>&1
|
||||||
|
check T7-atomic-rc "$?" 0
|
||||||
|
check T7-three-files "$(git -C "$d" show --name-only --format= HEAD | grep -c deploy)" 3
|
||||||
|
|
||||||
|
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
|
||||||
Loading…
Reference in New Issue
Block a user