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
6 changes: 3 additions & 3 deletions .claude/hooks/block-protected-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ fi
branch=$(git -C "${CLAUDE_PROJECT_DIR:-.}" symbolic-ref --short HEAD 2>/dev/null || true)
case "$branch" in
master|dev)
echo "Blocked: '$branch' is a protected branch. Do not commit/push/merge directly onto it." >&2
echo "Start a feature branch first (git switch -c feature/...), move the work there, and open a PR." >&2
echo "master and dev only advance via a reviewed GitHub PR." >&2
echo "Blocked: '$branch' is a protected branch. Do not commit/merge directly onto it." >&2
echo "Start a feature branch first (git switch -c feature/...), move the work there, and open a PR into dev." >&2
echo "Release is a fast-forward: scripts/promote-dev-to-master.sh" >&2
exit 2
;;
esac
Expand Down
7 changes: 4 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@

## Checklist

- [ ] Tests pass (`pre-commit` fast tests green; full suite runs on `dev → master`)
- [ ] Targeting the right base branch (`dev` for features; `master` only for releases)
- [ ] Tests pass (`pre-commit` fast tests green)
- [ ] Targeting `dev` (feature PRs never target `master`)

<!--
Workflow: feature branch → PR into dev → review + receipt → merge → (later) dev → master.
Workflow: feature branch → PR into dev → review + receipt → merge.
Release (later): scripts/promote-dev-to-master.sh (fast-forward master to dev).
See workflow/branching.md and workflow/review-gate.md.
-->
4 changes: 2 additions & 2 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
export FORCE_COLOR=1

# Refuse pushing the local master/dev directly — they only move via merged PRs
# on GitHub. Pushing feature branches (to open a PR) is always fine.
# Refuse pushing origin/dev. origin/master may only fast-forward to origin/dev.
# Pushing feature branches (to open a PR into dev) is always fine.
. "$(dirname "$0")/protected-branch-guard.sh"
protected_branch_guard push

Expand Down
87 changes: 79 additions & 8 deletions .husky/protected-branch-guard.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# Shared guard: refuse direct commits/pushes on protected branches.
# Sourced by .husky/pre-commit and .husky/pre-push.
#
# All real work happens on feature branches; master and dev are only ever
# updated by merging a reviewed PR on GitHub. See CONTRIBUTING.md.
# All real work happens on feature branches. `dev` only moves via a reviewed
# GitHub PR. `master` only moves by fast-forwarding to current origin/dev
# (scripts/promote-dev-to-master.sh). See workflow/branching.md.
#
# Escape hatch (use sparingly, e.g. a hotfix): ALLOW_PROTECTED_COMMIT=1 git commit ...
# Escape hatch (use sparingly, e.g. a hotfix commit): ALLOW_PROTECTED_COMMIT=1

protected_branch_guard() {
local action="$1" # "commit" or "push"
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
action="$1" # "commit" or "push"

if [ "$action" = "push" ]; then
protected_branch_push_guard
return
fi

branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return 0

case "$branch" in
master|dev)
Expand All @@ -19,9 +25,10 @@ protected_branch_guard() {
fi
echo ""
echo "✋ Direct $action on '$branch' is blocked."
echo " master and dev only move via a reviewed GitHub PR."
echo " Work on a feature branch; merge to dev via a reviewed PR."
echo " Release: scripts/promote-dev-to-master.sh (FF origin/dev → master)."
echo ""
echo " Start a branch and move your work onto it:"
echo " Start a branch:"
echo " git switch -c feature/your-thing"
echo ""
echo " Override (rare): ALLOW_PROTECTED_COMMIT=1 git $action ..."
Expand All @@ -30,3 +37,67 @@ protected_branch_guard() {
;;
esac
}

# Git feeds pre-push: <local_ref> <local_sha> <remote_ref> <remote_sha>
protected_branch_push_guard() {
if [ "${ALLOW_PROTECTED_COMMIT:-}" = "1" ]; then
echo "⚠️ ALLOW_PROTECTED_COMMIT=1 set — allowing push."
return 0
fi

saw_ref=0
while read -r _local_ref local_sha remote_ref remote_sha; do
[ -z "${remote_ref:-}" ] && continue
saw_ref=1
case "$remote_ref" in
refs/heads/dev)
echo ""
echo "✋ Pushing to origin/dev is blocked. Open a PR into dev instead."
echo ""
exit 1
;;
refs/heads/master)
expected=$(git rev-parse origin/dev 2>/dev/null) || {
echo "✋ Cannot push master: origin/dev is missing. git fetch origin first."
exit 1
}
if [ "$local_sha" != "$expected" ]; then
echo ""
echo "✋ origin/master may only fast-forward to current origin/dev."
echo " Use scripts/promote-dev-to-master.sh"
echo " attempted: $local_sha"
echo " origin/dev: $expected"
echo ""
exit 1
fi
zeros=0000000000000000000000000000000000000000
if [ "$remote_sha" != "$zeros" ]; then
if ! git merge-base --is-ancestor "$remote_sha" "$local_sha"; then
echo ""
echo "✋ origin/master update is not a fast-forward."
echo " See scripts/promote-dev-to-master.sh (and --reset-master-to-dev"
echo " only when the trees already match)."
echo ""
exit 1
fi
fi
;;
esac
done

if [ "$saw_ref" = 1 ]; then
return 0
fi

# Empty stdin (unusual): still refuse a default push from master/dev.
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return 0
case "$branch" in
master|dev)
echo ""
echo "✋ Direct push on '$branch' is blocked."
echo " Release: scripts/promote-dev-to-master.sh"
echo ""
exit 1
;;
esac
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ Avoid `lens_diagnostics mode=full` unless you specifically need a project-wide s

Read `workflow/branching.md` before working with branches or creating PRs. Key points:
- Always compare to `dev`, not `master`
- Feature PRs target `dev`; `devmaster` is a separate release step
- Feature PRs target `dev`; release is `scripts/promote-dev-to-master.sh` (fast-forward `master` to `dev`), not a GitHub PR
52 changes: 52 additions & 0 deletions scripts/promote-dev-to-master.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Fast-forward origin/master to origin/dev (release).
# See workflow/branching.md.
set -euo pipefail

ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"

RESET=0
if [ "${1:-}" = "--reset-master-to-dev" ]; then
RESET=1
fi

git fetch origin

dev_sha=$(git rev-parse origin/dev)
master_sha=$(git rev-parse origin/master)

if [ "$dev_sha" = "$master_sha" ]; then
echo "origin/master already points at origin/dev ($dev_sha)."
exit 0
fi

if git merge-base --is-ancestor "$master_sha" "$dev_sha"; then
echo "Fast-forwarding origin/master to origin/dev ($dev_sha)."
git push origin "origin/dev:refs/heads/master"
exit 0
fi

echo "Cannot fast-forward: origin/master is not an ancestor of origin/dev."
echo " master: $master_sha"
echo " dev: $dev_sha"

if git diff --quiet origin/dev origin/master; then
echo "Trees match (leftover GitHub merge commit on master)."
if [ "$RESET" = 1 ]; then
echo "Force-pushing origin/master to origin/dev (same tree)."
git push --force origin "origin/dev:refs/heads/master"
exit 0
fi
echo "One-time catch-up after old merge-commit promotes:"
echo " 1. Temporarily allow force-pushes on master (GitHub settings or"
echo " allow_force_pushes in scripts/protect-branches.sh), then:"
echo " 2. $0 --reset-master-to-dev"
echo " 3. Re-run scripts/protect-branches.sh so force-push is off again."
exit 1
fi

echo "Master has file changes that are not on dev. Land those on dev via a"
echo "feature PR first, then re-run this script."
git log --oneline origin/dev..origin/master
exit 1
59 changes: 31 additions & 28 deletions scripts/protect-branches.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,31 @@
set -euo pipefail
REPO="AdamSpitz/commonality"

# Force the PR flow: no direct pushes to master/dev, no force-pushes/deletes,
# Force the PR flow on `dev`: no direct pushes, no force-pushes/deletes,
# require the PR to be up to date, and require conversations resolved.
# required_approving_review_count is 0 because this is a solo account (you
# can't approve your own PR); the review discipline is ENFORCED by the
# `review-received` status check on `dev` (see scripts/review-gate.mjs and
# workflow/review-gate.md), plus required_conversation_resolution which blocks
# merge until every posted finding is resolved.
#
# Review receipts are required on BOTH `dev` and `master`: a PR must carry a
# receipt for its head commit. The one exemption is a dev -> master release,
# which is a rubber-stamp of content already reviewed on the way into dev.
# Hotfix branches may still go straight into master — they just need a receipt,
# so nothing reaches the release branch unreviewed.
# `master` is the release pointer. It does NOT require a pull request: release
# is a fast-forward push of current `origin/dev` onto `master` (see
# scripts/promote-dev-to-master.sh). Force-push and delete stay off, so GitHub
# will reject anything that is not a fast-forward. There is no review-received
# check on `master`; content was already reviewed on the way into `dev`.
#
# `strict` (require the PR branch to be up to date with the base) is on for dev
# and OFF for master. Promoting dev -> master leaves a merge commit on master
# that dev lacks, so a strict master would demand a back-merge into dev before
# every single release.
for BRANCH in master dev; do
echo "=== Protecting $BRANCH ==="
# Hotfix branches should still land on `dev` (review gate) and then be
# promoted. A leftover PR into `master` is optional paper trail, not required.

if [ "$BRANCH" = "dev" ]; then
REQUIRED_STATUS_CHECKS='{
echo "=== Protecting dev ==="
gh api -X PUT "repos/$REPO/branches/dev/protection" \
--input - <<'JSON'
{
"required_status_checks": {
"strict": true,
"contexts": ["review-received"]
}'
else
REQUIRED_STATUS_CHECKS='{
"strict": false,
"contexts": ["review-received"]
}'
fi

gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" \
--input - <<JSON
{
"required_status_checks": $REQUIRED_STATUS_CHECKS,
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 0,
Expand All @@ -52,5 +40,20 @@ for BRANCH in master dev; do
"required_linear_history": false
}
JSON
echo " ok"
done
echo " ok"

echo "=== Protecting master (FF push allowed; no PR required) ==="
gh api -X PUT "repos/$REPO/branches/master/protection" \
--input - <<'JSON'
{
"required_status_checks": null,
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"required_conversation_resolution": false,
"allow_force_pushes": false,
"allow_deletions": false,
"required_linear_history": false
}
JSON
echo " ok"
8 changes: 4 additions & 4 deletions workflow/AUTOMATED_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ git add && git commit && git push # Push to trigger Render

### After (Automated)
```bash
# Merge the reviewed dev -> master release PR.
gh pr create --base master --head dev --title "Promote dev to master"
# Fast-forward master to current reviewed dev.
scripts/promote-dev-to-master.sh

# Everything else happens automatically:
# ✅ Contracts deployed to Base Sepolia
Expand Down Expand Up @@ -113,8 +113,8 @@ If you don't have these yet, see `workflow/github-secrets-guide.md`.
After running the setup script:

```bash
# Release an actual reviewed change through the protected branch workflow.
gh pr create --base master --head dev --title "Promote dev to master"
# Release reviewed dev by fast-forwarding master.
scripts/promote-dev-to-master.sh
```

Then watch:
Expand Down
Loading
Loading