A new hire follows your README, types make setup, and gets make: *** No rule to make target 'setup' — because six weeks ago someone renamed the target to bootstrap and never touched the docs.

This page shows how to make that failure impossible to merge: a CI check that extracts every command the README tells a human to run and fails the build when any of them no longer exists as a real make target or a script on disk. It is the enforcement layer under README-driven automation, where the working assumption is that every documented step maps to something a machine actually runs. Documentation that is merely written down rots silently; documentation that CI executes against the tooling cannot. If you have not yet built the targets the README quotes, start with writing a make bootstrap target for one-command setup and come back here to guard them.

Diagnostic

Drift is invisible until someone trips over it, so the first job is a command that surfaces it on demand. The README below documents four commands; the Makefile has since diverged. Reproduce the mismatch by extracting both sets and diffing them.

#!/usr/bin/env bash
set -euo pipefail

# Commands the README tells a human to run.
grep -hoE 'make [a-z][a-z0-9_-]*' README.md \
  | awk '{print $2}' | sort -u > /tmp/documented.txt

# Targets the Makefile actually defines.
grep -oE '^[a-zA-Z0-9_-]+:' Makefile \
  | tr -d ':' | sort -u > /tmp/actual.txt

echo "== documented but missing from Makefile =="
comm -23 /tmp/documented.txt /tmp/actual.txt

On a drifted repository this prints the offending targets — the ones the README promises but the Makefile can no longer deliver:

== documented but missing from Makefile ==
migrate
setup

The README still says make setup and make migrate; the Makefile now spells them bootstrap and db-migrate. Nothing failed at commit time, nothing failed in the test suite, and the two files will keep drifting apart until a human hits the wall. That is exactly the class of failure to move left into CI.

Where documented commands drift from Bar chart attributing README drift incidents to renamed targets, deleted scripts, undocumented new targets, and changed flags. Causes of README Command Drift renamed target 41% deleted script 27% new, undocumented 20% changed flag 12%
Most drift is a rename or a delete on the tooling side that the docs never mirror.

Root cause

The command list exists in two places — prose the human reads and a Makefile the machine runs — and nothing binds them. A rename, a deleted helper script, or a new target added without a matching doc line moves one copy and leaves the other stale. Git does not object because both files are syntactically valid; the test suite does not object because it invokes targets by their real names, not by the names printed in the README. The drift is a cross-file invariant — "every command the docs mention must resolve to real tooling" — and no tool enforces cross-file invariants unless you write one. The fix is not better discipline; it is to encode that invariant as a script and run it on every pull request, so the machine checks the one thing humans reliably forget.

It is worth being precise about why discipline alone fails here. The author who renames a target is, at that moment, thinking about the code change, not the documentation two directories away; the reviewer reads the diff of the Makefile and the diff of the source, but the README is unchanged and therefore invisible in the review. Nobody is negligent — the information needed to catch the drift is simply not in front of anyone at the moment the drift is created. A mechanical check inverts that: it re-derives the invariant from the current state of both files on every run, so it does not depend on any human happening to hold both halves in their head at the same time.

Resolution

Build one check script that reads the documented commands out of the README, resolves each against the real Makefile targets and the scripts/ directory, and exits non-zero on any command that no longer resolves. Wire it into a make target so it is runnable locally, then run that target in CI.

  1. Extract documented make commands and script paths from the README.
  2. Enumerate the real targets from the Makefile and the real scripts on disk.
  3. Diff the two sets in both directions and fail on any documented command with no backing tooling.

Save the following as scripts/check-doc-drift.sh and mark it executable with chmod +x scripts/check-doc-drift.sh.

#!/usr/bin/env bash
set -euo pipefail

README="${1:-README.md}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# 1. What the docs claim you can run.
grep -hoE 'make [a-z][a-z0-9_-]*' "$README" \
  | awk '{print $2}' | sort -u > "$tmp/doc_targets.txt"
grep -hoE '(\./)?scripts/[a-zA-Z0-9_./-]+\.sh' "$README" \
  | sed 's#^\./##' | sort -u > "$tmp/doc_scripts.txt"

# 2. What actually exists.
grep -oE '^[a-zA-Z0-9_-]+:' Makefile \
  | tr -d ':' | sort -u > "$tmp/real_targets.txt"
find scripts -type f -name '*.sh' 2>/dev/null \
  | sort -u > "$tmp/real_scripts.txt" || true

status=0

# 3a. Documented targets that no longer exist.
missing_targets="$(comm -23 "$tmp/doc_targets.txt" "$tmp/real_targets.txt")"
if [ -n "$missing_targets" ]; then
  echo "ERROR: README documents make targets that do not exist:"
  echo "$missing_targets" | sed 's/^/  make /'
  status=1
fi

# 3b. Documented scripts that are not on disk.
missing_scripts="$(comm -23 "$tmp/doc_scripts.txt" "$tmp/real_scripts.txt")"
if [ -n "$missing_scripts" ]; then
  echo "ERROR: README references scripts that do not exist:"
  echo "$missing_scripts" | sed 's/^/  /'
  status=1
fi

if [ "$status" -eq 0 ]; then
  echo "doc-drift: README commands match the Makefile and scripts/ directory."
fi
exit "$status"

Two decisions in this script matter. First, it resolves make targets against the Makefile itself rather than against make help, so a target that exists but lacks a ## help annotation still counts as real — this check is about existence, not documentation quality, and conflating the two produces confusing failures. Second, the script paths are normalized by stripping a leading ./, because a README writes ./scripts/doctor.sh while find prints scripts/doctor.sh; without that sed, every referenced script reports as missing and the check cries wolf.

Now expose it as a target so nobody has to remember the path:

.PHONY: check-docs
check-docs: ## Fail if README commands drift from Makefile targets or scripts
	@./scripts/check-doc-drift.sh README.md
Documentation drift check pipeline The README is parsed for commands, the Makefile and scripts directory are enumerated, the two sets are diffed, and CI passes or fails. How the Drift Check Runs Parse README grep make + scripts Enumerate real Makefile + scripts/ comm diff exit 0 or 1 A non-empty diff means the docs promise a command the tooling cannot deliver.
The check turns a cross-file invariant into a single pass/fail exit code.

Expected output

A clean run — README, Makefile, and scripts/ all agreeing — prints one line and exits zero, so the CI step goes green:

$ make check-docs
doc-drift: README commands match the Makefile and scripts/ directory.

A drifted run names every broken command and exits non-zero, which is what actually blocks the merge:

$ make check-docs
ERROR: README documents make targets that do not exist:
  make setup
  make migrate
ERROR: README references scripts that do not exist:
  scripts/reset-db.sh
make: *** [Makefile:14: check-docs] Error 1

The message points straight at the fix: either rename the target back, update the README to the new name, or restore the deleted script. Because the failure carries the exact command strings, the author does not have to reverse-engineer what CI is unhappy about — they read the two lists and reconcile them in a one-line edit.

Prevention

Run the check on every pull request so drift is caught on the change that introduced it, while the author still has the context to fix it. The workflow below checks out the branch and runs the same make check-docs a developer runs locally — no special CI-only logic, so a green local run predicts a green pipeline.

# .github/workflows/doc-drift.yml
name: Docs In Sync
on:
  pull_request:
    paths:
      - "README.md"
      - "Makefile"
      - "scripts/**"
jobs:
  doc-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check README against tooling
        run: make check-docs

Scoping the trigger with paths keeps the job from running on pull requests that touch neither the docs nor the tooling, which trims noise without weakening the guard — the only way to introduce drift is to edit one of those three paths, and every one of them is listed. For a faster local feedback loop, run the same target from a pre-commit hook so an author who renames a target is told before the commit even lands:

#!/usr/bin/env bash
set -euo pipefail
# .git/hooks/pre-commit  (or a pre-commit framework entry)
if git diff --cached --name-only | grep -qE '^(README\.md|Makefile|scripts/)'; then
  make check-docs
fi

The CI job is the authority and the hook is the courtesy: hooks can be skipped with --no-verify, so the merge gate must live in CI where it cannot be bypassed. Together they close both ends — the hook catches the honest mistake early, and CI catches the one that slips past.

What the CI gate does per pull request A decision on whether every documented command resolves to real tooling, leading to a passing merge or a blocked one. Does Every Documented Command Resolve? make check-docs exit code? Exit 0 merge is allowed Exit 1 gate blocks the PR
The gate is binary: a non-empty diff is a red check, not a warning to ignore.

This check pairs naturally with the onboarding health-check script: doctor proves the documented environment still builds, while check-docs proves the documented commands still exist. Run both in the same pull-request pipeline and the README is held honest from two directions — the words it prints resolve to real targets, and those targets still produce a working stack.

Order of checks in the pull-request pipeline Four ordered stages: checkout the branch, check documentation drift, run the doctor health check, then allow the merge. Pull-Request Guard Order 1 — checkout branch 2 — make check-docs 3 — make doctor 4 — merge allowed
Drift and health checks run before merge so both fail on the change that broke them.

Platform caveats

The check runs the same everywhere, but three of the tools it leans on differ by platform. Pin the behaviour so the gate does not pass on a developer laptop and fail on the CI runner, or vice versa.

macOS (Docker Desktop): stock macOS ships BSD grep, whose -E handling is fine here but whose -o prints matches slightly differently on multi-match lines; if you extend the patterns, test with ggrep from brew install grep to match the GNU behaviour the Ubuntu CI runner uses. WSL2: keep the repository on the Linux filesystem (~/code, not /mnt/c). A README saved from a Windows editor can carry \r\n line endings, and the trailing carriage return makes make setup\r never match the real setup target; run sed -i 's/\r$//' README.md or set .gitattributes to * text=auto so the check compares clean strings. Apple Silicon (ARM64): the check itself is architecture-neutral, but if scripts/ includes compiled helpers, find will list them while the README may document an amd64-only path; keep documented scripts as portable shell so the two sides resolve identically on every runner.

Rollback

The check adds one script, one target, and one workflow file, and mutates nothing else, so backing it out is a clean delete:

#!/usr/bin/env bash
set -euo pipefail
rm -f scripts/check-doc-drift.sh .github/workflows/doc-drift.yml
# remove the check-docs target block from the Makefile by hand, or:
git checkout -- Makefile
echo "Drift guard removed. README is no longer enforced against the tooling."

Because the guard is side-effect-free — it only reads files and returns an exit code — removing it cannot leave the repository in a broken state; the worst case is that documentation drift becomes silent again. If a single false positive is blocking an urgent merge, prefer narrowing the grep patterns over deleting the guard, so the invariant stays enforced for every command it can still resolve.

Frequently Asked Questions

Why grep the Makefile directly instead of parsing make help?

Because this check is about existence, not documentation quality. A target can be perfectly real yet lack a ## help annotation, and make help would omit it — so parsing help would report a live target as "missing" and fail the build for the wrong reason. Grepping ^[a-zA-Z0-9_-]+: out of the Makefile captures every declared target regardless of annotation. If you also want to enforce that public targets carry help text, that is a separate, complementary check — keep the two failures distinct so each error message means exactly one thing.

Won't this fail on a code fence that shows an example make command from another project?

It can, and that is the one real source of false positives. The grep matches any make <target> string in the README, including illustrative ones. Two fixes: scope the extraction to a delimited region of the README (for example only lines between <!-- commands:start --> and <!-- commands:end --> markers), or maintain a small allowlist file of intentionally-documented-but-external commands and subtract it with comm -23 before the diff. Start without either — most READMEs only mention their own commands — and add scoping only if a real example command trips the gate.

Should the check also fail on targets that exist but are not documented?

That is a policy choice, not a correctness one. Failing on undocumented targets pushes every new target into the README, which keeps the docs complete but adds friction to internal or throwaway helpers. A common middle ground is to warn — print the undocumented targets without setting a non-zero exit — so the list is visible in the CI log without blocking merges. The version in this guide only fails on the dangerous direction: a documented command with no backing tooling, which is the one that breaks onboarding.

How do I run this in a monorepo where each package has its own README?

Pass the README path as the script's first argument and loop over them. The script already accepts "${1:-README.md}", so a wrapper like for f in packages/*/README.md; do ./scripts/check-doc-drift.sh "$f"; done checks each one against the same Makefile and scripts/ directory. If packages have their own Makefiles, run the check from inside each package directory so the relative Makefile and scripts/ paths resolve to that package's tooling rather than the repository root's.