From 095cbde492172aa0fa95083e2a9f4a37df7703de Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:47:30 +0100 Subject: [PATCH 1/4] fix(ci): detect unreachable reusable workflow pins --- lib/rules/baseline_health.ex | 127 +++++++++++++++++++++++++++++----- test/baseline_health_test.exs | 33 +++++++++ 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index af3e0687..1e0c4dfe 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -27,8 +27,10 @@ defmodule Hypatia.Rules.BaselineHealth do flake. - **BH004** — A workflow `uses:` line references an action by full SHA - that does not exist on the upstream repository. Every workflow run - using the pin fails immediately on action resolution. Discovered + that does not exist on the upstream repository, or references a reusable + workflow at a commit that exists but is not reachable from the upstream + default branch. Every workflow run using the pin fails immediately on + action/workflow resolution. Discovered 2026-05-26 in `hyperpolymath/rsr-template-repo` and 9 other estate repos — a single dead SHA pin (`actions/upload-artifact@65c79d7f…`) was propagated by template scaffolding and broke main on each. @@ -104,7 +106,12 @@ defmodule Hypatia.Rules.BaselineHealth do # `uses: /@<40-hex-sha>` (with or without a trailing # comment) for every action reference. Composite-action callouts # (`uses: ./...`) and docker-image refs (`docker://...`) are skipped. - @uses_sha_pattern ~r/^\s*-?\s*uses:\s*([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-fA-F0-9]{40})\b/m + # Capture the repository, optional in-repository path, and SHA separately. + # The previous form required `@` immediately after owner/repo, so it silently + # ignored every cross-repository reusable workflow (`owner/repo/.github/ + # workflows/x.yml@sha`). That blind spot is how an extant but non-mainline + # standards commit reached 251 active workflow files without BH004 noticing. + @uses_sha_pattern ~r/^\s*-?\s*uses:\s*([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(\/[a-zA-Z0-9_.\/-]+)?@([a-fA-F0-9]{40})\b/m # BH005/BH006 — distinguish workflows that ran on the latest PR vs # only on the main-branch push. A required check whose name appears @@ -284,8 +291,12 @@ defmodule Hypatia.Rules.BaselineHealth do # ─── BH004: Dead action SHA pin in workflow YAML ────────────────────── @doc """ - BH004: For each `uses: /@` reference in workflow YAML, - verify the SHA resolves to a real commit on the upstream action repo. + BH004: For each `uses: /[/path]@` reference in workflow + YAML, verify the SHA resolves to a real commit on the upstream repository. + For cross-repository reusable workflows, also verify that commit is reachable + from the repository's default branch. GitHub's contents API can retrieve an + orphaned/diverged commit, but the Actions workflow resolver rejects it as + `workflow was not found` before creating any jobs. Discovery 2026-05-26: a single dead pin (`actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478`) @@ -310,20 +321,48 @@ defmodule Hypatia.Rules.BaselineHealth do content = File.read!(path) rel = Path.relative_to(path, repo_path) - Regex.scan(@uses_sha_pattern, content, return: :index) - |> Enum.flat_map(fn [{full_start, _}, {repo_start, repo_len}, {sha_start, sha_len}] -> - # byte offsets from return: :index — String.slice counts graphemes, - # so any earlier multi-byte char shifted these and sent garbage - # owner/repo + sha pairs to the GitHub API (false BH004 criticals). - action_repo = binary_part(content, repo_start, repo_len) - sha = binary_part(content, sha_start, sha_len) |> String.downcase() - line_no = line_number_for_offset(content, full_start) - check_action_sha_alive(action_repo, sha, rel, line_no) + content + |> uses_sha_references() + |> Enum.flat_map(fn ref -> + check_action_sha_alive(ref.repository, ref.path, ref.sha, rel, ref.line) end) end) end - defp check_action_sha_alive(action_repo, sha, file, line_no) do + @doc false + def uses_sha_references(content) when is_binary(content) do + Regex.scan(@uses_sha_pattern, content, return: :index) + |> Enum.map(fn [ + {full_start, _}, + {repo_start, repo_len}, + {path_start, path_len}, + {sha_start, sha_len} + ] -> + # byte offsets from return: :index — String.slice counts graphemes, + # so any earlier multi-byte char shifted these and sent garbage + # owner/repo + sha pairs to the GitHub API (false BH004 criticals). + path = + if path_start < 0, + do: "", + else: binary_part(content, path_start, path_len) |> String.trim_leading("/") + + %{ + repository: binary_part(content, repo_start, repo_len), + path: path, + sha: binary_part(content, sha_start, sha_len) |> String.downcase(), + line: line_number_for_offset(content, full_start) + } + end) + end + + @doc false + def reusable_reachability("ahead"), do: :reachable + def reusable_reachability("identical"), do: :reachable + def reusable_reachability("behind"), do: :unreachable + def reusable_reachability("diverged"), do: :unreachable + def reusable_reachability(_), do: :unknown + + defp check_action_sha_alive(action_repo, upstream_path, sha, file, line_no) do case curl_github("repos/#{action_repo}/commits/#{sha}") do {:ok, %{"message" => "No commit found for SHA: " <> _}} -> [ @@ -338,6 +377,7 @@ defmodule Hypatia.Rules.BaselineHealth do detail: %{ line: line_no, action_repo: action_repo, + upstream_path: upstream_path, dead_sha: sha, fix: "Bump to the current tag head: " <> @@ -347,8 +387,14 @@ defmodule Hypatia.Rules.BaselineHealth do ] {:ok, %{"sha" => _real_sha}} -> - # SHA resolves cleanly — no finding. - [] + if reusable_workflow_path?(upstream_path) do + check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) + else + # Ordinary actions may intentionally live on a release/tag branch; + # existence is sufficient for those. Default-branch reachability is + # a GitHub constraint specifically for cross-repo reusable workflows. + [] + end {:error, :no_token} -> # We can't verify without a token. Don't emit — false positives @@ -361,6 +407,53 @@ defmodule Hypatia.Rules.BaselineHealth do end end + defp reusable_workflow_path?(path) do + String.starts_with?(path, ".github/workflows/") and + (String.ends_with?(path, ".yml") or String.ends_with?(path, ".yaml")) + end + + defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do + with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- + curl_github("repos/#{action_repo}"), + {:ok, %{"status" => status}} <- + curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do + case reusable_reachability(status) do + :reachable -> + [] + + :unreachable -> + [ + %{ + rule: "BH004", + file: file, + severity: :critical, + reason: + "workflow #{file}:#{line_no} pins #{action_repo}/#{upstream_path}@#{String.slice(sha, 0, 8)}…; " <> + "the commit exists but is not reachable from `#{branch}`, so GitHub rejects the reusable workflow as `workflow was not found`", + action: :open_followup_pr, + detail: %{ + line: line_no, + action_repo: action_repo, + upstream_path: upstream_path, + unreachable_sha: sha, + default_branch: branch, + compare_status: status, + fix: + "Pin a commit reachable from `#{branch}`: " <> + "`gh api repos/#{action_repo}/commits/#{branch} --jq .sha`" + } + } + ] + + :unknown -> + [] + end + else + # Network/permission ambiguity is not evidence that a pin is broken. + _ -> [] + end + end + # ─── BH005: Push-only required check ────────────────────────────────── @doc """ diff --git a/test/baseline_health_test.exs b/test/baseline_health_test.exs index c842db4a..e8d547a6 100644 --- a/test/baseline_health_test.exs +++ b/test/baseline_health_test.exs @@ -176,6 +176,39 @@ defmodule Hypatia.Rules.BaselineHealthTest do # ─── BH004: dead action SHA pin ───────────────────────────────────── describe "bh004_dead_action_sha_pin/1" do + test "parses both ordinary actions and reusable workflows with exact line numbers" do + action_sha = "ea165f8d65b6e75b540449e92b4886f43607fa02" + workflow_sha = "7fdc2705df74b4e352d2a1cde3e87a5923fdf329" + + refs = + BaselineHealth.uses_sha_references(""" + jobs: + ordinary: + steps: + - uses: actions/checkout@#{action_sha} + reusable: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@#{workflow_sha} + """) + + assert refs == [ + %{repository: "actions/checkout", path: "", sha: action_sha, line: 4}, + %{ + repository: "hyperpolymath/standards", + path: ".github/workflows/hypatia-scan-reusable.yml", + sha: workflow_sha, + line: 6 + } + ] + end + + test "default-branch compare polarity matches GitHub reusable resolution" do + assert BaselineHealth.reusable_reachability("identical") == :reachable + assert BaselineHealth.reusable_reachability("ahead") == :reachable + assert BaselineHealth.reusable_reachability("diverged") == :unreachable + assert BaselineHealth.reusable_reachability("behind") == :unreachable + assert BaselineHealth.reusable_reachability("unexpected") == :unknown + end + test "returns [] when no workflow files exist", %{repo: repo} do # No .github/workflows/ directory at all. assert BaselineHealth.bh004_dead_action_sha_pin(repo) == [] From ae5d8f4da1eb274730d9a86b33bae52af165a4ce Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:21:01 +0000 Subject: [PATCH 2/4] Fix CodeRabbit issues in PR #755 --- lib/rules/baseline_health.ex | 12 +++++++++--- test/baseline_health_test.exs | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 1e0c4dfe..904e2370 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -356,6 +356,12 @@ defmodule Hypatia.Rules.BaselineHealth do end @doc false + def reusable_reachability(%{"status" => status}) when is_binary(status), + do: reusable_reachability(status) + + def reusable_reachability(%{"message" => "No common ancestor between " <> _}), + do: :unreachable + def reusable_reachability("ahead"), do: :reachable def reusable_reachability("identical"), do: :reachable def reusable_reachability("behind"), do: :unreachable @@ -415,9 +421,9 @@ defmodule Hypatia.Rules.BaselineHealth do defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- curl_github("repos/#{action_repo}"), - {:ok, %{"status" => status}} <- + {:ok, comparison} <- curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do - case reusable_reachability(status) do + case reusable_reachability(comparison) do :reachable -> [] @@ -437,7 +443,7 @@ defmodule Hypatia.Rules.BaselineHealth do upstream_path: upstream_path, unreachable_sha: sha, default_branch: branch, - compare_status: status, + compare_status: Map.get(comparison, "status") || Map.get(comparison, "message"), fix: "Pin a commit reachable from `#{branch}`: " <> "`gh api repos/#{action_repo}/commits/#{branch} --jq .sha`" diff --git a/test/baseline_health_test.exs b/test/baseline_health_test.exs index e8d547a6..25da8fb4 100644 --- a/test/baseline_health_test.exs +++ b/test/baseline_health_test.exs @@ -209,6 +209,12 @@ defmodule Hypatia.Rules.BaselineHealthTest do assert BaselineHealth.reusable_reachability("unexpected") == :unknown end + test "recognizes GitHub's no-common-ancestor compare response as unreachable" do + assert BaselineHealth.reusable_reachability(%{ + "message" => "No common ancestor between deadbeef and main." + }) == :unreachable + end + test "returns [] when no workflow files exist", %{repo: repo} do # No .github/workflows/ directory at all. assert BaselineHealth.bh004_dead_action_sha_pin(repo) == [] From 8dec961d8d33c2c2f4cfcd9cd5b03a27c52ce078 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:33:25 +0100 Subject: [PATCH 3/4] fix(bh004): guard malformed compare payloads --- lib/rules/baseline_health.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 904e2370..56a983be 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -421,7 +421,7 @@ defmodule Hypatia.Rules.BaselineHealth do defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- curl_github("repos/#{action_repo}"), - {:ok, comparison} <- + {:ok, comparison} when is_map(comparison) <- curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do case reusable_reachability(comparison) do :reachable -> @@ -443,7 +443,8 @@ defmodule Hypatia.Rules.BaselineHealth do upstream_path: upstream_path, unreachable_sha: sha, default_branch: branch, - compare_status: Map.get(comparison, "status") || Map.get(comparison, "message"), + compare_status: + Map.get(comparison, "status") || Map.get(comparison, "message", "unknown"), fix: "Pin a commit reachable from `#{branch}`: " <> "`gh api repos/#{action_repo}/commits/#{branch} --jq .sha`" From 4bba052d511d60e35542d5f49b9f9bf43cd298ae Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:56:11 +0100 Subject: [PATCH 4/4] fix(baseline): encode compare branch components --- lib/rules/baseline_health.ex | 32 ++++++++++++++++++++++++-------- test/baseline_health_test.exs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/lib/rules/baseline_health.ex b/lib/rules/baseline_health.ex index 56a983be..cd6c6f1a 100644 --- a/lib/rules/baseline_health.ex +++ b/lib/rules/baseline_health.ex @@ -313,7 +313,7 @@ defmodule Hypatia.Rules.BaselineHealth do Requires `GITHUB_TOKEN` to confirm the SHA does not exist upstream. Returns `[]` cleanly without one. """ - def bh004_dead_action_sha_pin(repo_path) do + def bh004_dead_action_sha_pin(repo_path, github_request \\ &curl_github/1) do workflow_files = find_workflow_files(repo_path) workflow_files @@ -324,7 +324,7 @@ defmodule Hypatia.Rules.BaselineHealth do content |> uses_sha_references() |> Enum.flat_map(fn ref -> - check_action_sha_alive(ref.repository, ref.path, ref.sha, rel, ref.line) + check_action_sha_alive(ref.repository, ref.path, ref.sha, rel, ref.line, github_request) end) end) end @@ -368,8 +368,8 @@ defmodule Hypatia.Rules.BaselineHealth do def reusable_reachability("diverged"), do: :unreachable def reusable_reachability(_), do: :unknown - defp check_action_sha_alive(action_repo, upstream_path, sha, file, line_no) do - case curl_github("repos/#{action_repo}/commits/#{sha}") do + defp check_action_sha_alive(action_repo, upstream_path, sha, file, line_no, github_request) do + case github_request.("repos/#{action_repo}/commits/#{sha}") do {:ok, %{"message" => "No commit found for SHA: " <> _}} -> [ %{ @@ -394,7 +394,14 @@ defmodule Hypatia.Rules.BaselineHealth do {:ok, %{"sha" => _real_sha}} -> if reusable_workflow_path?(upstream_path) do - check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) + check_reusable_sha_reachable( + action_repo, + upstream_path, + sha, + file, + line_no, + github_request + ) else # Ordinary actions may intentionally live on a release/tag branch; # existence is sufficient for those. Default-branch reachability is @@ -418,11 +425,20 @@ defmodule Hypatia.Rules.BaselineHealth do (String.ends_with?(path, ".yml") or String.ends_with?(path, ".yaml")) end - defp check_reusable_sha_reachable(action_repo, upstream_path, sha, file, line_no) do + defp check_reusable_sha_reachable( + action_repo, + upstream_path, + sha, + file, + line_no, + github_request + ) do with {:ok, %{"default_branch" => branch}} when is_binary(branch) <- - curl_github("repos/#{action_repo}"), + github_request.("repos/#{action_repo}"), {:ok, comparison} when is_map(comparison) <- - curl_github("repos/#{action_repo}/compare/#{sha}...#{branch}") do + github_request.( + "repos/#{action_repo}/compare/#{sha}...#{URI.encode(branch, &URI.char_unreserved?/1)}" + ) do case reusable_reachability(comparison) do :reachable -> [] diff --git a/test/baseline_health_test.exs b/test/baseline_health_test.exs index 25da8fb4..a1061e10 100644 --- a/test/baseline_health_test.exs +++ b/test/baseline_health_test.exs @@ -215,6 +215,37 @@ defmodule Hypatia.Rules.BaselineHealthTest do }) == :unreachable end + test "encodes a slash-containing default branch and emits BH004", %{repo: repo} do + sha = "7fdc2705df74b4e352d2a1cde3e87a5923fdf329" + workflow_dir = Path.join([repo, ".github", "workflows"]) + File.mkdir_p!(workflow_dir) + + File.write!(Path.join(workflow_dir, "hypatia.yml"), """ + jobs: + scan: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@#{sha} + """) + + github_request = fn + "repos/hyperpolymath/standards/commits/" <> ^sha -> + {:ok, %{"sha" => sha}} + + "repos/hyperpolymath/standards" -> + {:ok, %{"default_branch" => "release/next"}} + + "repos/hyperpolymath/standards/compare/" <> ^sha <> "...release%2Fnext" -> + {:ok, %{"status" => "diverged"}} + + _unexpected -> + {:error, :unexpected_request} + end + + assert [finding] = BaselineHealth.bh004_dead_action_sha_pin(repo, github_request) + assert finding.rule == "BH004" + assert finding.detail.default_branch == "release/next" + assert finding.detail.compare_status == "diverged" + end + test "returns [] when no workflow files exist", %{repo: repo} do # No .github/workflows/ directory at all. assert BaselineHealth.bh004_dead_action_sha_pin(repo) == []