Scheduled · 06:17 UTC · daily

Docs that ship with the code.

A schedule trigger runs daily. The routine scans every PR merged since the last successful run, flags the curated documentation that references the APIs those PRs changed, and opens a single rolling update PR against the docs repository for an editor to review. It flags. The editor decides.

06:17
UTC, daily
offset clear of the midnight rollover
1
rolling editor PR
automation/docs-api-drift, draft-by-default
2
drift signals
path drift · symbol drift, noise-gated
0
prose rewrites by the bot
the watch points · the editor writes
The routine, in six steps

Window in. Worklist out.

Every step is bounded: the scan window closes on the previous successful run, the API scope is a fixed prefix list, and the deliverable is a single rolling PR. Nothing escapes the envelope.

Resolve the window deterministic

Lower bound is the created_at of this workflow's previous successful run. Cold start falls back to the last 24h; manual dispatch can override via since. When the rolling PR is open, the bound widens to the window that PR recorded — so the originating merges stay in scope until drift clears.

# lower bound resolution since = dispatch_since or open_pr.recorded_window.start or last_successful_run.created_at or now() - 24h

List merged PRs paginated

Every PR with merged_at > since via gh. Pagination is bounded — a run that exhausts the cap exits inconclusive, so the next run rescans the same window instead of advancing past unpublished drift.

# gh search, paginated gh search prs --merged \ --repo $REPO \ --merged-at ">=$since" \ --json number,mergedAt,files

Classify the API surface prefix-gated

A change counts as API only when it lands under scripts/, portal/, or apps/ in a code file (.py .sh .js .mjs .cjs .ts .tsx). Test trees and build noise are filtered out — your editor's morning is not spent on __pycache__.

# API_PATH_PREFIXES ("scripts/", "portal/", "apps/") # API_PATH_IGNORE ("/tests/", "/test_", "/__pycache__/", "/node_modules/")

Extract two drift signals low-noise

Path drift — a file the docs name was modified, removed, or renamed (renames tracked by the old path, since that is what the docs still point at). Symbol drift — a def / class / export was removed or renamed. Re-added names are excluded, so a body edit that keeps the signature never registers.

# diff → signals path_drift = touched_paths & api_surface symbol_drift = (removed_defs - added_defs) >= MIN_SYMBOL_LEN & not_in(SYMBOL_STOPWORDS)

Cross-reference the docs exact-match

Every signal is matched against every Markdown file under docs/ (excluding _generated/ and _shared/). Path match is exact. Symbol match requires the identifier as a whole token. Every hit carries the doc path, line number, originating PR, and the changed path or symbol.

# every hit is fully cited { "doc": "docs/deploy-runbook.md", "line": 117, "signal": "path", "ref": "scripts/verify_all.py", "pr": "#1102" }

Publish the rolling PR draft

One PR on branch automation/docs-api-drift. New on first drift, refreshed on subsequent runs, closed automatically when a complete scan clears the worklist. Opened as draft — the merge button stays off until an editor explicitly clicks "Ready for review," the affirmative gate for "I actioned every flag."

# single rolling target, never duplicated branch: automation/docs-api-drift state: draft body: docs/_generated/api-drift-report.md push: fast-forward only
What gets flagged

Two signals. Both engineered for low noise.

Path matching is exact and effectively false-positive-free. Symbol matching is the noisier signal, so it is deliberately conservative — generic identifiers never reach the editor.

Signal 01

Path drift

The docs cite a path by name; the merged PR modified, removed, or renamed that file. Exact-match. Renames are tracked by the old path because the docs still point there.

# in the merged PR - scripts/verify_all.py + scripts/verify.py # in docs/deploy-runbook.md:117 Run scripts/verify_all.py before promoting.
Signal 02

Symbol drift

A def, class, or export was removed or renamed. The identifier must be distinctive — long, snake_cased, or camelCased — and survive the stopword filter before it is chased into the docs.

# in the merged PR - def build_brain_zip(out): ... + def build_brain_archive(out): ... # in docs/automation-versioning.md:42 Call build_brain_zip to materialize the bundle.
The deliverable

Every flag carries the receipt.

The PR body is the generated report. A per-document worklist, each line citing the changed path or symbol, the originating PR, and the exact line in the doc that still references it.

Operating envelope

Trigger, scope, permissions.

One trigger. One concurrency group. Permissions narrowed to what the publisher step actually needs. Failure modes are inconclusive-by-default so a partial scan can never silently un-flag stale docs.

Workflow
.github/workflows/docs-api-drift.yml
Trigger
schedule at 17 6 * * * · workflow_dispatch with optional since
Scanner
scripts/docs_api_drift.py — stdlib-only, shells out to gh
Report
docs/_generated/api-drift-report.md — rewritten every run; never hand-edited
Branch
automation/docs-api-drift — rolling, fast-forward only, draft PR
Token
STONEWALL_AUTO_REBASE_PAT (draft create) · falls back to GITHUB_TOKEN for scan-only paths
Permissions
actions: read · contents: write · pull-requests: write
Concurrency
static group docs-api-drift, queued (not cancelled)
API surface
code files under scripts/ · portal/ · apps/ — tests & build noise excluded
Doc surface
Markdown under docs/, excluding _generated/ and _shared/
Failure mode
incomplete scan → non-zero exit → report not refreshed · PR not closed · next run rescans
Tests
tests/test_docs_api_drift.py — window, classifier, symbol noise, report shape
Run it by hand

Same scanner. Same window math. No PR.

The scanner is stdlib-only and shells out to gh. Authenticate gh first and the local run is byte-equivalent to what CI emits.

Go deeper

The full record.

This page is the front door. The deeper artifacts — editor contract, walkthrough, showcase, marketing spotlight — live alongside.