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:
@@ -45,18 +45,23 @@ ADR numbers are a single global sequence, so two branches can each grab
|
||||
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
|
||||
renumbering the former to ADR-0044.) To prevent it:
|
||||
|
||||
**Assign an ADR's number at merge-to-`main`, not at creation.** While the
|
||||
work lives on a non-`main` branch, draft the ADR under a placeholder — an
|
||||
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that
|
||||
way from any plan or notes. Give it the next free number only when the
|
||||
branch merges to `main`, renaming the file and updating its references in
|
||||
the same step.
|
||||
**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
|
||||
which superseded the earlier placeholder-until-merge default). The moment
|
||||
you know an ADR is needed — at branch start or mid-branch — run
|
||||
`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
|
||||
free number against `main` (the remote ref is the registry; a `git push`
|
||||
is the compare-and-swap, retried on contention) and records it in the
|
||||
append-only ledger `docs/adr/RESERVATIONS.log`. The number is then **stable
|
||||
from creation**, so it is safe to cite in commit messages (immutable under
|
||||
the no-rewrite rule), in other ADRs, and in the PR from the first commit.
|
||||
Create the ADR as `docs/adr/<NNNN>-<slug>.md` and add its README index row
|
||||
as part of the branch's normal work.
|
||||
|
||||
A number is "taken" only once it appears in `main`'s `docs/adr/README.md`,
|
||||
which is the single source of truth for the next free number — never
|
||||
compute "next" from a feature branch. A branch that genuinely needs a real
|
||||
number up front may instead reserve one by landing a stub index entry on
|
||||
`main` first, but placeholder-until-merge is the default.
|
||||
A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
|
||||
`main`; the script reads both to compute the next free number — never
|
||||
compute "next" by hand from a feature branch. The full rationale (why
|
||||
reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
|
||||
is in **ADR-0059**.
|
||||
|
||||
### Subproject ADR namespaces
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# ADR-0059: Branch-and-PR working method — worktrees, merge commits, and reserve-first ADR numbering
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **2026-06-23**, on branch `chore/dev-workflow` (pending merge).
|
||||
Number **0059**, the second ADR reserved via the reserve-first flow this
|
||||
ADR itself defines (the first was ADR-0058). Updates the **Numbering
|
||||
discipline** section of **ADR-0000** (supersedes its placeholder-until-merge
|
||||
default with reserve-first). Does not affect the subproject ADR namespaces
|
||||
(`docs/website/adr/`, `docs/ci/adr/`).
|
||||
|
||||
## Context
|
||||
|
||||
The repository is now public, and feature work is moving from
|
||||
commit-straight-to-`main` toward branches. A public, multi-branch project
|
||||
needs a written, trackable working method instead of ad-hoc habits.
|
||||
|
||||
The sharp edge that forced the issue is **ADR numbering**. ADR numbers are
|
||||
a single global integer sequence; two branches that each grab "the next
|
||||
number" collide on merge (this happened, and was the original motivation
|
||||
for ADR-0000's numbering rules). The deeper problem is fundamental:
|
||||
|
||||
> A contiguous integer requires a single allocator. Plain git branches have
|
||||
> **no shared allocator** until they reconverge at merge — so any number
|
||||
> claimed independently on a branch is speculative and can collide.
|
||||
|
||||
Earlier work papered over this for two long-lived subprojects by giving
|
||||
them their **own namespaces** (`ADR-website-NNN`, `ADR-ci-NNN`), which is
|
||||
right for a nameable body of decisions but doesn't generalise to every
|
||||
feature branch. ADR-0000 also wrote a **placeholder-until-merge** default
|
||||
("assign the number at merge"), but it had never actually been exercised,
|
||||
and exercising it revealed two fatal frictions:
|
||||
|
||||
1. **ADR numbers are referenced from the first commit onward** — in commit
|
||||
messages (which are immutable under our no-rewrite rule, so a wrong
|
||||
number there is wrong *forever*), in other ADRs, in the PR. A number
|
||||
that only crystallises at merge leaves every earlier reference dangling.
|
||||
2. ADRs are *not* low-collision in practice: they're born precisely on the
|
||||
**long-lived feature branches most likely to run in parallel**, so
|
||||
"rare collision" is not a safe assumption.
|
||||
|
||||
So the requirement is a number that is **stable from creation, contiguous,
|
||||
and collision-free** — which is only possible by acquiring a lock *up
|
||||
front*. The insight that unlocks it: **`main` is the registry and an atomic
|
||||
`git push` is the lock** — git serialises ref updates, so a push is a
|
||||
compare-and-swap. We just have to acquire it *before* referencing the
|
||||
number, not at merge.
|
||||
|
||||
This is a **genuine-choice** area (reasonable practitioners run GitHub
|
||||
Flow, Git Flow, trunk-based, etc.); the decisions below adapt mainstream
|
||||
practice to this repo's constraints (append-only history, self-hosted
|
||||
Gitea, solo-with-subagents development) rather than claiming a canonical
|
||||
default.
|
||||
|
||||
## Decision
|
||||
|
||||
### D1 — Everything lands via a PR onto a protected `main`
|
||||
|
||||
`main` is always-green and protected; no direct pushes (one narrow
|
||||
exception — D4). Every change is a branch + pull request onto `main`. One
|
||||
logical change per branch/PR. Branch names carry a conventional prefix
|
||||
(`feat/ fix/ docs/ chore/ refactor/ test/ ci/`) aligned with the commit
|
||||
type. PRs reference their issue (`Closes #N`); the review we already
|
||||
perform (the Devil's-Advocate / `/runda` pass) is pasted into the PR so the
|
||||
public repo carries the audit trail.
|
||||
|
||||
### D2 — One worktree per branch (never switch the primary checkout)
|
||||
|
||||
Each branch is developed in its **own git worktree**, a sibling of the
|
||||
primary checkout named `<repo>-worktree-<last-segment-of-branch>` (the
|
||||
convention already in use for the `website` and `ci` worktrees). Switching
|
||||
the primary checkout's branch while another shell sits in the same
|
||||
directory is how work gets clobbered — worktrees make that impossible.
|
||||
Helpers:
|
||||
|
||||
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
|
||||
`origin/main`, and add the sibling worktree; prints its path.
|
||||
- **`scripts/wt-clean.sh [--dry-run]`** — prune stale entries and remove
|
||||
worktrees whose branch is merged into `origin/main` (refuses dirty ones;
|
||||
never touches the primary or current worktree). Run it whenever.
|
||||
|
||||
### D3 — Merge commits (`--no-ff`); never rebase or squash
|
||||
|
||||
Branches land with a **merge commit** (`--no-ff`). This is the only
|
||||
strategy consistent with the project's append-only / no-history-rewrite
|
||||
rule: squash and rebase-merge both rewrite history. Fast-forward-only is
|
||||
out because it would require rebasing branches onto `main`. History gains
|
||||
merge bubbles; that is the accepted cost of never rewriting.
|
||||
|
||||
### D4 — Reserve-first ADR numbering via `adr-reserve.sh`
|
||||
|
||||
ADR numbers are **reserved up front, the moment you know an ADR is
|
||||
needed** — which may be mid-branch or late, so reservation is decoupled
|
||||
from branch creation. `scripts/adr-reserve.sh <slug> [title]`:
|
||||
|
||||
1. reads an append-only ledger (`docs/adr/RESERVATIONS.log`) plus the
|
||||
existing `NNNN-*.md` ADRs on `main`; next = highest + 1;
|
||||
2. appends `<NNNN> <slug> <date> <title>`, commits, and **pushes to
|
||||
`main`**;
|
||||
3. if the push is rejected because `main` moved (someone reserved first),
|
||||
it re-fetches, recomputes, and retries — the push is the compare-and-swap,
|
||||
so allocations are unique by construction, not by luck.
|
||||
|
||||
It runs from any branch/worktree without touching the working tree (it does
|
||||
the allocation in a throwaway worktree on a detached `origin/main`), and on
|
||||
a *non-race* rejection (e.g. protection misconfigured) it **aborts loudly**
|
||||
rather than spinning. The number is therefore **stable from the moment it
|
||||
returns** — safe to cite in immutable commit messages and other ADRs from
|
||||
the first commit, which is what the references-problem (Context) demands.
|
||||
|
||||
The ADR file is created as `docs/adr/NNNN-<slug>.md` in the working branch
|
||||
and its README index row is added there as normal work; the ledger line on
|
||||
`main` is the allocation record and never conflicts with feature work
|
||||
(feature branches don't touch it). The ledger may be pruned later; it's a
|
||||
lock record, not state. **Collisions** (two reservations in the same
|
||||
instant) are resolved automatically by the retry; the README index edit is
|
||||
a secondary tripwire if one ever slips through.
|
||||
|
||||
This **supersedes ADR-0000's placeholder-until-merge default.** Subproject
|
||||
namespaces (`ADR-website-NNN`, `ADR-ci-NNN`) are unchanged — they solve a
|
||||
different problem (a nameable long-lived body of decisions) and stay.
|
||||
|
||||
### D5 — Branch protection configuration
|
||||
|
||||
On `main` (Gitea → Settings → Branches):
|
||||
|
||||
- **Require pull requests** + **require the CI status check** (`ci / gate*`)
|
||||
to pass before merge, and **block merge of out-of-date branches** (this
|
||||
is what makes "`main` is the lock" airtight for the substantive merges).
|
||||
- **Whitelist the repo owner for direct push.** This is the one narrow
|
||||
carve-out that lets `adr-reserve.sh` push the append-only ledger line
|
||||
directly; everything substantive still goes through a PR. Without it the
|
||||
reserve script aborts with a clear "is the owner whitelisted?" message.
|
||||
|
||||
### D6 — Who does what
|
||||
|
||||
**Pushing and merging are human steps**; automated agents prepare branches,
|
||||
edits, PR text, and commit-message proposals but never push or merge. The
|
||||
reserve script is **run by the human** (so the ledger push originates from
|
||||
the human's invocation, consistent with that rule) — agents may write and
|
||||
test the script but not run its pushing path.
|
||||
|
||||
## Forks (all user-chosen, 2026-06-22/23)
|
||||
|
||||
- **Merge commits (`--no-ff`)** over squash or fast-forward-only — the only
|
||||
option consistent with append-only history (D3).
|
||||
- **Strict everything-via-PR** over allowing trivial direct commits — one
|
||||
carve-out only, the reservation line (D1/D4/D5).
|
||||
- **Branch protection enforced in Gitea** over convention-only — mechanical
|
||||
guarantee for a public repo (D5).
|
||||
- **Reserve-first contiguous integers** over: number-on-merge (rejected —
|
||||
breaks immutable references), Gitea-issue-number ids (rejected — non-
|
||||
contiguous, overlaps the legacy 0001–0057 range), or an allocator bot
|
||||
(rejected — machinery + a PR-rule exemption). Reserve-first keeps the
|
||||
short stable "ADR 0059" reference and needs no new infrastructure beyond
|
||||
one script (D4).
|
||||
- **Worktrees prescribed** over single-checkout branch switching — prevents
|
||||
the same-directory clobber hazard (D2).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A written, public-facing **`CONTRIBUTING.md`** (lean: setup, the CI gate,
|
||||
branch/PR conventions, the user-facing copy rules, "significant changes
|
||||
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
|
||||
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
|
||||
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
|
||||
`wt-clean.sh`, each with a local-`origin` test harness
|
||||
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
|
||||
idempotence, race-retry discriminator, and hard-abort paths are all
|
||||
covered (the real-remote push is exercised by the human running it).
|
||||
- `docs/adr/RESERVATIONS.log` appears on `main` as the allocation ledger.
|
||||
- ADR-0000's Numbering-discipline section is updated to point here.
|
||||
- `CLAUDE.md` gains the operational rules an agent must follow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Migrating the legacy `0001–0057` ADRs** to any new scheme — they keep
|
||||
their numbers; reserve-first applies going forward (next is `0060`).
|
||||
- **Automating PR creation/merge** — humans push and merge (D6).
|
||||
- **A CONTRIBUTING-side description of the reserve lock** — it's maintainer
|
||||
infrastructure, not contributor guidance (kept here instead).
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user