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:

JobCatchesDoesn't catch
lintMissing required headings; empty section bodies; wrong frontmatter shapeQuality of the writing; whether the rationale is actually sound
numberingTwo PRs claiming the same ADR number; gaps that suggest a missing record; non-zero-padded filenamesWhether the number ordering reflects chronological order (it doesn't have to)
supersessionStatus: 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)
indexStale doc/decisions/README.md after add / rename / supersedeManual 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

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).

Get early access

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