Files
rdbms-playground/scripts/adr-reserve.sh
T
claude@clouddev1 b5c848efcb
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s
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.
2026-06-23 20:41:42 +00:00

142 lines
5.2 KiB
Bash
Executable File

#!/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?"