chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)
Adopt a trackable working method now the repo is public: - PRs onto a protected main; --no-ff merge commits; one worktree per branch. - Reserve-first ADR numbering: scripts/adr-reserve.sh claims the next number atomically against main (push = compare-and-swap; ledger docs/adr/RESERVATIONS.log), so a number is stable from creation. - Worktree helpers scripts/wt-new.sh + wt-clean.sh. - Local-origin test harnesses (reserve 10/10, worktrees 7/7, shellcheck clean). Record the decision in ADR-0059, supersede ADR-0000's placeholder-until-merge numbering default, and add the lean CONTRIBUTING.md sections + CLAUDE.md operational rules.
This commit is contained in:
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# adr-reserve.sh — atomically reserve the next main-sequence ADR number.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# A contiguous ADR integer needs a single allocator, but git branches have
|
||||
# no shared allocator until they merge — which is why two parallel branches
|
||||
# that each invent "the next number" collide. This script makes `main` the
|
||||
# allocator and an atomic `git push` the lock:
|
||||
#
|
||||
# 1. read an append-only ledger (docs/adr/RESERVATIONS.log) + the existing
|
||||
# NNNN-*.md ADRs on main; next = highest + 1;
|
||||
# 2. append "<NNNN> <slug> <date> <title>" to the ledger, commit, PUSH;
|
||||
# 3. if the push is rejected because main moved (someone reserved first),
|
||||
# re-fetch, recompute, and retry.
|
||||
#
|
||||
# A push to a ref is a compare-and-swap, so the remote `main` ref is the
|
||||
# mutex and step 3 is the retry-on-contention. The number is therefore
|
||||
# stable from the moment this returns — safe to cite in commit messages
|
||||
# (which are immutable) and in other ADRs from the very first commit.
|
||||
# (Decision recorded in the dev-workflow ADR.)
|
||||
#
|
||||
# It runs from ANY branch / worktree / directory inside the repo and never
|
||||
# touches your working tree: the allocation happens in a throwaway worktree
|
||||
# on a detached origin/main.
|
||||
#
|
||||
# PREREQUISITE
|
||||
# You must be allowed to push directly to `main` (the repo owner is
|
||||
# whitelisted in the branch-protection rule for exactly this). Everything
|
||||
# substantive still goes through a PR; this one append-only ledger line is
|
||||
# the sole sanctioned direct push. If protection rejects the push, the
|
||||
# script aborts with a clear message rather than spinning.
|
||||
#
|
||||
# USAGE
|
||||
# scripts/adr-reserve.sh <slug> [title...]
|
||||
# scripts/adr-reserve.sh clause-concept-hints "Clause-concept hints"
|
||||
#
|
||||
# OUTPUT
|
||||
# The reserved zero-padded number on stdout (e.g. 0058); progress on stderr.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf ' reserve-adr: %s\n' "$*" >&2; }
|
||||
die() { printf 'reserve-adr: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
REMOTE="origin"
|
||||
BRANCH="main"
|
||||
LEDGER="docs/adr/RESERVATIONS.log"
|
||||
MAX_ATTEMPTS=20
|
||||
|
||||
[ $# -ge 1 ] || die "usage: adr-reserve.sh <slug> [title...]"
|
||||
slug="$1"; shift
|
||||
title="${*:-}"
|
||||
|
||||
# Slug must match the NNNN-<slug>.md filename convention.
|
||||
[[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] \
|
||||
|| die "slug must be lowercase-kebab-case (got: '$slug')"
|
||||
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repo"
|
||||
|
||||
log "allocating next ADR number for '$slug' against $REMOTE/$BRANCH"
|
||||
git -C "$root" fetch --quiet "$REMOTE" "$BRANCH" || die "git fetch $REMOTE $BRANCH failed"
|
||||
|
||||
# Throwaway detached worktree on origin/main — isolated from your working tree.
|
||||
tmp_base="$(mktemp -d)"
|
||||
wt="$tmp_base/wt"
|
||||
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
|
||||
cleanup() {
|
||||
git -C "$root" worktree remove --force "$wt" >/dev/null 2>&1 || true
|
||||
rm -rf "$tmp_base"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git -C "$root" worktree add --quiet --detach "$wt" "$REMOTE/$BRANCH" \
|
||||
|| die "could not create temp worktree at $wt"
|
||||
|
||||
# Highest number across existing top-level ADR files and the ledger, + 1.
|
||||
compute_next() {
|
||||
local max=0 n base
|
||||
shopt -s nullglob
|
||||
for f in "$wt"/docs/adr/[0-9][0-9][0-9][0-9]-*.md; do
|
||||
base="$(basename "$f")"; n=$((10#${base%%-*}))
|
||||
(( n > max )) && max=$n
|
||||
done
|
||||
if [ -f "$wt/$LEDGER" ]; then
|
||||
while read -r num _; do
|
||||
[[ "$num" =~ ^[0-9]{4}$ ]] || continue
|
||||
n=$((10#$num)); (( n > max )) && max=$n
|
||||
done < "$wt/$LEDGER"
|
||||
fi
|
||||
printf '%04d' $(( max + 1 ))
|
||||
}
|
||||
|
||||
# Idempotence: if this slug is already reserved, report it and stop.
|
||||
if [ -f "$wt/$LEDGER" ]; then
|
||||
existing="$(awk -v s="$slug" '$2==s {print $1; exit}' "$wt/$LEDGER" || true)"
|
||||
if [ -n "${existing:-}" ]; then
|
||||
log "'$slug' is already reserved as ADR $existing — nothing to do"
|
||||
echo "$existing"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
while (( attempt < MAX_ATTEMPTS )); do
|
||||
attempt=$(( attempt + 1 ))
|
||||
git -C "$wt" fetch --quiet "$REMOTE" "$BRANCH"
|
||||
git -C "$wt" reset --hard --quiet "$REMOTE/$BRANCH"
|
||||
|
||||
num="$(compute_next)"
|
||||
|
||||
# Dry run: report the number we WOULD reserve and stop (no commit/push).
|
||||
# Useful for a preview and for testing the read/allocation path.
|
||||
if [ -n "${ADR_RESERVE_DRY_RUN:-}" ]; then
|
||||
log "DRY RUN: would reserve ADR $num for '$slug' (no push)"
|
||||
echo "$num"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$wt/docs/adr"
|
||||
printf '%s %s %s %s\n' "$num" "$slug" "$(date +%F)" "$title" >> "$wt/$LEDGER"
|
||||
git -C "$wt" add "$LEDGER"
|
||||
git -C "$wt" commit --quiet -m "docs(adr): reserve $num for $slug"
|
||||
|
||||
log "attempt $attempt: claiming ADR $num …"
|
||||
if push_out="$(git -C "$wt" push "$REMOTE" "HEAD:$BRANCH" 2>&1)"; then
|
||||
log "reserved ADR $num for '$slug' (pushed to $REMOTE/$BRANCH)"
|
||||
echo "$num"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Distinguish a lost race (retryable) from a hard rejection (not).
|
||||
if grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$push_out"; then
|
||||
log "race lost (main moved) — re-fetching and retrying"
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$push_out" >&2
|
||||
die "push to $BRANCH was rejected and this is not a race — likely branch \
|
||||
protection (is the owner whitelisted for direct pushes to $BRANCH?). Aborting."
|
||||
done
|
||||
|
||||
die "gave up after $MAX_ATTEMPTS contended attempts — main is changing very fast?"
|
||||
Reference in New Issue
Block a user