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.
- How to update an ADR (without breaking the audit trail) — the operational counterpart to the supersession-integrity job in this workflow; the bidirectional protocol the CI check enforces is documented step-by-step on that page, including the legal status state machine and the Notes-section convention for context shifts.
- ADR supersession pattern — when to supersede, when to annotate, and how to stay bidirectional — the standalone reference for the supersession case specifically: the four triggers, the two-file atomic protocol, cascade chains, orphan ADR recovery, and cross-directory pointer formats. The CI integrity check job 3 on this page enforces the bidirectionality constraint this page documents.
- ADR numbering scheme — padding, gaps, and collision recovery — the operational details the numbering job in this workflow assumes: 4-digit zero-padded IDs, monotonic-with-gaps allocation, never-renumber discipline, and the merge-base allocator that prevents two parallel PRs from claiming the same ADR number.
- ADR status template — format, fields, and the lifecycle beyond Accepted — the format guide for the Status field that the lint and supersession jobs in this workflow parse; use YAML frontmatter as the source of truth so the regex parsing is reliable, and pair the lint with a status-vocabulary check (Proposed / Trial-Period / Accepted / Superseded / Deprecated / Rejected) to catch the variant-explosion failure mode early.
- TOGAF ADR template — extend this workflow with TOGAF-specific lint rules: every ADR has at least one
requirements_traceabilityentry pointing at an Architecture Requirements Specification ID (otherwise the Phase H change request is incomplete), at least onestakeholder_mappingentry with a decision-maker role, and a validea_domainvalue (Business / Data / Application / Technology). The four jobs on this page (lint, numbering, supersession, index) work unchanged inside per-segment Architecture Repository directories. - Architecture Board charter template — extend this workflow with a small additional job (15 lines bash + grep) that verifies the
governance_review_idfrontmatter field is set on any ADR labeledarch-boardafter the corresponding Board meeting date — catches ADRs that reached the Board agenda but didn't get the bidirectional Governance Log link. The four core jobs stay domain-agnostic; the Board-specific check is opt-in per repo. - ADR tooling comparison — adr-tools vs Log4Brains vs adr-log vs hand-rolled — the GitHub Action is independent of which creation or publication tool your team uses. If you're choosing between adr-tools (Bash, creation only), Log4Brains (Next.js, publication only), or adr-log (Node/MADR, index generation), the comparison guide covers the decision tree for picking — the Action supplements whichever creation tool you adopt.
- ADR review checklist — what to look for before merging — the human review layer that CI can't enforce: a 12-item checklist covering reasoning quality (real rejection rationales, at least one negative consequence), traceability (PR link, atomic supersession), and longevity (no rotting version numbers). CI handles the mechanical structure items; this page handles everything above the automation ceiling.
- ADR adoption guide — how to introduce ADRs to a resistant team — CI enforcement is part of the adoption story: auto-numbering and template scaffolding from this workflow remove friction for new contributors, but the adoption guide covers when to enable vs delay the reasoning-quality lint jobs (enabling them before the team has written 10 ADRs creates friction at exactly the wrong moment).
- ADR lightweight template — LADR, Y-Statements, and the minimum viable format — the CI scaffolding on this page works identically whether the team uses the full Nygard template or the three-field LADR variant; the auto-numbering and template scaffolding jobs are format-agnostic. Set the default scaffold template to LADR in the action config to lower adoption friction in the first 90 days.
- When to retire an ADR — deprecation, supersession, and the never-delete rule — the lifecycle guide for the end state: the supersession integrity check in job 3 tests Superseded-by pointers, but NOT Deprecated-by pointers — this is intentional because Deprecated-by often points to a PR URL or runbook, not another ADR file. This page explains the one-file deprecation protocol, what Deprecated-by can point to, and why the CI check deliberately treats the two retirement states differently.
- Log4Brains ADR — generate an architecture decision record website from your codebase — the publishing layer that pairs with this CI workflow: the GitHub Action on this page validates and indexes ADRs at merge time; Log4Brains builds a searchable static site from the same files at deploy time. The two work together — use GitHub Pages deployment in Log4Brains and scope its workflow to
paths: doc/decisions/**just as the lint workflow above does, so both workflows run on the same trigger. Non-engineer stakeholders who can't navigate git read the Log4Brains site; engineers submit PRs that pass the lint workflow before the site is updated. - ADR directory structure in a monorepo — per-service vs central, numbering, and CI — how to configure the
pathsfilter when ADRs live in per-service directories (services/auth/docs/decisions/**) vs a central directory; the hybrid pattern with separate workflows for platform-level and service-local decisions; and the cross-directory supersession check that runs nightly across all service boundaries.