Skip to content

Commit 4d59821

Browse files
feat(workflow-audit): rules for caller-SHA-as-foreign-checkout-ref anti-pattern (#348)
## Summary Two specific, sensitive checks in \`Hypatia.Rules.WorkflowAudit\`: | Rule | When it fires | Severity | |---|---|---| | \`:workflow_sha_as_foreign_ref\` | \`actions/checkout\` uses \`ref: \${{ github.workflow_sha }}\` against a \`repository:\` value that is not \`\${{ github.repository }}\` | :critical | | \`:reusable_caller_context_self_checkout\` | In a reusable workflow (\`on: workflow_call\`), \`actions/checkout\` of a literal foreign repo at \`ref:\` = any caller-context variable (\`github.ref\` / \`head_ref\` / \`sha\` / \`workflow_sha\`) | :critical | ## Why this rule pair \`github.workflow_sha\` (and \`github.ref\`, \`head_ref\`, \`sha\`) all resolve to the **caller repo's** value, never to the SHA of the reusable workflow itself. Passing them as \`ref:\` to \`actions/checkout\` of a *foreign* repository asks that repo for a SHA / branch that doesn't exist — \`git fetch\` exits with code 128, three retries, then the step (and downstream jobs) fail. This is the exact bug shipped in \`hyperpolymath/standards#219\`'s pre-fix \`governance-reusable.yml:155\`, which cascaded into governance failures across hundreds of stuck PRs estate-wide on 2026-05-26 — symptom \"governance / Language / package anti-pattern policy — Failing after 40s\" on every PR. ## Design notes ### Sensitivity (catches the bug) - Both rules use a YAML step-splitter (line-walker that respects indent) instead of a multi-line regex, so they handle both \`actions/checkout\` step shapes: - \`- uses: actions/checkout@…\` (uses-first) - \`- name: … / uses: actions/checkout@…\` (name-first) - Verified against the verbatim pre-fix \`governance-reusable.yml\` body — both rules fire. ### Specificity (low false-positive rate) - Rule 1 does **not** fire when \`repository: \${{ github.repository }}\` (the safe caller-self pattern shipped in many workflows). - Rule 2 does **not** fire on non-reusable workflows (no \`workflow_call:\`) or when \`ref:\` is pinned to a literal value (\`main\`, \`v1.2.0\`, or an explicit SHA). - Verified against the post-fix \`governance-reusable.yml\` (\`ref: main\`) — both rules stay quiet. ### Why two rules, not one - Rule 1 is the narrowest cut against the specific \`workflow_sha\` mistake — high signal, zero false positives expected. - Rule 2 is the broader anti-pattern: a reusable that puts ANY caller-context ref against a foreign \`repository:\`. Catches the cousin bugs (\`github.ref\` or \`github.sha\` used the same wrong way) that the standards PR fix did not need to address but Hypatia should still warn on. ## Test plan - [x] \`mix test test/workflow_audit_test.exs\` — 12 new test cases (4 + 5 + 3) covering positive firing, negative firing, and the two checkout step shapes. - [x] Standalone smoke (10 assertions) covering both pre-fix and post-fix \`governance-reusable.yml\` shapes — see PR body of standards#219 for source. - [ ] After merge: re-run Hypatia scan against \`hyperpolymath/standards\` post-PR-219 and confirm neither rule fires (it shouldn't — \`ref: main\` is the safe shape). - [ ] Estate sweep: run Hypatia against the rest of \`hyperpolymath/standards/.github/workflows/*-reusable.yml\` to confirm no other reusables carry the same anti-pattern. (I scanned manually and found only \`governance-reusable.yml\` — no false positives expected, but worth confirming through the production scanner path.) ## Related - \`hyperpolymath/standards#219\` — source-of-truth fix that unblocks the stuck-PR cascade. - The orthogonal CodeQL js-ts failure on \`.git-private-farm#24\` is a GitHub Actions billing/spending-limit block — not a Hypatia-detectable anti-pattern, just owner-action in Settings → Billing & plans. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d966e6e commit 4d59821

2 files changed

Lines changed: 398 additions & 1 deletion

File tree

lib/rules/workflow_audit.ex

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,15 @@ defmodule Hypatia.Rules.WorkflowAudit do
8989
download_then_run_issues = check_download_then_run(workflow_contents)
9090
nperm_typos = check_npermissions_typo(workflow_contents)
9191
codeql_lang_mismatch = check_codeql_language_matrix_mismatch(workflow_contents, opts)
92+
workflow_sha_foreign_ref = check_workflow_sha_as_foreign_ref(workflow_contents)
93+
reusable_caller_context_self_checkout = check_reusable_caller_context_self_checkout(workflow_contents)
9294

9395
%{
9496
findings:
9597
missing ++ unpinned ++ wrong_pins ++ permission_issues ++ duplicates ++
9698
caching_issues ++ run_context_issues ++ download_then_run_issues ++ nperm_typos ++
97-
codeql_lang_mismatch,
99+
codeql_lang_mismatch ++ workflow_sha_foreign_ref ++
100+
reusable_caller_context_self_checkout,
98101
missing_count: length(missing),
99102
unpinned_count: length(unpinned),
100103
wrong_pin_count: length(wrong_pins),
@@ -505,6 +508,233 @@ defmodule Hypatia.Rules.WorkflowAudit do
505508
end
506509
end
507510

511+
@doc """
512+
Detect `actions/checkout` steps that use `${{ github.workflow_sha }}`
513+
as `ref:` for a `repository:` that is NOT the caller's own repository.
514+
515+
Root cause caught: in any workflow (especially reusable), the
516+
`github.workflow_sha` context resolves to the *caller's* commit SHA,
517+
not the SHA of the workflow YAML itself. Passing it as `ref:` to
518+
`actions/checkout` of a foreign repository — e.g. checking out
519+
`hyperpolymath/standards` at the caller's SHA — fails with
520+
`git fetch ... exit code 128` ("No commit found for SHA"), and after
521+
three retries the step (and the whole job) fails.
522+
523+
This was the root cause of the estate-wide stuck-PR cascade on
524+
2026-05-26 (governance / Language / package anti-pattern policy
525+
failing on every PR after `governance-reusable.yml` was updated to
526+
fetch its own scripts dir via the foreign-checkout pattern with
527+
`ref: ${{ github.workflow_sha }}`). See `hyperpolymath/standards`
528+
PR fixing `governance-reusable.yml:155`.
529+
530+
Sensitivity / specificity:
531+
532+
* Specific — only fires when `repository:` is a literal owner/name
533+
OR `${{ inputs.* }}` (i.e. NOT `${{ github.repository }}`). The
534+
common safe pattern `repository: ${{ github.repository }}` +
535+
`ref: ${{ github.workflow_sha }}` is fine (or at least
536+
consistent — both refer to the caller).
537+
* Sensitive — catches the pattern regardless of which job/step it
538+
lives in. Operates on the parsed `with:` block keys so it does
539+
not depend on key ordering.
540+
"""
541+
def check_workflow_sha_as_foreign_ref(workflow_contents) do
542+
Enum.flat_map(workflow_contents, fn {filename, content} ->
543+
content
544+
|> strip_comments()
545+
|> extract_checkout_blocks()
546+
|> Enum.flat_map(fn block ->
547+
repo_value = block_value(block, ~r/^\s*repository:\s*([^\n]+?)\s*$/m)
548+
ref_value = block_value(block, ~r/^\s*ref:\s*([^\n]+?)\s*$/m)
549+
550+
cond do
551+
is_nil(repo_value) or is_nil(ref_value) ->
552+
[]
553+
554+
true ->
555+
uses_workflow_sha? =
556+
Regex.match?(~r/\$\{\{\s*github\.workflow_sha\s*\}\}/, ref_value)
557+
558+
caller_repo? =
559+
Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)
560+
561+
if uses_workflow_sha? and not caller_repo? do
562+
[%{
563+
type: :workflow_sha_as_foreign_ref,
564+
file: filename,
565+
detail:
566+
"actions/checkout uses `ref: ${{ github.workflow_sha }}` " <>
567+
"for `repository: #{repo_value}` (not the caller's own " <>
568+
"repo). `github.workflow_sha` resolves to the caller's " <>
569+
"commit SHA, which does not exist in `#{repo_value}` — " <>
570+
"the fetch fails with exit code 128. Pin `ref:` to a " <>
571+
"branch (e.g. `main`), tag, or explicit SHA in the " <>
572+
"target repo, or expose it as an input on the reusable.",
573+
severity: :critical,
574+
action: :pin_external_checkout_ref
575+
}]
576+
else
577+
[]
578+
end
579+
end
580+
end)
581+
end)
582+
end
583+
584+
@doc """
585+
Detect reusable workflows (`on: workflow_call`) that check out their
586+
*own* repository at a caller-derived ref (`github.ref`,
587+
`github.head_ref`, `github.sha`).
588+
589+
In a reusable-workflow context, all `github.*` ref variables resolve
590+
to the *caller*'s values, not the reusable repo's. A reusable that
591+
does:
592+
593+
uses: actions/checkout@…
594+
with:
595+
repository: hyperpolymath/standards # this repo
596+
ref: ${{ github.ref }} # caller's branch ref
597+
598+
…will try to fetch the caller's branch from the reusable's repo,
599+
which almost always fails (the caller's branch name doesn't exist
600+
here). The two safe shapes are:
601+
602+
1. `ref:` omitted (defaults to the reusable's default branch), or
603+
2. `ref:` pinned to a specific branch/tag/SHA of the reusable's
604+
repo, or
605+
3. an explicit `inputs.*_ref` threaded through from the caller.
606+
607+
This is the structural cousin of the `workflow_sha` bug — same
608+
failure mode (`exit code 128 — No commit found`), different context
609+
variable.
610+
"""
611+
def check_reusable_caller_context_self_checkout(workflow_contents) do
612+
caller_ref_re =
613+
~r/\$\{\{\s*github\.(?:ref|head_ref|sha|workflow_sha)\s*\}\}/
614+
615+
Enum.flat_map(workflow_contents, fn {filename, content} ->
616+
stripped = strip_comments(content)
617+
reusable? = Regex.match?(~r/^\s*workflow_call:/m, stripped)
618+
619+
if reusable? do
620+
stripped
621+
|> extract_checkout_blocks()
622+
|> Enum.flat_map(fn block ->
623+
repo_value = block_value(block, ~r/^\s*repository:\s*([^\n]+?)\s*$/m)
624+
ref_value = block_value(block, ~r/^\s*ref:\s*([^\n]+?)\s*$/m)
625+
626+
cond do
627+
is_nil(repo_value) or is_nil(ref_value) ->
628+
[]
629+
630+
true ->
631+
caller_repo_var? =
632+
Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)
633+
634+
foreign_literal_self? =
635+
not caller_repo_var? and
636+
String.contains?(repo_value, "/") and
637+
not String.starts_with?(repo_value, "${{")
638+
639+
caller_derived_ref? = Regex.match?(caller_ref_re, ref_value)
640+
641+
if foreign_literal_self? and caller_derived_ref? do
642+
[%{
643+
type: :reusable_caller_context_self_checkout,
644+
file: filename,
645+
detail:
646+
"Reusable workflow (`on: workflow_call`) checks out a " <>
647+
"literal foreign repo `#{repo_value}` at " <>
648+
"`ref: #{ref_value}` — a caller-context variable. In a " <>
649+
"reusable workflow, `github.ref` / `head_ref` / `sha` / " <>
650+
"`workflow_sha` resolve to the *caller's* values, which " <>
651+
"do not exist in `#{repo_value}`, so the fetch fails " <>
652+
"with exit code 128. Pin `ref:` to a literal branch/tag/SHA " <>
653+
"in `#{repo_value}`, omit it (defaults to the default " <>
654+
"branch), or thread an explicit input through from the caller.",
655+
severity: :critical,
656+
action: :pin_reusable_self_checkout_ref
657+
}]
658+
else
659+
[]
660+
end
661+
end
662+
end)
663+
else
664+
[]
665+
end
666+
end)
667+
end
668+
669+
# ─── Helpers for the two checkout-shape rules above ────────────────────
670+
#
671+
# `extract_checkout_blocks/1` splits the file into YAML steps (each
672+
# starting at a `- key:` line), then returns only the steps whose body
673+
# contains `uses: actions/checkout@…`. This handles both shapes a
674+
# checkout step can take:
675+
#
676+
# - uses: actions/checkout@SHA # uses-first
677+
# with: { … }
678+
#
679+
# - name: Check out X # name-first
680+
# uses: actions/checkout@SHA
681+
# with: { … }
682+
#
683+
# Unlike a single multi-line regex, it does not get tripped by interior
684+
# `with:` / `repository:` / `ref:` / `path:` sub-keys.
685+
defp extract_checkout_blocks(content) do
686+
content
687+
|> String.split("\n")
688+
|> Enum.reduce({[], nil}, fn line, {steps, current} ->
689+
cond do
690+
# A step starts at any line of shape ` - key:` (the `-` is the
691+
# list-item marker; whatever follows is the first key of the
692+
# step). We anchor on the `-` column so we know when the step
693+
# ends.
694+
matches = Regex.run(~r/^(\s*)-\s+/, line) ->
695+
indent = matches |> Enum.at(1) |> String.length()
696+
{flush_step(steps, current), {indent, [line]}}
697+
698+
is_tuple(current) ->
699+
{step_indent, lines_acc} = current
700+
701+
if String.trim(line) == "" or leading_indent(line) > step_indent do
702+
{steps, {step_indent, [line | lines_acc]}}
703+
else
704+
# Less- or equal-indented line that isn't a new `- key:`
705+
# closes the current step (and we don't start a new one).
706+
{flush_step(steps, current), nil}
707+
end
708+
709+
true ->
710+
{steps, current}
711+
end
712+
end)
713+
|> then(fn {steps, current} -> flush_step(steps, current) end)
714+
|> Enum.reverse()
715+
|> Enum.filter(&Regex.match?(~r/uses:\s*actions\/checkout@/, &1))
716+
end
717+
718+
defp flush_step(steps, nil), do: steps
719+
720+
defp flush_step(steps, {_indent, lines_acc}) do
721+
[lines_acc |> Enum.reverse() |> Enum.join("\n") | steps]
722+
end
723+
724+
defp leading_indent(line) do
725+
case Regex.run(~r/^(\s*)/, line) do
726+
[_, ws] -> String.length(ws)
727+
_ -> 0
728+
end
729+
end
730+
731+
defp block_value(block, regex) do
732+
case Regex.run(regex, block) do
733+
[_, value] -> String.trim(value)
734+
_ -> nil
735+
end
736+
end
737+
508738
@doc """
509739
Check for flawed regex patterns in workflow files.
510740
Detects common mistakes like unescaped dots, overly broad matches,

0 commit comments

Comments
 (0)