Skip to content

Orphan pin class: a reusable pin can be a real commit GitHub still refuses to run — 61 startup-dead rows, 0 alive; check-action-pins-resolve.sh tests the wrong predicate #782

Description

@hyperpolymath

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

  1. Fix the predicate in check-action-pins-resolve.sh (self-contained, no behaviour change for healthy pins).
  2. Add the ancestry assertion to whatever writes pins, so the class cannot recur.
  3. 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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaves incorrectlycicdCI/CD: workflows, actions, lockfiles, pins, runners, release gatespriority:p1High - schedule nextscope:estateAffects many or all repos across the estatetestingTests, benchmarks, fuzzing, property checks, coverage

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions