Skip to content

Commit edf15c0

Browse files
soheimamclaude
andcommitted
ci: route the base-std docs tree, skip deleted sources, report unrouted files (#1938)
* Route the base-std docs tree and report unrouted or removed sources base-std#213 (be6d045) deleted the flat docs/B20, docs/PolicyRegistry and docs/ActivationRegistry pages and added an audience-layered docs/ tree. The route table mapped only the six deleted files, so the sync edited their target pages from an all-minus diff (adding "source file removed" banners to reference pages generated from an unchanged interface) and dropped the fifteen new files without a trace (#1928). Route table - Retire the rules for the deleted files. Add rules for overview.md, architecture.md, concepts/*, guides/*, reference/*, placed where docs/ia-guidelines.md and docs/content-guidelines.md put that content: chain-generic precompile mechanics on Base Protocol > Execution, the B20 component map and key concepts on the specification overview, execution and versioning guarantees on the invariants page, how-to guides on the existing Build on Base task pages, reference tables on the B20 supporting pages. - New `ignored` kind for upstream scaffolding (README, guide template, interface link index) so it stays out of the unrouted report. Sync script - `removed_paths`: derived by the workflow from the commit API for the verified sha (renames count their previous name). Removed files never route. - `classifyChangedPaths` buckets every changed path as routed, ignored, unrouted or removed; the PR body gains "Unrouted source files" and "Removed source files" sections. When nothing routes, the workflow files the report as an issue, one per source sha. - Placement proposals: unrouted Markdown sources go through one Haiku call that reads the IA and content guidelines plus the existing Specifications and Build on Base page list, and names the existing page each file belongs on with the guideline rule that decides it. Proposals are filtered against the candidate list; nothing creates a page. GUIDELINE_ROUTING=apply also edits the proposed pages. - `validateCallouts` rejects Warning/Note/Info/Tip callouts that talk about source files, restructures or "last known state"; prompt rules 10 and 11 say the same and let the sync replace a "Generated B20 reference for" placeholder description. Tests cover the new routes against the real be6d045 file list, removed path exclusion, classification, proposal filtering, the routing report and the callout guard. * Harden the routing report and removed_paths handling Only hyperlink source paths made of plain path characters in the routing report; anything else renders as inert code so a crafted filename cannot close the markdown link. Cap removed_paths in the script (200 entries, 512 bytes each) and make the workflow overwrite the field for every non-release dispatch, so the payload can never supply it. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent fd12749 commit edf15c0

10 files changed

Lines changed: 1046 additions & 77 deletions

File tree

‎.github/workflows/base-std-docs-sync.yml‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ jobs:
131131
permissions:
132132
contents: write
133133
pull-requests: write
134+
# Only used by "Open issue for unrouted source files" below: when a
135+
# dispatch routes nothing but changed files the route table does not
136+
# know, the routing report is filed as an issue instead of vanishing.
137+
issues: write
134138
steps:
135139
- name: Harden the runner
136140
# Audit mode logs every outbound connection without blocking. After a
@@ -772,6 +776,65 @@ jobs:
772776
773777
echo "::notice title=Payload provenance verified::kind=${KIND:-code-change} sha=${SHA:0:7} tag=${TAG:-none} pr_number=${PR_NUMBER:-none} artifact_run_id=${ARTIFACT_RUN_ID:-none} (all checks passed against ${SOURCE_REPO})"
774778
779+
- name: Derive trusted removed paths
780+
# A code-change dispatch lists changed_paths but not what happened to
781+
# each file. Before base-std#213 that did not matter; that commit
782+
# deleted six documentation files the route table mapped, and the
783+
# sync edited their target pages from an all-minus diff, adding
784+
# "source file removed" banners to reference pages generated from an
785+
# unchanged interface (base/docs#1928). The commit API is the trusted
786+
# source of per-file status, so derive `removed_paths` here — never
787+
# from client_payload — and let the script skip them for routing.
788+
# A rename counts its previous name as removed.
789+
#
790+
# Best-effort: an API failure leaves removed_paths empty and logs a
791+
# warning rather than failing a sync whose content is otherwise fine.
792+
# Runs for every non-release dispatch so the payload's own
793+
# removed_paths, if any, is always overwritten.
794+
if: env.PAYLOAD_KIND != 'release'
795+
env:
796+
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
797+
SHA: ${{ env.PAYLOAD_SHA }}
798+
SOURCE_TOKEN: ${{ secrets.DOCS_REPO_TOKEN }}
799+
PAYLOAD_PATH: ${{ steps.payload_file.outputs.path }}
800+
MAX_REMOVED_PATHS: 200
801+
MAX_REMOVED_PATH_BYTES: 512
802+
run: |
803+
set -euo pipefail
804+
805+
if [[ -z "${SHA:-}" ]]; then
806+
jq '.removed_paths = []' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"
807+
mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"
808+
echo "No sha in payload; removed_paths cleared."
809+
exit 0
810+
fi
811+
812+
commit="$RUNNER_TEMP/code-change-commit.json"
813+
code=$(curl -sS -o "$commit" -w '%{http_code}' \
814+
-H "Accept: application/vnd.github+json" \
815+
-H "Authorization: Bearer $SOURCE_TOKEN" \
816+
-H "X-GitHub-Api-Version: 2022-11-28" \
817+
"https://api.github.com/repos/${SOURCE_REPO}/commits/${SHA}") || code="000"
818+
if [[ "$code" != "200" ]]; then
819+
echo "::warning title=Removed paths unavailable::reading commit ${SHA:0:7} on ${SOURCE_REPO} failed (HTTP ${code}); deletions will route like edits this run"
820+
jq '.removed_paths = []' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"
821+
mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"
822+
exit 0
823+
fi
824+
jq -e '(.files // []) | all(.[]; (.filename | type == "string"))' "$commit" >/dev/null \
825+
|| { echo "::warning title=Removed paths unavailable::commit ${SHA:0:7} returned malformed file metadata"; jq '.removed_paths = []' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"; mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"; exit 0; }
826+
jq --argjson cap "$MAX_REMOVED_PATHS" --argjson bytes "$MAX_REMOVED_PATH_BYTES" '
827+
[ .files[]?
828+
| select(.status == "removed" or .status == "renamed")
829+
| (if .status == "renamed" then .previous_filename else .filename end)
830+
| select(type == "string" and length > 0 and length <= $bytes)
831+
] | unique | .[0:$cap]
832+
' "$commit" > "$RUNNER_TEMP/trusted-removed-paths.json"
833+
jq --slurpfile removed "$RUNNER_TEMP/trusted-removed-paths.json" \
834+
'.removed_paths = $removed[0]' "$PAYLOAD_PATH" > "$PAYLOAD_PATH.tmp"
835+
mv "$PAYLOAD_PATH.tmp" "$PAYLOAD_PATH"
836+
echo "::notice title=Trusted removed paths derived::sha=${SHA:0:7} removed_paths=$(jq length "$RUNNER_TEMP/trusted-removed-paths.json")"
837+
775838
- name: Derive trusted release routing inputs
776839
# client_payload.changed_paths is attacker-controlled JSON. For a
777840
# release, replace it with the paths returned by GitHub for the
@@ -1009,6 +1072,10 @@ jobs:
10091072
RELEASE_PAGE_CONCURRENCY: ${{ vars.RELEASE_PAGE_CONCURRENCY }}
10101073
CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }}
10111074
CLAUDE_MAX_TOKENS: ${{ vars.CLAUDE_MAX_TOKENS }}
1075+
# Repo variable. "propose" (default) lists a guideline-derived
1076+
# placement for unrouted source files in the PR/issue body;
1077+
# "apply" also edits the proposed pages; "off" skips the call.
1078+
GUIDELINE_ROUTING: ${{ vars.GUIDELINE_ROUTING }}
10121079
run: |
10131080
node scripts/sync-from-base-std/index.mjs --payload "$PAYLOAD_PATH"
10141081
@@ -1338,3 +1405,89 @@ jobs:
13381405
echo "::warning title=Docs PR step::Step succeeded but no PR URL was returned"
13391406
fi
13401407
echo "PR: ${pr_url:-(no url returned)}"
1408+
1409+
- name: Open issue for unrouted source files
1410+
# When a dispatch produces a PR, the routing report (unrouted and
1411+
# removed source files, guideline-derived placement proposals) is
1412+
# part of the PR body. When it produces no PR — nothing routed, or
1413+
# every routed page came back unchanged — the report would otherwise
1414+
# be visible only in the run log. File it as an issue instead, once
1415+
# per source sha, so a maintainer can add the missing route-table
1416+
# rule. Same REST + jq pattern as the PR step; no gh CLI.
1417+
if: steps.commit.outputs.no_changes == 'true' && steps.sync.outputs.unrouted_count != '' && steps.sync.outputs.unrouted_count != '0' && steps.sync.outputs.review_md_path != ''
1418+
env:
1419+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1420+
REPO: ${{ github.repository }}
1421+
REVIEW_MD_PATH: ${{ steps.sync.outputs.review_md_path }}
1422+
UNROUTED_COUNT: ${{ steps.sync.outputs.unrouted_count }}
1423+
SOURCE_REPO: ${{ env.PAYLOAD_SOURCE_REPO }}
1424+
SHA: ${{ env.PAYLOAD_SHA }}
1425+
PR_NUMBER: ${{ env.PAYLOAD_PR_NUMBER }}
1426+
PR_TITLE: ${{ env.PAYLOAD_PR_TITLE }}
1427+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
1428+
run: |
1429+
set -euo pipefail
1430+
if [[ ! -f "$REVIEW_MD_PATH" ]]; then
1431+
echo "::warning title=Unrouted report missing::sync reported ${UNROUTED_COUNT} unrouted file(s) but wrote no report"
1432+
exit 0
1433+
fi
1434+
short_sha="${SHA:0:7}"
1435+
title="Unrouted base-std docs: ${SOURCE_REPO}@${short_sha:-unknown}"
1436+
1437+
# One issue per source sha: a re-dispatch of the same commit updates
1438+
# the existing open issue instead of opening a twin.
1439+
existing=$(curl -sS \
1440+
-H "Accept: application/vnd.github+json" \
1441+
-H "Authorization: Bearer $GITHUB_TOKEN" \
1442+
-H "X-GitHub-Api-Version: 2022-11-28" \
1443+
"https://api.github.com/repos/${REPO}/issues?state=open&creator=app%2Fgithub-actions&per_page=100" \
1444+
| jq -r --arg t "$title" '[.[] | select(.pull_request == null) | select(.title == $t)][0].number // ""')
1445+
1446+
body_file="$RUNNER_TEMP/unrouted_issue_body.md"
1447+
{
1448+
if [[ -n "${PR_NUMBER:-}" ]]; then
1449+
echo "> **Source PR**: [${SOURCE_REPO}#${PR_NUMBER}](https://github.com/${SOURCE_REPO}/pull/${PR_NUMBER})${PR_TITLE:+ — _${PR_TITLE}_}"
1450+
echo ">"
1451+
fi
1452+
if [[ -n "${SHA:-}" ]]; then
1453+
echo "> **Merge commit**: [\`${short_sha}\`](https://github.com/${SOURCE_REPO}/commit/${SHA})"
1454+
fi
1455+
echo
1456+
echo "The docs sync ran for this commit and opened no PR: ${UNROUTED_COUNT} changed file(s) match no rule in \`scripts/sync-from-base-std/route-table.json\`. Add a rule (or an \`ignored\` rule) for each one, then re-dispatch the commit."
1457+
echo
1458+
cat "$REVIEW_MD_PATH"
1459+
echo
1460+
echo "_Opened by \`Apply Base Std Update\` workflow ([run](${RUN_URL}))._"
1461+
} > "$body_file"
1462+
1463+
payload_file="$RUNNER_TEMP/issue.json"
1464+
if [[ -n "$existing" ]]; then
1465+
echo "Updating existing issue #$existing"
1466+
jq -n --arg title "$title" --rawfile body "$body_file" '{title: $title, body: $body}' > "$payload_file"
1467+
status=$(curl -sS -o "$RUNNER_TEMP/issue_resp.json" -w "%{http_code}" \
1468+
-X PATCH \
1469+
-H "Accept: application/vnd.github+json" \
1470+
-H "Authorization: Bearer $GITHUB_TOKEN" \
1471+
-H "X-GitHub-Api-Version: 2022-11-28" \
1472+
"https://api.github.com/repos/${REPO}/issues/${existing}" \
1473+
--data-binary @"$payload_file")
1474+
expected="200"
1475+
else
1476+
echo "Creating issue: $title"
1477+
jq -n --arg title "$title" --rawfile body "$body_file" '{title: $title, body: $body}' > "$payload_file"
1478+
status=$(curl -sS -o "$RUNNER_TEMP/issue_resp.json" -w "%{http_code}" \
1479+
-X POST \
1480+
-H "Accept: application/vnd.github+json" \
1481+
-H "Authorization: Bearer $GITHUB_TOKEN" \
1482+
-H "X-GitHub-Api-Version: 2022-11-28" \
1483+
"https://api.github.com/repos/${REPO}/issues" \
1484+
--data-binary @"$payload_file")
1485+
expected="201"
1486+
fi
1487+
if [[ "$status" != "$expected" ]]; then
1488+
echo "::warning title=Unrouted issue not filed::issues API returned HTTP ${status} (expected ${expected}); the routing report is in this run's log"
1489+
cat "$RUNNER_TEMP/issue_resp.json" >&2 || true
1490+
exit 0
1491+
fi
1492+
issue_url=$(jq -r '.html_url // empty' "$RUNNER_TEMP/issue_resp.json")
1493+
echo "::notice title=Unrouted source files::${UNROUTED_COUNT} file(s) need a route-table rule — ${issue_url}"

‎scripts/sync-from-base-std/README.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,61 @@ The route table supports both exact `pages` and `page_globs`. Globs are expanded
1717
only against existing Markdown files beneath `docs/`; they cannot create new
1818
paths. This version intentionally does not create, rename, or delete API pages.
1919

20+
## Routing outcomes
21+
22+
Every changed source path in a `code-change` dispatch ends up in exactly one
23+
bucket, logged as `[routing]` and reported in the PR body (or an issue):
24+
25+
| Bucket | Meaning | What happens |
26+
|---|---|---|
27+
| routed | matched a rule with `kind` interface, product-doc, changelog-* | its pages are edited |
28+
| ignored | matched only a `kind: "ignored"` rule | nothing; deliberately unsynced (upstream README, authoring templates) |
29+
| unrouted | matched no rule | listed under **Unrouted source files** with a guideline-derived placement proposal |
30+
| removed | deleted in the source commit (`removed_paths`) | never routed; listed under **Removed source files** |
31+
32+
`removed_paths` is derived by the workflow from the commit API for the verified
33+
sha, never from the dispatcher's payload. A deleted documentation file carries
34+
nothing to sync: its docs pages are generated from surviving sources, and a
35+
deprecation shows up in the diff of the file that declares it. Routing a
36+
deletion used to hand the model an all-minus diff and produced "the source file
37+
has been removed" banners on reference pages (base/docs#1928); the validator now
38+
rejects callouts that describe repository housekeeping (`validateCallouts` in
39+
`safety.mjs`).
40+
41+
### Placement proposals from the IA guidelines
42+
43+
Unrouted Markdown sources go through one Haiku call (`proposePlacement`) that
44+
reads the same `docs/ia-guidelines.md` and `docs/content-guidelines.md` every
45+
page-editing prompt already carries, plus the title and description of every
46+
existing page under `docs/specifications/` and `docs/build-on-base/`, and
47+
returns the existing page each file's content belongs on with the guideline rule
48+
that decides it. Proposals are filtered back against the candidate list, so a
49+
hallucinated path is dropped; nothing here creates a page. The result lands in
50+
the PR or issue body so a maintainer can turn it into a route-table rule.
51+
52+
`GUIDELINE_ROUTING` (repo variable, default `propose`) controls it: `apply`
53+
also edits the proposed pages in the same run, tagged `guideline:<source>` in
54+
the routing log; `off` skips the call. Prompt-size caps:
55+
`PLACEMENT_MAX_CANDIDATES` (250), `PLACEMENT_EXCERPT_LINES` (60),
56+
`PLACEMENT_MAX_SOURCES` (25).
57+
58+
When a dispatch opens no PR (nothing routed, or every page came back unchanged)
59+
but has unrouted files, the workflow files the routing report as an issue titled
60+
`Unrouted base-std docs: <source_repo>@<sha>`, one per source sha.
61+
62+
### base-std `docs/` tree
63+
64+
Since base-std#213 the upstream docs are audience-layered (`overview.md`,
65+
`architecture.md`, `concepts/`, `guides/`, `reference/`). The route table maps
66+
them where `docs/ia-guidelines.md` and `docs/content-guidelines.md` put that
67+
kind of content: chain-generic precompile mechanics to Base Protocol →
68+
Execution, the B20 component map and key concepts to the specification
69+
overview, execution and versioning guarantees to the invariants page, how-to
70+
guides to the existing Build on Base task pages, and reference tables to the
71+
B20 supporting pages. Pages above the regeneration budget
72+
(`MAX_REGENERABLE_CHARS`) are skipped with a logged reason and need a human
73+
edit.
74+
2075
## Local checks
2176

2277
From the copied `docs-repo` root:
@@ -34,6 +89,11 @@ LLM_GATEWAY_API_KEY=... \
3489
--payload scripts/sync-from-base-std/fixtures/code-change-ib20.json
3590
```
3691

92+
`fixtures/code-change-docs-restructure.json` is the real file list from
93+
base-std@be6d045 with its `removed_paths`; run it the same way to exercise the
94+
docs-tree routes and the placement pass (set `RUNNER_TEMP` to see the routing
95+
report written as `sync-review.md`).
96+
3797
Configuration knobs are optional positive numbers:
3898

3999
- `CODE_CHANGE_PAGE_CONCURRENCY` (default `4`)

0 commit comments

Comments
 (0)