Topic: adr github action
The ADR GitHub Action — A CI Pipeline for Architecture Decision Records
A four-job GitHub Actions workflow that lints ADR structure, prevents number collisions on rebase, enforces supersession integrity, and auto-rebuilds the directory index on every PR. Copy-paste below; zero CI minutes on PRs that don't touch doc/decisions/.
TL;DR
One file (.github/workflows/adr.yml), four jobs (lint, numbering, supersession, index), one paths filter so only PRs touching doc/decisions/ spend CI minutes. Each job is intentionally small (20–40 lines) and uses only bash + grep + awk + the GitHub-provided actions/checkout and peter-evans/create-pull-request actions. Bootstrap:
mkdir -p .github/workflows
curl -o .github/workflows/adr.yml https://whychose.com/assets/adr-action.yml
The full YAML is reproduced below in this page so it stays readable in any AI search citation.
What this Action does (and explicitly doesn't)
The four jobs cover exactly the failure modes that show up in real ADR directories after the practice has been alive for six months. Anything outside that list is the reviewer's job, not CI's:
| Job | Catches | Doesn't catch |
|---|---|---|
| lint | Missing required headings; empty section bodies; wrong frontmatter shape | Quality of the writing; whether the rationale is actually sound |
| numbering | Two PRs claiming the same ADR number; gaps that suggest a missing record; non-zero-padded filenames | Whether the number ordering reflects chronological order (it doesn't have to) |
| supersession | Status: Superseded with no Supersedes link; broken supersedes link target; one-sided supersession (old ADR points at new, new doesn't point back) | Whether the supersession is appropriate (a reviewer call) |
| index | Stale doc/decisions/README.md after add / rename / supersede | Manual prose changes to the index header; those persist across regenerations |
The discipline is the same as the ADR template choice: CI is for things that are obviously wrong. Style, voice, and judgement are PR-review concerns. A workflow that tries to enforce "good ADRs" via lint produces noise; one that catches the four mechanical failure modes above gets used because it's right every time it fires.
The full workflow
# .github/workflows/adr.yml
name: adr
on:
pull_request:
paths:
- 'doc/decisions/**'
schedule:
# Nightly supersession check — catches drift the path filter misses
- cron: '17 3 * * *'
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Structure lint
run: |
set -euo pipefail
changed=$(git diff --name-only --diff-filter=AM \
origin/${{ github.base_ref }}...HEAD \
-- 'doc/decisions/*.md' \
| grep -v '0000-template.md' || true)
[ -z "$changed" ] && { echo "No ADRs changed."; exit 0; }
fail=0
for f in $changed; do
for pattern in '^# ' '^\*\*Status:\*\*' '^## Context' '^## Decision' '^## Consequences'; do
grep -qE "$pattern" "$f" || { echo "::error file=$f::Missing $pattern"; fail=1; }
done
awk '/^## (Context|Decision|Consequences)$/ { sec=$0; getline blank; getline body;
if (body ~ /^## /) print FILENAME":empty section: "sec }' "$f" \
| grep . && fail=1 || true
done
exit $fail
numbering:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Number-collision check
run: |
set -euo pipefail
base=$(git merge-base origin/${{ github.base_ref }} HEAD)
# Highest number on the trunk side
trunk_max=$(git ls-tree -r --name-only "$base" doc/decisions/ \
| grep -oE '^doc/decisions/[0-9]{4}-' \
| sort -u | tail -1 \
| grep -oE '[0-9]{4}' || echo "0000")
next=$(printf "%04d" $((10#$trunk_max + 1)))
# Numbers introduced by this PR
for f in $(git diff --name-only --diff-filter=A "$base"...HEAD -- 'doc/decisions/*.md'); do
num=$(basename "$f" | grep -oE '^[0-9]{4}' || echo "")
[ -z "$num" ] && { echo "::error file=$f::Filename missing 4-digit prefix"; exit 1; }
if [ "$num" != "$next" ]; then
echo "::error file=$f::Expected number $next (next free on trunk), got $num. Rebase and rename."
exit 1
fi
next=$(printf "%04d" $((10#$next + 1)))
done
supersession:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bidirectional supersession integrity
run: |
set -euo pipefail
fail=0
for f in doc/decisions/*.md; do
[ "$f" = "doc/decisions/0000-template.md" ] && continue
status=$(grep -m1 -oE '\*\*Status:\*\*\s*\w+' "$f" | awk '{print $NF}' || echo "")
if [ "$status" = "Superseded" ]; then
target=$(grep -m1 -oE 'Superseded by\s+[0-9]{4}-[\w-]+' "$f" | awk '{print $NF}' || echo "")
[ -z "$target" ] && { echo "::error file=$f::Status: Superseded but no 'Superseded by' link"; fail=1; continue; }
[ ! -f "doc/decisions/$target.md" ] && { echo "::error file=$f::Supersedes target doc/decisions/$target.md does not exist"; fail=1; continue; }
# Bidirectional: target must reference this file back
this=$(basename "$f" .md)
grep -qE "Supersedes\s+$this" "doc/decisions/$target.md" \
|| { echo "::error file=doc/decisions/$target.md::Missing 'Supersedes $this' back-reference"; fail=1; }
fi
done
exit $fail
index:
runs-on: ubuntu-latest
needs: [lint, numbering, supersession]
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.ref }} }
- name: Rebuild index
run: |
set -euo pipefail
{
echo "# Architecture Decisions"
echo
echo "Last regenerated: $(date -u +%Y-%m-%d) by .github/workflows/adr.yml"
echo
echo "| # | Title | Status | Date |"
echo "|---|---|---|---|"
for f in $(ls doc/decisions/[0-9]*.md | sort); do
num=$(basename "$f" | grep -oE '^[0-9]{4}')
title=$(grep -m1 '^# ' "$f" | sed 's/^# //')
status=$(grep -m1 -oE '\*\*Status:\*\*\s*\w+' "$f" | awk '{print $NF}')
date=$(grep -m1 -oE '\*\*Date:\*\*\s*[0-9-]+' "$f" | awk '{print $NF}')
link=$(basename "$f")
echo "| $num | [$title]($link) | $status | $date |"
done
} > doc/decisions/README.md
- uses: peter-evans/create-pull-request@v6
with:
commit-message: "chore(adr): regenerate index"
title: "chore(adr): regenerate decisions index"
branch: adr-index-${{ github.event.pull_request.number }}
delete-branch: true
The four failure modes this catches in practice
Mode 1: contributor copies the template, fills the title, ships
Most common failure for new ADR contributors. They copy 0000-template.md, type a title, fill in Context, and forget Consequences entirely. The PR review catches it half the time; the other half it ships and the directory grows a half-finished record. The structure-lint job above grep-checks every required heading and uses awk to detect heading-immediately-followed-by-heading (i.e. an empty section body). Both checks fire in under 100ms; both produce a GitHub annotation pointing the contributor at the missing piece.
Mode 2: two PRs claim the same ADR number
Two engineers each branch from main when the latest ADR is 0041. Both write a new ADR and number it 0042. Both PRs pass review on their own merits and merge — the second merge silently lands a second 0042. Now doc/decisions/ has two unrelated records under the same number, every git blame trail to "ADR-0042" is ambiguous, and someone has to rename one and update every reference. The numbering job above computes the next free number from the merge-base and fails the PR if its new ADR claims an already-taken number. The contributor rebases, the next-free recomputes, the PR passes. Catastrophic-after-the-fact bug becomes a 30-second CI failure.
Mode 3: one-sided supersession
An engineer reverses an old ADR by writing a new one. They mark the old one Status: Superseded and add a Superseded by 0073 link. They forget to add the matching Supersedes 0042 back-reference inside 0073. Six months later, anyone reading 0073 has no idea it overruled a prior decision; anyone reading 0042 sees the supersession but the new context isn't surfaced in the supersession target. The supersession job above checks both directions: every Status: Superseded needs a real link, the link target must exist, and the target file must contain the back-reference. Bidirectional integrity in 30 lines.
Mode 4: stale README index
Every ADR-tools tutorial says "keep an index in doc/decisions/README.md." Every team starts to. Every team falls behind within a quarter — someone adds a new ADR, doesn't update the index, the index now lists 14 ADRs when there are 17. New contributors copy the index format from the existing entries and now there's drift in formatting too. The index job above regenerates the README from the filesystem itself: ADR number, title, status, date, all read out of the files. After the lint / numbering / supersession jobs pass, this job opens a small PR (chore(adr): regenerate decisions index) that the original PR author can merge alongside their substantive change. The index never goes stale because it's never written by hand again.
Pitfalls and how to handle them
- Fork-PR security. The
indexjob needscontents: writeto open the regeneration PR. By default GitHub does not give that permission to PRs from forks, so the auto-index step will not run on forked PRs — it'll just emit a warning. That's the right default; you don't want a fork's CI to write to your repo. If your team uses forks heavily, run the regeneration weekly via thescheduletrigger instead, scoped againstmain. - The lint regex assumes Nygard format. If your team uses MADR, replace the section list with
'^---$'(frontmatter open),'^status:','^## Context','^## Decision','^## Consequences'. The full YAML on this page is intentionally Nygard-shaped because that's the most common open-source convention; one find-and-replace adapts it for MADR or arc42. - The numbering job runs against the merge base, not
main. This matters on long-lived feature branches that have already merged from main multiple times — the merge base is the most recent common ancestor, which is correct for collision detection but means the "next free number" can shift across rebases. The CI message tells the contributor to rebase if the number changes; it does not auto-renumber. - Schedule trigger doesn't have
github.base_ref. The lint and numbering jobs referencegithub.base_refwhich only exists forpull_requestevents. The supersession job does not, so the nightly schedule still catches drift across the whole directory. If you also want lint and numbering on the schedule, fall back togit diff main...HEAD(or your default branch). - The index job uses
peter-evans/create-pull-request. The only third-party action in the workflow. It's the cleanest way to open a PR from inside CI; alternatives include hand-rolling the GitHub RESTPOST /repos/{owner}/{repo}/pullscall with a token, which adds another 25 lines and a manualgit push. Trust profile: the action has 1.5k+ stars, MIT license, single maintainer (Peter Evans), used by ~50k repos. Acceptable.
How WhyChose helps
The Action above keeps the ADRs you have structurally honest. The harder problem — capturing the durable decisions that escaped into ChatGPT and Claude conversations and never reached the repo at all — is what WhyChose addresses. Run the open-source extractor against your AI chat exports once a quarter; the output is a list of decision-shaped exchanges with chosen options, alternatives, and conversation backlinks. Run the four-question test against each one. The ones that pass become new ADRs, drop into doc/decisions/, and the workflow above takes over from there — structure-linted, numbered without collision, indexed automatically. The result is an ADR directory that's both complete (the chat-buried decisions made it back in) and structurally sound (CI catches the four mechanical failure modes).
Related questions
Why not use one of the published ADR Actions on the GitHub Marketplace?
Because the published ones do less than you need and more than you want. The popular adr-tools-action wraps Nat Pryce's adr-tools CLI but only handles the npx adr new flow; it doesn't lint structure or check supersession. The ADR-validator actions tend to enforce a single rigid format (usually MADR with strict YAML frontmatter) which fails for teams using Nygard. The 90-line workflow on this page is roughly the union of what those actions do, with explicit handling for the four failure modes and zero unmaintained dependencies. Forking the YAML and editing it for your team's ADR format is faster than fighting an action that assumes a different format.
Does this run on every PR or only on PRs that touch ADRs?
Only on PRs that touch doc/decisions/. The paths filter limits it; PRs that touch only application code add zero CI minutes. The trade-off is rename-only PRs (e.g. moving the directory) won't trigger the lint — if you reorganize ADR locations, run the workflow manually once via workflow_dispatch. The path filter also doesn't catch supersession references that point to ADRs the PR doesn't modify; for that we run the supersession check on a nightly schedule too.
How does the number-collision check work on rebases?
The naive approach — "next ADR number = max existing + 1" — collides whenever two PRs branch from the same point and pick number N+1 simultaneously. The check on this page reads the merge base, computes the next-available number from the trunk side, and fails the PR if its new ADR claims an already-used number. The contributor rebases, the next-available number recomputes, and the PR passes. About 200ms per PR; well worth it once your ADR count crosses double digits.
What's the right way to handle supersession in CI?
Supersession is the highest-leverage thing CI can enforce. Two checks: (1) every ADR with Status: Superseded must have a Supersedes link to the ADR that replaced it, and the target must exist as a real file; (2) the new ADR must have a back-reference, and that reference must point at the new ADR. Bidirectional integrity. Without check (2), the supersession is one-sided — the old ADR knows it's been replaced, the new ADR doesn't know it replaced anything, and the directory has two records that quietly contradict each other. Both checks are 30 lines of bash + grep; both run in under a second.
Further reading
- ADR template for GitHub repos — the folder layout and PR template that pair with this workflow; the basic lint stub there is superseded by the four-job version on this page.
- ADR template in Markdown — copy-paste ready — the file format the lint job assumes.
- The Nygard ADR template — the original five-section structure the lint regex is calibrated against; switch the regex if your team uses a different format.
- MADR ADR template — adapt the lint regex for YAML frontmatter; one-line change per check.
- How to document architecture decisions (without your team revolting) — the human side of the practice; CI catches mechanics, this catches the cultural failure modes.
- ADR vs Design Decision Record — what belongs in
doc/decisions/vs in a PR body; affects which files this workflow runs against. - When to write an ADR — the four-question test that decides which decisions actually warrant the ceremony this workflow enforces.