diff --git a/.github/workflows/inbox-steward-intake.yml b/.github/workflows/inbox-steward-intake.yml new file mode 100644 index 00000000..28411717 --- /dev/null +++ b/.github/workflows/inbox-steward-intake.yml @@ -0,0 +1,388 @@ +# SPDX-License-Identifier: MPL-2.0 +# Inbox Steward Intake — Process reports from gitbot-fleet inbox-steward +# +# This workflow: +# 1. Receives inbox-steward reports from gitbot-fleet +# 2. Analyzes patterns from auto-merged PRs +# 3. Updates Hypatia ruleset based on lessons learned +# 4. Triggers re-scanning of repos that had PRs merged +# 5. Dispatches updated rules to .git-private-farm for fleet-wide application +# +# Part of the closed-loop automation: +# gitbot-fleet/inbox-steward → hypatia/inbox-steward-intake → .git-private-farm/propagate +# +# Closes the loop: PRs that pass CICD get auto-merged, patterns are learned, +# and rules are updated to prevent similar issues in other repos. + +name: Inbox Steward Intake + +on: + repository_dispatch: + types: + - inbox-steward-report + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run mode (no actual updates)' + required: false + default: 'false' + type: boolean + +permissions: + contents: write + pull-requests: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + # Job 1: Record the steward report + record-report: + name: Record steward report + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + report_path: ${{ steps.record.outputs.report_path }} + repo: ${{ steps.record.outputs.repo }} + pr_count: ${{ steps.record.outputs.pr_count }} + validated_count: ${{ steps.record.outputs.validated_count }} + merged_count: ${{ steps.record.outputs.merged_count }} + + steps: + - name: Checkout hypatia + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.FARM_PAT }} + + - name: Record report + id: record + env: + EVENT_JSON: ${{ toJson(github.event) }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + # Parse event + SOURCE_REPO=$(echo "$EVENT_JSON" | jq -r '.client_payload.source_repo // "unknown"') + PR_COUNT=$(echo "$EVENT_JSON" | jq -r '.client_payload.total_prs // 0') + VALIDATED=$(echo "$EVENT_JSON" | jq -r '.client_payload.validated_prs // 0') + MERGED=$(echo "$EVENT_JSON" | jq -r '.client_payload.auto_merged_prs // 0') + TIMESTAMP=$(echo "$EVENT_JSON" | jq -r '.client_payload.timestamp // ""') + RUN_URL=$(echo "$EVENT_JSON" | jq -r '.client_payload.run_url // ""') + + echo "Report from: $SOURCE_REPO" + echo "PRs checked: $PR_COUNT, Validated: $VALIDATED, Merged: $MERGED" + + # Create report file + REPORTS_DIR="data/inbox-steward-reports" + mkdir -p "$REPORTS_DIR" + + FILENAME="$(date -u +%Y%m%d-%H%M%S)-${SOURCE_REPO//\//-}-report.json" + REPORT_PATH="$REPORTS_DIR/$FILENAME" + + echo "$EVENT_JSON" | jq '{ + timestamp: .client_payload.timestamp // now, + source_repo: .client_payload.source_repo, + source: .client_payload.source // "gitbot-fleet", + total_prs: .client_payload.total_prs, + validated_prs: .client_payload.validated_prs, + auto_merged_prs: .client_payload.auto_merged_prs, + run_url: .client_payload.run_url, + received_at: now + }' > "$REPORT_PATH" + + echo "report_path=$REPORT_PATH" >> "$GITHUB_OUTPUT" + echo "repo=$SOURCE_REPO" >> "$GITHUB_OUTPUT" + echo "pr_count=$PR_COUNT" >> "$GITHUB_OUTPUT" + echo "validated_count=$VALIDATED" >> "$GITHUB_OUTPUT" + echo "merged_count=$MERGED" >> "$GITHUB_OUTPUT" + + # Commit the report + if [ "$DRY_RUN" = "false" ]; then + git config user.name "Inbox Steward Intake" + git config user.email "inbox-steward-intake@reposystem.dev" + git add "$REPORT_PATH" + git commit -m "inbox-steward: record from $SOURCE_REPO ($PR_COUNT PRs, $MERGED merged)" 2>/dev/null || true + git push origin HEAD 2>/dev/null || echo "::warning::Could not push report" + else + echo "::notice::Dry run - not committing report" + fi + + # Job 2: Analyze patterns from merged PRs + analyze-patterns: + name: Analyze patterns from merged PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: record-report + if: needs.record-report.outputs.merged_count > 0 + outputs: + patterns: ${{ steps.analyze.outputs.patterns }} + rule_updates: ${{ steps.analyze.outputs.rule_updates }} + + steps: + - name: Checkout hypatia + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.FARM_PAT }} + + - name: Analyze merged PR patterns + id: analyze + env: + REPO: ${{ needs.record-report.outputs.repo }} + GH_TOKEN: ${{ secrets.FARM_PAT }} + run: | + set -euo pipefail + + # Get recent merged PRs from the source repo + MERGED_PRs=$(gh api \ + "repos/$REPO/pulls?state=closed&sort=updated&direction=desc&per_page=50" \ + --jq '[.[] | select(.merged_at != null) | { + number, + title, + merged_at, + author: .user.login, + additions: .additions, + deletions: .deletions, + changed_files: .changed_files, + files: [.files[] | {filename, changes, additions, deletions, blob_url}] + }] | .[:20]') # Last 20 merged PRs + + echo "Analyzing $(echo "$MERGED_PRs" | jq length) recently merged PRs" + + # Identify common patterns + # Pattern 1: Common file types changed + FILE_TYPES=$(echo "$MERGED_PRs" | jq '[.[].files[].filename] | flatten | map(split("-") | .[0]) | group_by(.[] // "unknown") | map({type: .[0][0] // "unknown", count: length}) | sort_by(-.count)') + + # Pattern 2: Common workflow files changed + WORKFLOW_CHANGES=$(echo "$MERGED_PRs" | jq '[.[].files[] | select(.filename | contains(".github/workflows")) | .filename] | flatten | unique | length') + + # Pattern 3: Common issues fixed (from commit messages) + COMMON_FIXES=$(echo "$MERGED_PRs" | jq '[.[].title] | flatten | map(split(" ") | .[0] | ascii_downcase) | group_by(.) | map({fix: .[0], count: length}) | sort_by(-.count) | .[:5]') + + # Pattern 4: Average PR size + AVG_ADDITIONS=$(echo "$MERGED_PRs" | jq '[.[].additions] | add / length' || echo "0") + AVG_DELETIONS=$(echo "$MERGED_PRs" | jq '[.[].deletions] | add / length' || echo "0") + + echo "Common file types: $FILE_TYPES" + echo "Workflow changes: $WORKFLOW_CHANGES" + echo "Common fixes: $COMMON_FIXES" + + # Generate rule update suggestions based on patterns + RULE_UPDATES="[]" + + # If many workflow changes, suggest tighter workflow validation + if [ "$WORKFLOW_CHANGES" -gt 10 ]; then + RULE_UPDATES=$(echo "$RULE_UPDATES" | jq '. + [{ + type: "workflow_hygiene", + action: "enhance", + reason: "High volume of workflow changes detected", + suggestion: "Add stricter workflow validation rules", + priority: "medium" + }]') + fi + + # If common fixes are dependency updates, suggest automation + DEP_UPDATES=$(echo "$COMMON_FIXES" | jq '[.[] | select(.fix | contains("bump") or contains("update") or contains("upgrade"))] | length') + if [ "$DEP_UPDATES" -gt 3 ]; then + RULE_UPDATES=$(echo "$RULE_UPDATES" | jq '. + [{ + type: "dependency_automation", + action: "enable", + reason: "Frequent dependency updates detected", + suggestion: "Enable Dependabot automation for this repo pattern", + priority: "high" + }]') + fi + + # If large PRs, suggest breaking them down + AVG_SIZE=$(echo "$AVG_ADDITIONS" | jq -r '. // 0' | awk '{printf "%.0f", $1}') + if [ "$AVG_SIZE" -gt 500 ]; then + RULE_UPDATES=$(echo "$RULE_UPDATES" | jq '. + [{ + type: "pr_size_limit", + action: "warn", + reason: "Average PR size is $AVG_SIZE lines", + suggestion: "Warn on PRs larger than 1000 lines", + priority: "low" + }]') + fi + + echo "patterns={\"file_types\": $FILE_TYPES, \"workflow_changes\": $WORKFLOW_CHANGES, \"common_fixes\": $COMMON_FIXES, \"avg_additions\": $AVG_ADDITIONS, \"avg_deletions\": $AVG_DELETIONS}" >> "$GITHUB_OUTPUT" + echo "rule_updates=$RULE_UPDATES" >> "$GITHUB_OUTPUT" + + echo "::notice::Identified $(echo "$RULE_UPDATES" | jq length) potential rule updates" + + # Job 3: Update Hypatia ruleset + update-ruleset: + name: Update Hypatia ruleset + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [record-report, analyze-patterns] + if: needs.analyze-patterns.result == 'success' && inputs.dry_run != 'true' + + steps: + - name: Checkout hypatia + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.FARM_PAT }} + + - name: Update ruleset + id: update + env: + RULE_UPDATES: ${{ needs.analyze-patterns.outputs.rule_updates }} + run: | + set -euo pipefail + + # Load existing baseline + BASELINE_PATH=".hypatia-baseline.json" + + # Create rule update file + UPDATES_DIR="data/ruleset-updates" + mkdir -p "$UPDATES_DIR" + + TIMESTAMP=$(date -u +%Y%m%d-%H%M%S) + UPDATE_FILE="$UPDATES_DIR/${TIMESTAMP}-inbox-steward-updates.json" + + echo "$RULE_UPDATES" | jq '{ + timestamp: now, + source: "inbox-steward", + updates: ., + applied: false + }' > "$UPDATE_FILE" + + echo "Created rule updates: $UPDATE_FILE" + + # Auto-apply critical/high priority updates + CRITICAL_UPDATES=$(echo "$RULE_UPDATES" | jq '[.[] | select(.priority == "critical" or .priority == "high")]') + + if [ "$(echo "$CRITICAL_UPDATES" | jq length)" -gt 0 ]; then + echo "Applying critical/high priority updates..." + + for UPDATE in $(echo "$CRITICAL_UPDATES" | jq -c '.[]'); do + UPDATE_TYPE=$(echo "$UPDATE" | jq -r '.type') + ACTION=$(echo "$UPDATE" | jq -r '.action') + REASON=$(echo "$UPDATE" | jq -r '.reason') + SUGGESTION=$(echo "$UPDATE" | jq -r '.suggestion') + + echo " - $UPDATE_TYPE: $ACTION ($REASON)" + + # Apply the update to baseline + # For now, append as a comment/todo - full automation needs more logic + echo " :: TODO: Apply rule update: $SUGGESTION" >> "$BASELINE_PATH" + done + + # Commit updates + git config user.name "Inbox Steward Intake" + git config user.email "inbox-steward-intake@reposystem.dev" + git add "$UPDATE_FILE" "$BASELINE_PATH" + git commit -m "inbox-steward: apply critical rule updates from steward reports" 2>/dev/null || true + git push origin HEAD 2>/dev/null || echo "::warning::Could not push updates" + else + echo "No critical/high priority updates to apply" + git config user.name "Inbox Steward Intake" + git config user.email "inbox-steward-intake@reposystem.dev" + git add "$UPDATE_FILE" + git commit -m "inbox-steward: record rule update suggestions" 2>/dev/null || true + git push origin HEAD 2>/dev/null || echo "::warning::Could not push updates" + fi + + # Job 4: Dispatch to .git-private-farm for fleet-wide application + dispatch-to-farm: + name: Dispatch to .git-private-farm + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [record-report, analyze-patterns, update-ruleset] + if: always() && needs.record-report.result == 'success' + + steps: + - name: Checkout hypatia + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Send dispatch to .git-private-farm + env: + GH_TOKEN: ${{ secrets.FARM_PAT }} + REPORT_PATH: ${{ needs.record-report.outputs.report_path }} + PATTERNS: ${{ needs.analyze-patterns.outputs.patterns }} + RULE_UPDATES: ${{ needs.analyze-patterns.outputs.rule_updates }} + run: | + set -euo pipefail + + # Build payload + PAYLOAD=$(jq -nc \ + --arg repo "${{ github.repository }}" \ + --arg report_path "$REPORT_PATH" \ + --argjson patterns "$PATTERNS" \ + --argjson rule_updates "$RULE_UPDATES" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + "event_type": "inbox-steward-propagate", + "client_payload": { + "source_repo": $repo, + "source": "hypatia", + "report_path": $report_path, + "patterns": $patterns, + "rule_updates": $rule_updates, + "timestamp": $timestamp, + "action": "apply_ruleset_updates" + } + }') + + echo "Dispatching to .git-private-farm: $(echo "$PAYLOAD" | jq -c '.')" + + # Send to dot-git-private-farm + if gh api -X POST \ + "/repos/hyperpolymath/dot-git-private-farm/dispatches" \ + --input - <<<"$PAYLOAD" >/dev/null 2>&1; then + echo "✅ Successfully dispatched to .git-private-farm" + else + echo "::warning::Failed to dispatch to .git-private-farm" + fi + + # Job 5: Summary + summary: + name: Generate intake summary + runs-on: ubuntu-latest + needs: [record-report, analyze-patterns, update-ruleset, dispatch-to-farm] + if: always() + + steps: + - name: Generate summary + run: | + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + # Inbox Steward Intake Summary + + ## Report Received + + | Metric | Value | + |--------|-------| + | Source Repo | ${{ needs.record-report.outputs.repo }} | + | PRs Checked | ${{ needs.record-report.outputs.pr_count }} | + | Validated | ${{ needs.record-report.outputs.validated_count }} | + | Auto-Merged | ${{ needs.record-report.outputs.merged_count }} | + + ## Analysis Results + + ${{ needs.analyze-patterns.result == 'success' && echo '✅ Pattern analysis completed' || echo '❌ Pattern analysis failed' }} + + | Analysis | Status | + |----------|--------| + | Common file types | Identified | + | Workflow changes | ${{ needs.analyze-patterns.outputs.patterns != '{}' && echo $(echo '${{ needs.analyze-patterns.outputs.patterns }}' | jq -r '.file_types // "N/A" | head -c 50) || echo 'N/A' }} | + | Rule updates suggested | ${{ needs.analyze-patterns.outputs.rule_updates != '[]' && echo $(echo '${{ needs.analyze-patterns.outputs.rule_updates }}' | jq length) || echo '0' }} | + + ## Actions Taken + + 1. ✅ Recorded steward report + ${{ needs.analyze-patterns.result == 'success' && echo '2. ✅ Analyzed patterns from merged PRs' || echo '2. ❌ Pattern analysis failed' }} + ${{ needs.update-ruleset.result == 'success' && echo '3. ✅ Updated Hypatia ruleset' || echo '3. ❌ Ruleset update failed' }} + ${{ needs.dispatch-to-farm.result == 'success' && echo '4. ✅ Dispatched to .git-private-farm' || echo '4. ❌ Dispatch to farm failed' }} + + ## Rule Updates Applied + + ${{ needs.update-ruleset.result == 'success' && needs.analyze-patterns.outputs.rule_updates != '[]' && echo $(echo '${{ needs.analyze-patterns.outputs.rule_updates }}' | jq -r '.[] | "- [\(.priority | ascii_upcase)] \(.type): \(.suggestion)" | join("\n")') || echo 'None' }} + + --- + *Generated by Inbox Steward Intake workflow* + EOF diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json index 3ef7458f..adbed50d 100644 --- a/.hypatia-baseline.json +++ b/.hypatia-baseline.json @@ -243,5 +243,78 @@ "type": "SD009", "file": "ffi/zig/src/main.zig", "action": "add_spdx_header" + }, + { + "severity": "low", + "rule_module": "workflow_audit", + "type": "inbox_steward_missing", + "file": ".github/workflows/inbox-steward.yml", + "action": "create", + "reason": "Inbox steward automation for PR processing" + }, + { + "severity": "low", + "rule_module": "workflow_audit", + "type": "inbox_steward_intake_missing", + "file": ".github/workflows/inbox-steward-intake.yml", + "action": "create", + "reason": "Hypatia intake for inbox steward reports" + }, + { + "severity": "medium", + "rule_module": "inbox_automation", + "type": "IA001", + "file": ".", + "action": "enable_auto_merge", + "reason": "Enable auto-merge for trusted contributors and Dependabot PRs that pass all checks" + }, + { + "severity": "medium", + "rule_module": "inbox_automation", + "type": "IA002", + "file": ".", + "action": "require_dogfood_gate", + "reason": "All PRs must pass Dogfood Gate before auto-merge" + }, + { + "severity": "medium", + "rule_module": "inbox_automation", + "type": "IA003", + "file": ".", + "action": "require_scorecard", + "reason": "All PRs must pass Scorecard Enforcer before auto-merge" + }, + { + "severity": "high", + "rule_module": "inbox_automation", + "type": "IA004", + "file": ".", + "action": "require_hypatia_scan", + "reason": "All PRs must pass Hypatia Security Scan before auto-merge" + }, + { + "severity": "low", + "rule_module": "inbox_automation", + "type": "IA005", + "file": ".", + "action": "trusted_contributors", + "reason": "Define trusted contributors whose PRs can be auto-merged without approval", + "trusted": ["hyperpolymath", "dependabot", "renovate"] + }, + { + "severity": "medium", + "rule_module": "inbox_automation", + "type": "IA006", + "file": ".", + "action": "propagate_rules", + "reason": "Propagate learned rules from auto-merged PRs to other repos via .git-private-farm" + }, + { + "severity": "low", + "rule_module": "inbox_automation", + "type": "IA007", + "file": ".", + "action": "monitor_application", + "reason": "Monitor that inbox automation applies to repos not currently in inbox" } ] diff --git a/lib/rules/sha_bump_propagation.ex b/lib/rules/sha_bump_propagation.ex new file mode 100644 index 00000000..a71fd169 --- /dev/null +++ b/lib/rules/sha_bump_propagation.ex @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +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/"` (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: /@`) 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 diff --git a/test/rules/sha_bump_propagation_test.exs b/test/rules/sha_bump_propagation_test.exs new file mode 100644 index 00000000..13506113 --- /dev/null +++ b/test/rules/sha_bump_propagation_test.exs @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +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