Skip to content

fix(ci): docs-drift advisory names the tree it was computed on - #9524

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9519-advisory-names-its-tree
Aug 18, 2026
Merged

fix(ci): docs-drift advisory names the tree it was computed on#9524
os-steve merged 1 commit into
mainfrom
claude/issue-9519-advisory-names-its-tree

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#9519

Docs Drift Check runs on pull_request, so actions/checkout@v7 gives it the merge
of base and head, and affected-docs.mjs reads every page off that working tree with
readFileSync. The row set was therefore a fact about a commit that exists on no branch a
reader can name — and the comment's own re-derivation instruction, --json origin/BASEREF,
sent them to run the tool against their worktree. A page that gained or lost an anchor
token on the base branch after their branch was cut yields a row they cannot reproduce.

Measured cost, per the card: a reader did exactly that, grepped, found nothing, reported a
correct row as a false positive, and the follow-up spent a full round investigating a defect
class this tool does not have (the anchor set is derived fresh per run — no cache, index or
snapshot anywhere). Not claimed here either: that any row was wrong. Only that the tree was
unnamed.

What changed

scripts/docs-audit/affected-docs.mjs emits one new JSON field:

"computedOn": {
"head": "e2634384281134eae5ba291bb196c626727a2b21",
"headParents": ["097fe96e...", "047457ca..."],
"diffBase": "097fe96e1228f7da71f87e8f5ed95ae2739b53f1",
"dirty": false
}

.github/workflows/docs-drift-check.yml renders it in a collapsed "Which tree this was
computed on" section, in the same say what the run could not see voice the file already
uses, and pins the three re-derivation commands to diffBase instead of origin/BASEREF.

Why a sha alone would not have closed it

Printing git rev-parse HEAD is what the card suggested, and on its own it hands the reader
an identifier they cannot use: refs/pull/N/merge is ephemeral and GitHub drops it once the
PR closes. Three things were needed instead, each measured rather than assumed:

  1. headParents. The merge commit's two parents are the durable handle on that tree —
    both stay fetchable after the merge ref is gone, and re-merging them rebuilds it. Verified
    on a live refs/pull/9459/merge: parent 1 is the base, parent 2 is the PR head. That
    ordering is GitHub's convention rather than git's, so the renderer checks it against
    context.payload.pull_request.head.sha and leaves the pair unlabelled when the payload
    cannot confirm it — a confidently wrong label would send a reader to rebuild the mirror
    image of the tree.
  2. diffBase, not the resolved sinceRef. The row set depends on two commits, not one.
    The diff is three-dot, so diffBase publishes the merge-base the mapper had already
    resolved
    — which makes the emitted command replay correctly from a clone whose
    origin/main has moved on. Re-resolving origin/main at emit time would have reproduced
    the same trap one field down.
  3. dirty. The tool reads the working tree, not head's tree, so with uncommitted
    changes present the sha does not identify what was read. A sha that misidentifies the tree
    is worse than no sha, so the one condition under which it does is stated where the sha is.

The emitted recipe is therefore: fetch and check out the merge commit while the PR is open,
rebuild it from the two parents afterwards, then re-derive against diffBase.

Verification

The row set is unchanged — the PM's stated assumption, checked rather than asserted.
computedOn is read off git at the emit boundary, after every derivation has finished. Ran
the pre-change mapper and this one on three refs and diffed the full JSON:

origin/main~5 base=541b new=775b ← the only diff is the computedOn block
origin/main~30 base=25571b new=25805b ← idem
origin/main~120 base=77414b new=77648b ← idem (107 editable rows, 421 anchors)

docs, releaseOwnedDocs, anchors, detail, packageMentionDocs, changedPackages,
anchorlessChanges, every count and every summary string: byte-identical.

The emitted recipe actually reproduces the advisory. Checked out a real
refs/pull/9459/merge in a scratch worktree, ran the mapper as CI does (--json origin/main,
13 docs / 6 anchors), then ran the command this PR now emits (--json 097fe96e...) — from a
clone whose origin/main sits well past that merge base. Identical: docs,
releaseOwnedDocs, anchors, detail, packageMentionDocs, changedPackages,
anchorlessChanges all compare equal.

Rendering — simulated, never seen live.⚠️ A pull_request run cannot be triggered from
here, so the comment on this PR is the first real render. What was validated instead: the
Comment on PR script was extracted from the YAML and executed against stubbed
require / github / context / core — so the shipped source ran, not a retyped copy —
across seven inputs. Two rendering defects were found and fixed this way: the opening
sentence said "every page above" in the branch that lists no pages, and the closing
blockquote pointed at a section the missing-identity branch does not render.

caseoutcome
real refs/pull/9459/merge, 13 rowssection rendered, both parents labelled
capped list, 107 rowsrendered; capped re-derivation pinned to diffBase
anchors derived, zero pagesrendered — the reader whose tree yields a row is owed the reason
no anchors at allnot rendered — no opinion to disagree with, and every byte here is relayed
mapper emitted no identityloud one-line warning, never silence
dirty checkoutuncommitted-changes warning beside the sha
non-merge HEAD, payload cannot confirmparents left unlabelled, no guessed base/head

Self-test 220 → 233 (12 shape cases on a pure shaper + 1 presence pin). Reverse-verified
from the committed state, each ablation restored to 233 green afterwards:

ablationobserved
drop computedOn from the emitterred, only the presence pin — the 12 shape pins stay green, which is the point
collapse a failed dirty probe to falsered on "could not tell is not checked, clean"
keep one parent of a merge commitred on "a merge commit keeps BOTH parents, in order"

Untouched, per the dispatch ruling: the 503/5xx delivery-tolerance logic from #9373.
Proved rather than eyeballed — from the Delivery, and ONLY delivery comment to EOF, this
branch and origin/main are byte-identical.

Gates — derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, run after the final commit 3254f8147, all green:
check:docs-audit-scope, check:node-version, check:required-contexts,
check:shard-attestation, check:workflow-status-functions,
node scripts/docs-audit/check-affected-docs.mjs, plus check:nul-bytes (any-edit family;
the path derivation did not name it).

skip-changeset: this PR touches only .github/workflows/ and scripts/, so it publishes
nothing.

Generated by Claude Code


Generated by Claude Code

On `pull_request`, actions/checkout gives the MERGE of base and head, and the
mapper reads every page off that working tree with readFileSync. The row set was
therefore a fact about a commit that exists on no branch a reader can name — and
the comment's own re-derivation instruction sent them to run the tool against
their own worktree, where a page that gained or lost an anchor token on the base
branch after their branch was cut yields a different list. A reader did that,
grepped, found nothing, and reported a correct row as a false positive; the
follow-up investigated a defect class this tool does not have and cost a round.
- affected-docs.mjs emits `computedOn` = { head, headParents, diffBase, dirty }.
`diffBase` is the three-dot merge-base §2 had already resolved, not `sinceRef`
re-read — naming the commit is what makes the command replayable from a clone
whose `origin/main` has moved; naming the branch is what made it a trap.
`dirty` is the field's own correctness guard: the tool reads the working tree,
so with uncommitted changes present the shas do not identify what was read.
- The workflow renders it beside the re-derivation commands, with a recipe that
lands the reader on the same tree — the ephemeral merge commit while the PR is
open, its two parents (which stay fetchable) afterwards.
- The re-derivation commands are pinned to `diffBase` instead of `origin/<base>`.
Strictly additive: `computedOn` is read off git at the emit boundary, after every
derivation has finished. Verified byte-for-byte against the pre-change mapper on
three refs — the `computedOn` block is the entire diff. Self-test 220 -> 233.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-steve@claude