Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/merge_orchestration/sensor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ defmodule Hypatia.MergeOrchestration.Sensor do
String.contains?(p, "lib/rules/") or
String.contains?(p, "merge-orchestration/schemas") or
String.contains?(p, "merge-orchestration/ci-templates") or
(repo == "hyperpolymath/standards" and
(Hypatia.Rules.StandardsRepo.name?(repo) and
(String.contains?(p, "contractiles/") or String.contains?(p, "rulesets/")))
end

Expand Down
15 changes: 9 additions & 6 deletions lib/rules/security_errors.ex
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,12 @@ defmodule Hypatia.Rules.SecurityErrors do
# rationale to dismiss the Scorecard alert. Keyed by action-name prefix so
# every version and the `/.github/workflows/...` path form is covered.
# Refs hyperpolymath/hypatia#262.
@own_org_wrapper_reason "Own-org reusable workflow referenced at @main is the canonical " <>
"wrapper pattern (project_admin_merge_wrappers_2026_05_26). " <>
"SHA-pinning each wrapper would force a fan-out SHA-bump PR " <>
"per consumer repo per reusable change — defeating the wrapper's " <>
"whole purpose. Trust boundary: own-org commit rights."

@pin_exempt %{
"slsa-framework/slsa-github-generator" =>
"SLSA generator self-verifies its own github.ref to mint verifiable " <>
Expand All @@ -352,12 +358,9 @@ defmodule Hypatia.Rules.SecurityErrors do
# dependency you have commit rights on. Observed flagging 2026-05-27
# across affinescript#357 / panll#54 / claude-integrations#43 /
# idaptik#98 / tma-mark2#41 / maa-framework#78 (6/6 PRs flagged).
"hyperpolymath/standards/.github/workflows/" =>
"Own-org reusable workflow referenced at @main is the canonical " <>
"wrapper pattern (project_admin_merge_wrappers_2026_05_26). " <>
"SHA-pinning each wrapper would force a fan-out SHA-bump PR " <>
"per consumer repo per reusable change — defeating the wrapper's " <>
"whole purpose. Trust boundary: own-org commit rights."
"hyperpolymath/standards/.github/workflows/" => @own_org_wrapper_reason,
# The same repository under its current name (Hypatia.Rules.StandardsRepo).
"metadatastician/standards/.github/workflows/" => @own_org_wrapper_reason
}

def pin_exempt, do: @pin_exempt
Expand Down
47 changes: 29 additions & 18 deletions lib/rules/sha_bump_propagation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,37 @@ defmodule Hypatia.Rules.ShaBumpPropagation do
# Estate reusable-workflow registry. Sourced from hypatia#418 issue body.
# Each entry: `{repo, workflow_path}` — matches against `event.source_repo`
# and any file in `event.files_changed`.
@known_reusables [
{"hyperpolymath/standards", ".github/workflows/governance-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/rust-ci-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/deno-ci-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/elixir-ci-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/scorecard-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/secret-scanner-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/codeql-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/hypatia-scan-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/mirror-reusable.yml"},
{"hyperpolymath/standards", ".github/workflows/changelog-reusable.yml"},
# hyperpolymath/cicd-suite (cicd-suite#54): the estate audit and the four
# language gates, consumed as thin callers at a full cicd-suite SHA.
{"hyperpolymath/cicd-suite", ".github/workflows/main-estate-audit.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/rust-ci.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/creusot-gate.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/spark-gate.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/kanren-gate.yml"}
@standards_reusables [
".github/workflows/governance-reusable.yml",
".github/workflows/rust-ci-reusable.yml",
".github/workflows/deno-ci-reusable.yml",
".github/workflows/elixir-ci-reusable.yml",
".github/workflows/scorecard-reusable.yml",
".github/workflows/secret-scanner-reusable.yml",
".github/workflows/codeql-reusable.yml",
".github/workflows/hypatia-scan-reusable.yml",
".github/workflows/mirror-reusable.yml",
".github/workflows/changelog-reusable.yml"
]

# The standards reusables under every name of the repository: merge events
# from it now carry `metadatastician/standards`, and the estate still holds
# pins under the former name (Hypatia.Rules.StandardsRepo).
@known_reusables for(
repo <- Hypatia.Rules.StandardsRepo.names(),
path <- @standards_reusables,
do: {repo, path}
) ++
[
# hyperpolymath/cicd-suite (cicd-suite#54): the estate audit and the four
# language gates, consumed as thin callers at a full cicd-suite SHA.
{"hyperpolymath/cicd-suite", ".github/workflows/main-estate-audit.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/rust-ci.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/creusot-gate.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/spark-gate.yml"},
{"hyperpolymath/cicd-suite", ".github/workflows/kanren-gate.yml"}
]

# Composite actions (referenced via `uses: <owner>/<repo>@<SHA>`) also flow
# through this rule — the workflow_path is the conventional `action.yml`
# at the action repo root.
Expand Down
53 changes: 53 additions & 0 deletions lib/rules/standards_repo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.Rules.StandardsRepo do
@moduledoc """
The names of the estate standards repository.

`hyperpolymath/standards` moved to `metadatastician/standards` on
2026-10-25. GitHub redirects git and the REST API for the old name, but not
a reusable workflow's `uses:`: a caller still naming the old repository ends
in `startup_failure` ("workflow was not found"). Callers, lock records and
merge events under the old name stay in the estate until each repository is
re-pointed, so every rule that keys on the standards repository accepts
both names. The current name comes first.
"""

@current "metadatastician/standards"
@former ["hyperpolymath/standards"]

@doc "The repository's current name."
def current, do: @current

@doc "Every name the repository is known by, current first."
def names, do: [@current | @former]

@doc "True when `repo` (`owner/name`, any case) is the standards repository."
def name?(repo) when is_binary(repo) do
downcased = String.downcase(repo)
Enum.any?(names(), &(&1 == downcased))
end

def name?(_), do: false

@doc """
Regex source for `path` (`owner/repo/rest`) that matches it under any name
of the standards repository, the `owner/repo` part case-insensitively (as
GitHub resolves it). A path outside the standards repository is escaped
as it stands.
"""
def path_regex_source(path) when is_binary(path) do
case String.split(path, "/", parts: 3) do
[owner, repo, rest] ->
if name?(owner <> "/" <> repo) do
alternation = Enum.map_join(names(), "|", &Regex.escape/1)
"(?i:(?:#{alternation}))/#{Regex.escape(rest)}"
else
Regex.escape(path)
end

_ ->
Regex.escape(path)
end
end
end
45 changes: 24 additions & 21 deletions lib/rules/workflow_audit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,10 @@ defmodule Hypatia.Rules.WorkflowAudit do
[_, name] = Regex.run(~r/^ ([A-Za-z0-9_-]+):/, line)
{MapSet.put(seen, name), with_to, reusable, in_j, name}

(in_j and curj) && String.match?(line, ~r/^ timeout-minutes:\s*\d+/) ->
in_j and curj && String.match?(line, ~r/^ timeout-minutes:\s*\d+/) ->
{seen, MapSet.put(with_to, curj), reusable, in_j, curj}

(in_j and curj) && String.match?(line, ~r/^ uses:\s*\S/) ->
in_j and curj && String.match?(line, ~r/^ uses:\s*\S/) ->
{seen, with_to, MapSet.put(reusable, curj), in_j, curj}

String.match?(line, ~r/^[A-Za-z]/) ->
Expand Down Expand Up @@ -877,11 +877,9 @@ defmodule Hypatia.Rules.WorkflowAudit do
Enum.flat_map(workflow_contents, fn {filename, content} ->
stripped = strip_comments(content)

uses_scorecard? =
Regex.match?(~r/uses:\s*ossf\/scorecard-action@/, stripped)
uses_scorecard? = Regex.match?(~r/uses:\s*ossf\/scorecard-action@/, stripped)

publish_true? =
Regex.match?(~r/publish_results:\s*true/, stripped)
publish_true? = Regex.match?(~r/publish_results:\s*true/, stripped)

# Only emit if the job that runs `ossf/scorecard-action` with `publish_results: true`
# itself contains a `run:` step. We need to check per-job, not per-file.
Expand Down Expand Up @@ -981,8 +979,7 @@ defmodule Hypatia.Rules.WorkflowAudit do

uses_checkout? = Regex.match?(~r/uses:\s*actions\/checkout@/, stripped)

has_user_root? =
Regex.match?(~r/options:\s*[^\n]*--user\s+(?:root|0)\b/, stripped)
has_user_root? = Regex.match?(~r/options:\s*[^\n]*--user\s+(?:root|0)\b/, stripped)

if uses_checkout? and not has_user_root? do
Enum.flat_map(@nonroot_container_images, fn img ->
Expand Down Expand Up @@ -1079,7 +1076,9 @@ defmodule Hypatia.Rules.WorkflowAudit do

Enum.flat_map(@orphan_reusable_shas, fn {reusable_path, orphan_shas} ->
# Match: `uses: owner/repo/.github/workflows/x-reusable.yml@SHA`
ref_re = ~r/uses:\s*#{Regex.escape(reusable_path)}@([0-9a-f]{8,40})/
# Under either name of the standards repository (StandardsRepo).
ref_re =
~r/uses:\s*#{Hypatia.Rules.StandardsRepo.path_regex_source(reusable_path)}@([0-9a-f]{8,40})/

Regex.scan(ref_re, stripped)
|> Enum.flat_map(fn [_full, sha] ->
Expand Down Expand Up @@ -2907,8 +2906,17 @@ defmodule Hypatia.Rules.WorkflowAudit do
# case-insensitively and rewrites it as the caller spells it.
defp wf027_reusable_re(reusable) do
case String.split(reusable, "/", parts: 3) do
[owner, repo, path] -> "(?i:#{Regex.escape(owner <> "/" <> repo)})/#{Regex.escape(path)}"
_ -> Regex.escape(reusable)
[owner, repo, path] ->
if Hypatia.Rules.StandardsRepo.name?(owner <> "/" <> repo) do
# A standards reusable is the same call under either name of the
# repository (Hypatia.Rules.StandardsRepo).
Hypatia.Rules.StandardsRepo.path_regex_source(reusable)
else
"(?i:#{Regex.escape(owner <> "/" <> repo)})/#{Regex.escape(path)}"
end

_ ->
Regex.escape(reusable)
end
end

Expand Down Expand Up @@ -3291,11 +3299,9 @@ defmodule Hypatia.Rules.WorkflowAudit do
[]

true ->
uses_workflow_sha? =
Regex.match?(~r/\$\{\{\s*github\.workflow_sha\s*\}\}/, ref_value)
uses_workflow_sha? = Regex.match?(~r/\$\{\{\s*github\.workflow_sha\s*\}\}/, ref_value)

caller_repo? =
Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)
caller_repo? = Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)

if uses_workflow_sha? and not caller_repo? do
[
Expand Down Expand Up @@ -3350,8 +3356,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
variable.
"""
def check_reusable_caller_context_self_checkout(workflow_contents) do
caller_ref_re =
~r/\$\{\{\s*github\.(?:ref|head_ref|sha|workflow_sha)\s*\}\}/
caller_ref_re = ~r/\$\{\{\s*github\.(?:ref|head_ref|sha|workflow_sha)\s*\}\}/

Enum.flat_map(workflow_contents, fn {filename, content} ->
stripped = strip_comments(content)
Expand All @@ -3369,8 +3374,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
[]

true ->
caller_repo_var? =
Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)
caller_repo_var? = Regex.match?(~r/\$\{\{\s*github\.repository\s*\}\}/, repo_value)

foreign_literal_self? =
not caller_repo_var? and
Expand Down Expand Up @@ -4203,8 +4207,7 @@ defmodule Hypatia.Rules.WorkflowAudit do
Enum.flat_map(workflow_contents, fn {filename, content} ->
stripped = strip_comments(content)

chapel? =
Enum.any?(@chapel_marker_patterns, &Regex.match?(&1, stripped))
chapel? = Enum.any?(@chapel_marker_patterns, &Regex.match?(&1, stripped))

if chapel? do
runs_on_vals =
Expand Down
13 changes: 13 additions & 0 deletions scripts/ci-health/action-superset.txt
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml
hyperpolymath/standards/.github/workflows/spark-theatre-gate.yml
jlumbroso/free-disk-space
leanprover/lean-action
metadatastician/standards/.github/workflows/changelog-reusable.yml
metadatastician/standards/.github/workflows/codeql-reusable.yml
metadatastician/standards/.github/workflows/deno-ci-reusable.yml
metadatastician/standards/.github/workflows/elixir-ci-reusable.yml
metadatastician/standards/.github/workflows/governance-reusable.yml
metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml
metadatastician/standards/.github/workflows/mirror-reusable.yml
metadatastician/standards/.github/workflows/readme-derive-reusable.yml
metadatastician/standards/.github/workflows/rsr-antipattern-reusable.yml
metadatastician/standards/.github/workflows/rust-ci-reusable.yml
metadatastician/standards/.github/workflows/scorecard-reusable.yml
metadatastician/standards/.github/workflows/secret-scanner-reusable.yml
metadatastician/standards/.github/workflows/spark-theatre-gate.yml
mozilla-actions/sccache-action
oven-sh/setup-bun
peter-evans/dockerhub-description
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci-health/remediate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ read -r isfork isarch <<<"$meta"
case "$CLASS" in
B-ALLOWLIST)
# Build body: hyperpolymath/* + each superset action as owner/repo@*
body=$(jq -R -s -c 'split("\n") | map(select(length > 0) | . + "@*") | ["hyperpolymath/*"] + . as $pats | {"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":$pats}' "$HERE/action-superset.txt")
body=$(jq -R -s -c 'split("\n") | map(select(length > 0) | . + "@*") | (["hyperpolymath/*"] + .) as $pats | {"github_owned_allowed":true,"verified_allowed":true,"patterns_allowed":$pats}' "$HERE/action-superset.txt")
if [ "$DRY" = "true" ]; then echo "DRYRUN $R/B-ALLOWLIST would PUT $(printf '%s' "$body" | jq '.patterns_allowed | length') patterns"; exit 0; fi
printf '%s' "$body" | gh api -X PUT "repos/$O/$R/actions/permissions/selected-actions" --input - >/dev/null
expected=$(printf '%s' "$body" | jq -c '.patterns_allowed | sort')
Expand Down
6 changes: 6 additions & 0 deletions test/merge_orchestration/sensor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ defmodule Hypatia.MergeOrchestration.SensorTest do
})
end

test "standards contractiles stay meta under the repository's current name" do
for repo <- ["metadatastician/standards", "Metadatastician/Standards"] do
assert {_, :meta, _} = Sensor.classify(%{"repo" => repo, "files" => ["rulesets/main.json"]})
end
end

test "ordinary library code is object-level" do
assert {_, :object, _} = Sensor.classify(obs(%{"files" => ["lib/x.ex", "lib/y.ex"]}))
end
Expand Down
22 changes: 21 additions & 1 deletion test/rules/sha_bump_propagation_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,31 @@ defmodule Hypatia.Rules.ShaBumpPropagationTest do
end
end

describe "check/1 — the standards repository under its current name" do
test "fires on a merge event from metadatastician/standards" do
[finding] = ShaBumpPropagation.check(event(%{source_repo: "metadatastician/standards"}))

assert finding.source_repo == "metadatastician/standards"
assert finding.source_workflow == ".github/workflows/governance-reusable.yml"
end

test "every standards reusable is registered under both names" do
registered = ShaBumpPropagation.known_reusables()

for {"hyperpolymath/standards", path} <- registered do
assert {"metadatastician/standards", path} in registered
end
end
end

describe "known_reusables/0 — registry hygiene" do
test "registry entries are well-formed" do
for {repo, path} <- ShaBumpPropagation.known_reusables() do
assert is_binary(repo)
assert String.starts_with?(repo, "hyperpolymath/")

assert String.starts_with?(repo, "hyperpolymath/") or
String.starts_with?(repo, "metadatastician/")

assert String.ends_with?(path, ".yml") or String.ends_with?(path, ".yaml")
end
end
Expand Down
43 changes: 43 additions & 0 deletions test/rules/standards_repo_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.Rules.StandardsRepoTest do
use ExUnit.Case, async: true

alias Hypatia.Rules.StandardsRepo

test "the current name comes first, the former name stays known" do
assert StandardsRepo.current() == "metadatastician/standards"
assert StandardsRepo.names() == ["metadatastician/standards", "hyperpolymath/standards"]
end

test "name?/1 accepts either name in any case, and nothing else" do
assert StandardsRepo.name?("metadatastician/standards")
assert StandardsRepo.name?("HyperPolymath/Standards")
refute StandardsRepo.name?("hyperpolymath/standards-mirror")
refute StandardsRepo.name?("hyperpolymath/hypatia")
refute StandardsRepo.name?(nil)
end

test "path_regex_source/1 matches a standards path under either name" do
re =
Regex.compile!(
"^" <>
StandardsRepo.path_regex_source(
"hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml"
) <> "$"
)

assert Regex.match?(re, "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml")
assert Regex.match?(re, "metadatastician/standards/.github/workflows/rust-ci-reusable.yml")
assert Regex.match?(re, "Metadatastician/Standards/.github/workflows/rust-ci-reusable.yml")
refute Regex.match?(re, "metadatastician/standards/.github/workflows/rust-ciXreusable.yml")
refute Regex.match?(re, "someone/standards/.github/workflows/rust-ci-reusable.yml")
end

test "path_regex_source/1 escapes any other path as it stands" do
assert StandardsRepo.path_regex_source(
"hyperpolymath/cicd-suite/.github/workflows/rust-ci.yml"
) ==
Regex.escape("hyperpolymath/cicd-suite/.github/workflows/rust-ci.yml")
end
end
Loading
Loading