Fails a build when two documents in a directory claim the same id — SPEC-031, ADR-007,
RFC-012.
The id is picked when the document is written, on a branch, and the rest of the filename varies (a date, a slug). So two branches each take the next free id they can see, and the files do not collide on disk when both merge. Only the id collides — and the id is what people, agents, tracker tasks and code comments actually cite. Nothing notices.
It is not rare. Across four Full Stack House repos at the time this action was written: 7 collided ids in one, 6 in another, 5 in a third, 2 in a fourth. One repo had renumbered by hand three times and had five live collisions again; another gave up and wrote "left as-is — renumbering would break inbound links" into its README.
jobs:
spec-numbers:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: fullstackhouse/unique-doc-ids-action@v1
with:
dir: docs/specsRun it locally too — the logic is a dependency-free bash script:
./scripts/check.sh --dir docs/specs| Input | Default | Notes |
|---|---|---|
dir |
docs/specs |
Also .ai/specs in some repos |
prefix |
SPEC- |
ADR-, RFC-, … |
id-width |
3 |
Exact digit count; see below |
ignore |
SPEC-000-template.md |
Space-separated globs |
known-duplicates |
— | Ids already colliding on the base branch |
check-open-prs |
off |
off | warning | error |
github-token |
github.token |
Only for check-open-prs |
id-width is fixed rather than "one or more digits" on purpose: with a variable width,
SPEC-31-x.md parses as id 31, is never compared against SPEC-031-x.md, and a typo quietly buys
itself a private id. Off-width filenames fail as malformed instead.
known-duplicates lets the guard land green on a repo that already has debt, while still failing on
every new collision. The list only shrinks: once an id is no longer duplicated, leaving it listed
fails the build on the base branch, because a stale entry silently re-permits the next clash on that
id.
Run it on push to the base branch, not only on pull_request. A pull_request build checks
out refs/pull/N/merge, which GitHub computes lazily — on a live PR we measured it three weeks
behind the real base. A PR can therefore be green against a base that has since gained a colliding
id, merge, and land the collision silently. The push run is what catches those.
Do not run it behind a paths / dorny/paths-filter gate. Filters usually exclude docs/**
and **/*.md, and a docs-only PR is exactly how a duplicate id arrives.
Route the base-branch failure somewhere a human reads. This is the step that is easiest to skip
and costs the most. When the guard fails on push, the offending PR has already merged and its
author has moved on — the only signals are a red mark on a commit nobody revisits and whatever mail
GitHub sends the person who pressed Merge. Detection nobody is told about is not detection, and a
collision is cheap to fix the hour it lands and expensive a month later. Add whatever your repo
already uses for CI alerts as a final step in the same job:
- name: Notify on failure
if: failure() && github.ref_name == 'main'
uses: fullstackhouse/slack-notify-action@v1 # or your own alerting step
with:
bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
channel: "#your-alerts"
title: "Duplicate spec number on main"
message: "Two documents under `docs/specs/` claim the same id. Renumber now, while almost nothing cites it yet."
status: failureGuard it on the base branch: on a pull_request the red check already sits on a PR its author is
looking at, so alerting there is noise.
Make it a required status check, with "require branches to be up to date" on. Without that setting the merge ref is never recomputed and the race above stays open; with it, GitHub serializes the merges for you and the guard becomes a real gate rather than an alarm. A merge queue does the same without the rebase tax.
Off by default, and deliberately so: an open PR may never merge, so an id it holds is not really
taken, and enforcing it would burn ids on abandoned branches. What it buys is timing — it fires
before the id reaches the document body, the PR description, the tracker task and forty code
comments, which is the only moment the fix is free. Every other layer fires after that. warning is
the useful setting.
MIT © Full Stack House