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
182 changes: 182 additions & 0 deletions lib/rules/sha_bump_propagation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.Rules.ShaBumpPropagation do
@moduledoc """
Detection: an estate-wide reusable workflow has been bumped to a new SHA
upstream and consumers pinning the old SHA need propagation.

Detection-half of the three-system propagation architecture:

hypatia (this module) -> gitbot-fleet (actuation) -> .git-private-farm (propagation)

Strategy is `:review` (flag-only). The finding payload feeds the actuator,
which pre-filters by title keyword (per `feedback_pr_sweep_title_keyword_exclusion`)
before triggering the propagation primitive.

See hypatia#418 for full spec.
"""

@rule :reusable_workflow_sha_bump_needs_propagation
@severity :medium
@strategy :review

@typedoc """
A pull-request merge event payload consumed by `check/1`.

* `:source_repo` — `"hyperpolymath/<repo>"` (string)
* `:files_changed` — list of file paths changed in the PR (binary list)
* `:merge_sha` — 40-char hex commit SHA of the merge
* `:old_sha` — the SHA pinned prior to this merge (40-char hex)
* `:pr_title` — upstream PR title (kept verbatim in the finding)
* `:pr_number` — upstream PR number (integer)
* `:estimated_consumers` — optional, integer count if known upstream

Keys are atoms; binary keys are also accepted (`normalise/1`).
"""
@type event :: %{
required(:source_repo) => binary(),
required(:files_changed) => [binary()],
required(:merge_sha) => binary(),
required(:old_sha) => binary(),
required(:pr_title) => binary(),
required(:pr_number) => non_neg_integer(),
optional(:estimated_consumers) => non_neg_integer() | nil
}

@typedoc "A finding map emitted when an event matches."
@type finding :: %{
rule: atom(),
severity: atom(),
strategy: atom(),
source_repo: binary(),
source_workflow: binary(),
old_sha: binary(),
new_sha: binary(),
pr_title: binary(),
pr_number: non_neg_integer(),
estimated_consumers: non_neg_integer() | nil
}

# 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"}
]

# 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.
@known_actions [
{"hyperpolymath/a2ml-validate-action", "action.yml"}
]

@doc "List of (repo, workflow_path) tuples treated as estate reusables."
def known_reusables, do: @known_reusables

@doc "List of (repo, action_path) tuples treated as estate composite actions."
def known_actions, do: @known_actions

@doc """
Inspect a merge event and return `[finding]` if a reusable workflow / action
changed, or `[]` otherwise.

Sensitivity: every change touching a known reusable's YAML fires.
Specificity: docs-only changes (where no `.yml`/`.yaml` in the reusable
registry was modified) do NOT fire; only the matching workflow paths are
emitted (one finding per touched reusable).

The title-keyword exclusion is NOT enforced here — actuation owns that
filter. The `pr_title` field is carried verbatim so the actuator can
reject without round-tripping back to hypatia.
"""
@spec check(event() | map()) :: [finding()]
def check(event) when is_map(event) do
case normalise(event) do
{:ok, ev} ->
ev
|> matched_reusables()
|> Enum.map(&build_finding(&1, ev))

{:error, _reason} ->
[]
end
end

# --- internals --------------------------------------------------------------

@doc false
def normalise(event) do
repo = fetch(event, :source_repo)
files = fetch(event, :files_changed) || []
merge_sha = fetch(event, :merge_sha)
old_sha = fetch(event, :old_sha)
title = fetch(event, :pr_title) || ""
number = fetch(event, :pr_number)
consumers = fetch(event, :estimated_consumers)

with true <- is_binary(repo),
true <- is_list(files),
true <- sha?(merge_sha),
true <- sha?(old_sha),
true <- is_integer(number) do
{:ok,
%{
source_repo: repo,
files_changed: files,
merge_sha: merge_sha,
old_sha: old_sha,
pr_title: title,
pr_number: number,
estimated_consumers: consumers
}}
else
_ -> {:error, :malformed_event}
end
end

defp fetch(map, key) when is_atom(key) do
Map.get(map, key) || Map.get(map, Atom.to_string(key))
end

defp sha?(s) when is_binary(s), do: Regex.match?(~r/^[0-9a-f]{40}$/, s)
defp sha?(_), do: false

# Return the workflow paths in the event that match a known reusable for the
# source repo. Empty list = no finding.
defp matched_reusables(%{source_repo: repo, files_changed: files}) do
paths_for_repo =
(@known_reusables ++ @known_actions)
|> Enum.filter(fn {r, _path} -> r == repo end)
|> Enum.map(fn {_r, path} -> path end)
|> MapSet.new()

files
|> Enum.filter(&MapSet.member?(paths_for_repo, &1))
|> Enum.uniq()
end

defp build_finding(workflow_path, ev) do
%{
rule: @rule,
severity: @severity,
strategy: @strategy,
source_repo: ev.source_repo,
source_workflow: workflow_path,
old_sha: ev.old_sha,
new_sha: ev.merge_sha,
pr_title: ev.pr_title,
pr_number: ev.pr_number,
estimated_consumers: ev.estimated_consumers
}
end
end
141 changes: 141 additions & 0 deletions test/rules/sha_bump_propagation_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule Hypatia.Rules.ShaBumpPropagationTest do
use ExUnit.Case, async: true

alias Hypatia.Rules.ShaBumpPropagation

# hypatia#418 — detection rule shape: fires on merged PRs touching a known
# estate reusable workflow (or composite action). Emits one finding per
# matched workflow path so the actuator can fan out per-pin.

@merge_sha "abcdef0123456789abcdef0123456789abcdef01"
@old_sha "0011223344556677889900112233445566778899"

defp event(overrides) do
Map.merge(
%{
source_repo: "hyperpolymath/standards",
files_changed: [".github/workflows/governance-reusable.yml"],
merge_sha: @merge_sha,
old_sha: @old_sha,
pr_title: "ci(governance): tighten codeql pin set",
pr_number: 999,
estimated_consumers: 228
},
overrides
)
end

describe "check/1 — sensitivity" do
test "fires on a known reusable workflow change" do
[finding] = ShaBumpPropagation.check(event(%{}))

assert finding.rule == :reusable_workflow_sha_bump_needs_propagation
assert finding.severity == :medium
assert finding.strategy == :review
assert finding.source_repo == "hyperpolymath/standards"
assert finding.source_workflow == ".github/workflows/governance-reusable.yml"
assert finding.old_sha == @old_sha
assert finding.new_sha == @merge_sha
assert finding.pr_number == 999
assert finding.estimated_consumers == 228
end

test "carries pr_title verbatim so the actuator can keyword-filter" do
title = "fix: bump license header"
[finding] = ShaBumpPropagation.check(event(%{pr_title: title}))

# hypatia emits — actuation rejects. Title MUST reach the actuator unchanged.
assert finding.pr_title == title
end

test "fires per matched workflow when a PR touches multiple reusables" do
ev =
event(%{
files_changed: [
".github/workflows/governance-reusable.yml",
".github/workflows/rust-ci-reusable.yml",
"docs/changelog.md"
]
})

findings = ShaBumpPropagation.check(ev)
paths = findings |> Enum.map(& &1.source_workflow) |> Enum.sort()

assert paths == [
".github/workflows/governance-reusable.yml",
".github/workflows/rust-ci-reusable.yml"
]
end

test "fires on known composite-action repos via action.yml" do
ev =
event(%{
source_repo: "hyperpolymath/a2ml-validate-action",
files_changed: ["action.yml", "README.md"]
})

[finding] = ShaBumpPropagation.check(ev)
assert finding.source_repo == "hyperpolymath/a2ml-validate-action"
assert finding.source_workflow == "action.yml"
end
end

describe "check/1 — specificity" do
test "docs-only PR on a reusable repo does NOT fire" do
ev = event(%{files_changed: ["README.md", "docs/upgrade.adoc"]})
assert ShaBumpPropagation.check(ev) == []
end

test "non-registered workflow on a reusable-repo does NOT fire" do
ev = event(%{files_changed: [".github/workflows/internal-ci.yml"]})
assert ShaBumpPropagation.check(ev) == []
end

test "non-reusable-repo PR does NOT fire even if workflow path matches" do
ev =
event(%{
source_repo: "hyperpolymath/random-app",
files_changed: [".github/workflows/governance-reusable.yml"]
})

assert ShaBumpPropagation.check(ev) == []
end

test "malformed SHA returns []" do
ev = event(%{merge_sha: "not-a-sha"})
assert ShaBumpPropagation.check(ev) == []
end

test "missing required field returns []" do
ev = event(%{}) |> Map.delete(:merge_sha)
assert ShaBumpPropagation.check(ev) == []
end
end

describe "check/1 — input shapes" do
test "accepts string-keyed event payloads (webhook-style)" do
ev = %{
"source_repo" => "hyperpolymath/standards",
"files_changed" => [".github/workflows/governance-reusable.yml"],
"merge_sha" => @merge_sha,
"old_sha" => @old_sha,
"pr_title" => "x",
"pr_number" => 1
}

assert [_] = ShaBumpPropagation.check(ev)
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.ends_with?(path, ".yml") or String.ends_with?(path, ".yaml")
end
end
end
end
Loading