Skip to content
Open
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
116 changes: 116 additions & 0 deletions .github/workflows/adr-number-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: ADR number check

# Enforces ADR-014's amendment: an ADR number is claimed at PR-open time and must be
# unique against `next` AND against every other open PR. Without this, each author picks
# max+1 in isolation and collides with branches they cannot see — which is how the
# register ended up with three different decisions all titled ADR-027.

on:
pull_request:
# `labeled`/`unlabeled` so adding or removing `adr-reconciliation` re-evaluates
# without needing an empty commit.
types: [opened, synchronize, reopened, labeled, unlabeled]
paths:
- 'docs/architecture-decisions/**'
# Re-check open PRs when the base moves, so the loser of a simultaneous claim goes red
# before it can merge rather than after.
push:
branches: [next]
paths:
- 'docs/architecture-decisions/**'

permissions:
contents: read
pull-requests: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check ADR numbers are unique
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
ADR_DIR="docs/architecture-decisions"

nums_from() { grep -oE '^[0-9]{3}' <<<"$1" || true; }

# Numbers already on next (permanent — never recycled).
git fetch -q origin next
MERGED=$(git ls-tree --name-only "origin/next" -- "$ADR_DIR/" \
| xargs -r -n1 basename | grep -oE '^[0-9]{3}' | sort -u || true)

# Numbers this PR introduces (present here, absent on next).
MINE=$(comm -23 \
<(ls "$ADR_DIR" | grep -oE '^[0-9]{3}' | sort -u) \
<(printf '%s\n' "$MERGED" | sort -u))

if [ -z "${MINE//[[:space:]]/}" ]; then
echo "No new ADR numbers introduced."; exit 0
fi
echo "This PR claims: $(echo $MINE | tr '\n' ' ')"

fail=0

# 1. Collision with an already-merged number.
for n in $MINE; do
if grep -qx "$n" <<<"$MERGED"; then
echo "::error::ADR-$n already exists on next. Numbers on next are permanent and are never recycled."
fail=1
fi
done

# 2. Collision with another open PR's claim.
#
# Skipped for a reconciliation PR: one that exists to re-assign contested
# numbers necessarily claims numbers the PRs it is reconciling still hold,
# so this check would always fail it. The merged-number and heading checks
# still apply. Opt out with the `adr-reconciliation` label.
RECONCILE=""
if [ -n "${PR_NUMBER:-}" ]; then
RECONCILE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels \
--jq '.labels[].name' 2>/dev/null | grep -Fx 'adr-reconciliation' || true)
[ -n "$RECONCILE" ] && echo "Label 'adr-reconciliation' present - skipping the cross-PR check."
fi
if [ -n "${PR_NUMBER:-}" ] && [ -z "$RECONCILE" ]; then
for other in $(gh pr list --repo "$REPO" --state open --limit 200 \
--json number --jq '.[].number'); do
[ "$other" = "$PR_NUMBER" ] && continue
theirs=$(gh pr view "$other" --repo "$REPO" --json files \
--jq '.files[].path' 2>/dev/null \
| grep "^$ADR_DIR/[0-9][0-9][0-9]-" | xargs -r -n1 basename \
| grep -oE '^[0-9]{3}' | sort -u || true)
[ -z "$theirs" ] && continue
for n in $MINE; do
if grep -qx "$n" <<<"$theirs"; then
echo "::error::ADR-$n is also claimed by open PR #$other."
fail=1
fi
done
done
fi

# 3. Filename number must match the heading (ADR-014 rule 3).
for f in "$ADR_DIR"/[0-9][0-9][0-9]-*.md; do
n=$(basename "$f" | grep -oE '^[0-9]{3}')
head -1 "$f" | grep -qE "^#[[:space:]]*ADR-$n:[[:space:]]*\S" || {
echo "::error file=$f::First line must be '# ADR-$n: Title' (ADR-014 rule 3)."
fail=1
}
done

if [ "$fail" = "1" ]; then
taken=$(printf '%s\n%s\n' "$MERGED" "$MINE" | sort -u)
next=$(seq -f '%03g' 1 999 | grep -vxF -f <(echo "$taken") | head -1)
echo "::notice::Next free ADR number is $next."
exit 1
fi
echo "ADR numbers OK."
5 changes: 3 additions & 2 deletions docs/architecture-decisions/004-migration-tool-design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# ADR-004: Project Structure Migration Tool

## Status
Proposed
**Status**: Proposed
**Date**: 2025-01-25
**Deciders**: TBD

## Context

Expand Down
7 changes: 4 additions & 3 deletions docs/architecture-decisions/005-admin-script-runner.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Architecture Decision Record: Admin Script Runner Service
# ADR-005: Admin Script Runner Service

## Status
Accepted (Implemented)
**Status**: Accepted (Implemented)
**Date**: 2025-12-10
**Deciders**: TBD

## Context

Expand Down
39 changes: 39 additions & 0 deletions docs/architecture-decisions/014-consolidate-adr-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,45 @@ Execution steps:
- Numbers get assigned in a logical block rather than strictly by original authoring date; original
`Date` fields are preserved in each file.

## Amendment (2026-08-31): claiming a number

The original decision created one register but no way to **claim** a number. Every author picks
`max + 1` at authoring time, which is only correct if no one else is doing the same — and in practice
several were. At the time of this amendment the register had eight collisions across six open PRs, four
of them against numbers already merged to `next`: three different decisions were all titled `ADR-027`,
and a fourth reused `ADR-010`. Nothing catches this, because each PR is internally consistent and only
collides with branches its author cannot see.

**Numbers are claimed at PR-open time, and uniqueness is enforced by CI — not assigned at merge.**

Authoring does not change. Pick the next free number, name the file `NNN-kebab-title.md`, write the
`# ADR-NNN:` heading and cross-link by number as before. A workflow on any PR touching
`docs/architecture-decisions/**` computes the taken set as *numbers on `next`* ∪ *numbers claimed by
every other open PR*, and fails with the conflict and the next free number when they overlap. It re-runs
on every push and when `next` moves, so if two PRs open simultaneously and both pass, the second to
rebase goes red before it can merge.

### Why not assign the number at merge time

Deferring assignment sounds tidier and is worse in practice:

- The file is named wrong for the entire review. Reviewers read `draft-foo.md` / `ADR-XXX` and cannot
cite it in review comments or link it from other PRs.
- Cross-ADR links cannot be written until the number exists, so a cluster of related ADRs (this register
has three such clusters) has to be link-patched after the fact.
- Someone has to perform the rename plus link rewrite at every merge — which is exactly the manual
reconciliation this amendment exists to stop, just relocated and made recurring.

Enforcing at PR-open keeps the number stable from first commit and moves detection from *after merge*
to *before review*. The cost is a rename when CI reports a conflict, which is cheap because it is a
document, and rarer because the check names the next free number.

### Numbers are not recycled

A number that has appeared on `next` is permanent, even if that ADR is later superseded or deprecated.
A number that has only ever existed in an unmerged branch is not yet claimed and may be reassigned — that
is what let this reconciliation close the 028-030 gap rather than leave holes in the sequence.

## Alternatives Considered
- **Keep both, add an index that spans them.** Rejected: still two structures/locations to learn;
the drift problem remains.
Expand Down
Loading
Loading