Summary
A reusable-workflow pin can be a real commit object that GitHub still refuses to run. Four such SHAs in this estate account for 61 startup-dead workflow rows with zero alive rows, and the estate's own phantom-pin detector (scripts/check-action-pins-resolve.sh) passes every one of them — because it tests the wrong predicate.
reachable != consumable. The missing half is ancestry, not object existence.
Mechanism
GitHub's reusable-workflow fetch resolves only commits reachable from a ref. A pinned commit that is an ancestor of nothing fails at graph-resolution time with:
error parsing called workflow ... secret-scanner-reusable.yml@7fdc2705...: workflow was not found
Two consequences that make this hard to see:
- The run reports
jobs.total_count == 0 and, in 130 of 224 measured dead rows, conclusion: "failure" rather than startup_failure. Where the context is required, the gate never reports at all.
- No liveness probe can detect it.
commits/<sha> returns 200, and the file is present at that ref, so a contents/<path>?ref=<sha> probe also passes. GitHub never reaches the reusable's contents, so nothing about the file matters.
Measured population
Joined across all 31 distinct pin SHAs in an estate-wide census of 435 repos x 4 workflows (1,740 rows):
ancestor of main? |
SHAs |
ALIVE rows |
DEAD rows |
| NO |
5b1d0022 (not an object at all), 7fdc2705, 892497fe, 46960521 |
0 |
61 |
| YES |
the other 27 |
1,100 |
124 |
Zero alive rows on the non-ancestor side. Within the 33 repos holding at least one such row, those rows are 61/61 dead while the other rows in the same repos are 8/56 (14.3%) — a within-repo rate contrast, so this is not a per-repo effect.
Sufficient, not necessary: it explains 61 of 185 dead standards-pin rows (33%) and says nothing about the other 124.
Two provenances, one fatal property
The repair differs, so these should not be told as one story:
| SHA |
How it became a non-ancestor |
7fdc2705 |
Squash-merge orphan. PR #596 (feat/gitleaks-estate-baseline-sharing), merged 2026-08-07, merge commit 86bad549. The pin captured the PR head, which the squash discarded. |
892497fe |
Deleted unmerged branch. commits/<sha>/pulls returns 0 PRs; its parent fa1453db is also a non-ancestor. Never merged by any route. |
46960521 |
Reachable from a live remote branch (origin/audit/language-safety-20260907) but not an ancestor of main. 3/3 dead. |
5b1d0022 |
Not an object at all — prefix corruption sharing a 26-char suffix with real commit d135b05b. |
Defect 1 — the detector's predicate
scripts/check-action-pins-resolve.sh:100:
api "repos/$repo/commits/$sha"
case "$HTTP" in
200)
: # resolves — good
A non-ancestor orphan returns 200 here, so it is classified good with a comment asserting it. Both 7fdc2705 and 892497fe pass.
Fix — test ancestry, which is one API call and needs no clone:
api "repos/$repo/compare/main...$sha" # or the repo's default branch
case "$(jq -r .status <<<"$BODY")" in
behind|identical) : ;; # ancestor => consumable
ahead|diverged) bad=$((bad+1)) ;; # NOT an ancestor => fatal pin
esac
Measured on this repo: compare/main...7fdc2705 = diverged, compare/main...892497fe = diverged, compare/main...81dbf2dd (healthy) = behind. All three return 200 from commits/<sha>.
Two alternatives that do not work, both measured:
git for-each-ref --contains <sha> — passes 46960521, which is on a remote branch and still 3/3 dead.
git merge-base --is-ancestor <sha> origin/main — correct in a full clone, but in a shallow clone every SHA reads as absent and the check reports the entire estate broken. Do not use the local form in CI.
Defect 2 — placement (already known, restated for completeness)
This script runs inside governance-reusable.yml (.github/workflows/governance-reusable.yml:1244-1246), whose callers are pinned at the very SHAs it would catch. When the pin is fatal, the workflow never starts, so the check cannot run. The fuse blows before it can trip — adding a pin assertion to a reusable workflow is inert by construction. The predicate fix above is necessary but not sufficient while the detector lives there.
Defect 3 — the generator
Fixing 61 rows does not stop the 62nd. A pin-bump tool must assert ancestry of the default branch before writing a SHA. Pinning a PR head is correct at the moment of writing and becomes fatal the instant that PR squash-merges — which is the normal merge method here (allowed_merge_methods: ["squash"]).
Concretely, a bumper should resolve to the merge commit on the default branch, never the PR head, and refuse to write a SHA whose compare/<default>...<sha> is not behind/identical.
Suggested disposition
- Fix the predicate in
check-action-pins-resolve.sh (self-contained, no behaviour change for healthy pins).
- Add the ancestry assertion to whatever writes pins, so the class cannot recur.
- Re-point the 61 rows — owner-ordered, not proposed here. This population overlaps the R-2-EXTENDED pin-repair campaign, and the 2026-09-09 ruling is that the owner says which session acts. No repin, ruleset change or PR is proposed in this issue.
Provenance note
The mechanism was already recorded in this estate on 2026-09-02, naming 7fdc2705 and quoting the banner above; the census that measured the 61 rows on 2026-09-09 nonetheless filed the cause as "measured but not diagnosed" for twelve days. A finding filed under a symptom is invisible to a search framed by an identifier. The durable rule adopted: before writing "undiagnosed" about anything carrying an identifier, grep for that identifier first.
One correction to a previously recorded lead, so it is not chased again: the job-level permissions: block in scorecard-reusable.yml at 7fdc2705 was recorded as a candidate cause. It cannot be — GitHub never reaches job parsing when the call graph does not resolve. That lead is withdrawn; the permissions:-replacement class remains real for its own separate 14 rows.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VG5AnnA12E8GikbXZS7NbD
Summary
A reusable-workflow pin can be a real commit object that GitHub still refuses to run. Four such SHAs in this estate account for 61 startup-dead workflow rows with zero alive rows, and the estate's own phantom-pin detector (
scripts/check-action-pins-resolve.sh) passes every one of them — because it tests the wrong predicate.reachable != consumable. The missing half is ancestry, not object existence.Mechanism
GitHub's reusable-workflow fetch resolves only commits reachable from a ref. A pinned commit that is an ancestor of nothing fails at graph-resolution time with:
Two consequences that make this hard to see:
jobs.total_count == 0and, in 130 of 224 measured dead rows,conclusion: "failure"rather thanstartup_failure. Where the context is required, the gate never reports at all.commits/<sha>returns 200, and the file is present at that ref, so acontents/<path>?ref=<sha>probe also passes. GitHub never reaches the reusable's contents, so nothing about the file matters.Measured population
Joined across all 31 distinct pin SHAs in an estate-wide census of 435 repos x 4 workflows (1,740 rows):
main?5b1d0022(not an object at all),7fdc2705,892497fe,46960521Zero alive rows on the non-ancestor side. Within the 33 repos holding at least one such row, those rows are 61/61 dead while the other rows in the same repos are 8/56 (14.3%) — a within-repo rate contrast, so this is not a per-repo effect.
Sufficient, not necessary: it explains 61 of 185 dead standards-pin rows (33%) and says nothing about the other 124.
Two provenances, one fatal property
The repair differs, so these should not be told as one story:
7fdc2705feat/gitleaks-estate-baseline-sharing), merged 2026-08-07, merge commit86bad549. The pin captured the PR head, which the squash discarded.892497fecommits/<sha>/pullsreturns 0 PRs; its parentfa1453dbis also a non-ancestor. Never merged by any route.46960521origin/audit/language-safety-20260907) but not an ancestor ofmain. 3/3 dead.5b1d0022d135b05b.Defect 1 — the detector's predicate
scripts/check-action-pins-resolve.sh:100:A non-ancestor orphan returns 200 here, so it is classified
goodwith a comment asserting it. Both7fdc2705and892497fepass.Fix — test ancestry, which is one API call and needs no clone:
Measured on this repo:
compare/main...7fdc2705=diverged,compare/main...892497fe=diverged,compare/main...81dbf2dd(healthy) =behind. All three return 200 fromcommits/<sha>.Two alternatives that do not work, both measured:
git for-each-ref --contains <sha>— passes46960521, which is on a remote branch and still 3/3 dead.git merge-base --is-ancestor <sha> origin/main— correct in a full clone, but in a shallow clone every SHA reads as absent and the check reports the entire estate broken. Do not use the local form in CI.Defect 2 — placement (already known, restated for completeness)
This script runs inside
governance-reusable.yml(.github/workflows/governance-reusable.yml:1244-1246), whose callers are pinned at the very SHAs it would catch. When the pin is fatal, the workflow never starts, so the check cannot run. The fuse blows before it can trip — adding a pin assertion to a reusable workflow is inert by construction. The predicate fix above is necessary but not sufficient while the detector lives there.Defect 3 — the generator
Fixing 61 rows does not stop the 62nd. A pin-bump tool must assert ancestry of the default branch before writing a SHA. Pinning a PR head is correct at the moment of writing and becomes fatal the instant that PR squash-merges — which is the normal merge method here (
allowed_merge_methods: ["squash"]).Concretely, a bumper should resolve to the merge commit on the default branch, never the PR head, and refuse to write a SHA whose
compare/<default>...<sha>is notbehind/identical.Suggested disposition
check-action-pins-resolve.sh(self-contained, no behaviour change for healthy pins).Provenance note
The mechanism was already recorded in this estate on 2026-09-02, naming
7fdc2705and quoting the banner above; the census that measured the 61 rows on 2026-09-09 nonetheless filed the cause as "measured but not diagnosed" for twelve days. A finding filed under a symptom is invisible to a search framed by an identifier. The durable rule adopted: before writing "undiagnosed" about anything carrying an identifier, grep for that identifier first.One correction to a previously recorded lead, so it is not chased again: the job-level
permissions:block inscorecard-reusable.ymlat7fdc2705was recorded as a candidate cause. It cannot be — GitHub never reaches job parsing when the call graph does not resolve. That lead is withdrawn; thepermissions:-replacement class remains real for its own separate 14 rows.🤖 Generated with Claude Code
https://claude.ai/code/session_01VG5AnnA12E8GikbXZS7NbD