diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fe05010b3..e5b2047f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -100,6 +100,7 @@ jobs: tests/test_rate_limit_samples.bats \ tests/fleet_stub_drift.bats \ tests/fleet_stub_remediate.bats \ + tests/fleet_stub_registry.bats \ tests/fleet_remediate_gate.bats \ tests/template_stub_drift.bats \ tests/canary_rollout.bats \ diff --git a/scripts/fleet_monitor.sh b/scripts/fleet_monitor.sh index a6cfc90bf..d81e0c989 100755 --- a/scripts/fleet_monitor.sh +++ b/scripts/fleet_monitor.sh @@ -65,6 +65,24 @@ CANONICAL_STUB_REPO="${CANONICAL_STUB_REPO:-petry-projects/.github}" STUB_REGISTRY=( $'initiative-planner\tInitiative-planner\t.github/workflows/initiative-planner.yml\tstandards/workflows/initiative-planner.yml\t\t\t' $'initiative-driver\tInitiative-driver\t.github/workflows/initiative-driver.yml\tstandards/workflows/initiative-driver.yml\t\t\t' + # Collapsed agent-ingress.yml roles (#1729 AC11): one row per job in the + # petry-projects/markets ingress. These entries register for future drift + # coverage once the canonical standards/workflows/agent-ingress.yml is + # published to petry-projects/.github (#1729 AC11). Until then, a canonical + # 404 gracefully skips these rows with a warning, leaving them without + # coverage. The monitor and remediation helpers handle this degradation + # automatically (see the @test "fleet_monitor: a 404 canonical + # agent-ingress skips every role row with a warning and stays green" in + # tests/fleet_stub_registry.bats). + # legacy_canonical_path is empty for pr-review and ci-failure-analyst because + # petry-projects/.github publishes no standards/workflows/.yml for them + # (checked 2026-10-10), so a repo that has not collapsed classifies MISSING + # for those two roles. + $'dev-lead\tDev-lead\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tdev-lead\t.github/workflows/dev-lead.yml\tstandards/workflows/dev-lead.yml' + $'pr-auto-review\tPr-auto-review\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-auto-review\t.github/workflows/pr-auto-review.yml\tstandards/workflows/pr-auto-review.yml' + $'pr-review\tPr-review\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-review\t.github/workflows/pr-review.yml\t' + $'pr-review-mention\tPr-review-mention\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-review-mention\t.github/workflows/pr-review-mention.yml\tstandards/workflows/pr-review-mention.yml' + $'ci-failure-analyst\tCi-failure-analyst\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tci-failure-analyst\t.github/workflows/ci-failure-analyst.yml\t' ) # fetch_raw_content — emit a file's raw contents on stdout. Returns diff --git a/scripts/fleet_stub_drift.sh b/scripts/fleet_stub_drift.sh index 964a1def5..1ae964d25 100755 --- a/scripts/fleet_stub_drift.sh +++ b/scripts/fleet_stub_drift.sh @@ -130,11 +130,15 @@ patch_job_block() { # back to the legacy file, which would mask/resurrect a removed stub). # • otherwise (no ingress file — a legacy repo mid-rollout): classify on the # legacy whole-file SHAs, exactly as before (backward compatibility, AC #4). +# With no legacy canonical SHA (the role has no published per-role template) +# the legacy file cannot be compared, so it is MISSING, never DRIFTED (#1729). classify_role_drift() { local ingress_present="${1:-}" canon_block="${2:-}" ingress_block="${3:-}" local legacy_canon="${4:-}" legacy_repo="${5:-}" if [ "$ingress_present" = "yes" ]; then classify_stub_drift "$canon_block" "$ingress_block" + elif [ -z "$legacy_canon" ]; then + echo "MISSING" else classify_stub_drift "$legacy_canon" "$legacy_repo" fi diff --git a/scripts/fleet_stub_remediate.sh b/scripts/fleet_stub_remediate.sh index 7a0d01ace..6d5e147c6 100644 --- a/scripts/fleet_stub_remediate.sh +++ b/scripts/fleet_stub_remediate.sh @@ -68,6 +68,24 @@ CANONICAL_STUB_REPO="${CANONICAL_STUB_REPO:-petry-projects/.github}" STUB_REGISTRY=( $'initiative-planner\tInitiative-planner\t.github/workflows/initiative-planner.yml\tstandards/workflows/initiative-planner.yml\t\t\t' $'initiative-driver\tInitiative-driver\t.github/workflows/initiative-driver.yml\tstandards/workflows/initiative-driver.yml\t\t\t' + # Collapsed agent-ingress.yml roles (#1729 AC11): one row per job in the + # petry-projects/markets ingress. These entries register for future drift + # coverage once the canonical standards/workflows/agent-ingress.yml is + # published to petry-projects/.github (#1729 AC11). Until then, a canonical + # 404 gracefully skips these rows with a warning, leaving them without + # coverage. The monitor and remediation helpers handle this degradation + # automatically (see the @test "fleet_monitor: a 404 canonical + # agent-ingress skips every role row with a warning and stays green" in + # tests/fleet_stub_registry.bats). + # legacy_canonical_path is empty for pr-review and ci-failure-analyst because + # petry-projects/.github publishes no standards/workflows/.yml for them + # (checked 2026-10-10), so a repo that has not collapsed classifies MISSING + # for those two roles. + $'dev-lead\tDev-lead\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tdev-lead\t.github/workflows/dev-lead.yml\tstandards/workflows/dev-lead.yml' + $'pr-auto-review\tPr-auto-review\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-auto-review\t.github/workflows/pr-auto-review.yml\tstandards/workflows/pr-auto-review.yml' + $'pr-review\tPr-review\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-review\t.github/workflows/pr-review.yml\t' + $'pr-review-mention\tPr-review-mention\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tpr-review-mention\t.github/workflows/pr-review-mention.yml\tstandards/workflows/pr-review-mention.yml' + $'ci-failure-analyst\tCi-failure-analyst\t.github/workflows/agent-ingress.yml\tstandards/workflows/agent-ingress.yml\tci-failure-analyst\t.github/workflows/ci-failure-analyst.yml\t' ) # ── Never-overwrite allowlist (AC #3) ───────────────────────────────────────── diff --git a/tests/fixtures/agent-ingress/markets-agent-ingress.yml b/tests/fixtures/agent-ingress/markets-agent-ingress.yml new file mode 100644 index 000000000..5cdac1ad3 --- /dev/null +++ b/tests/fixtures/agent-ingress/markets-agent-ingress.yml @@ -0,0 +1,184 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Agent Ingress — petry-projects/markets (ADR-0007, epic #1723, story #1729) +# +# ONE ingress for this repo's eligible Class-1 event-driven agentic roles. The +# on: block is the UNION of the collapsed roles' triggers; each job below is ONE +# role, guarded by a PURE event-filter if: that reconstructs that role's original +# subscription. pull_request_target roles (add-to-project, dependabot-automerge, +# dependabot-rebase), repo-local (auto-rebase), Class-3 (feature-ideation, +# initiative-driver), and required-check gates (agent-shield, dependency-audit) +# keep their own stubs — see docs/initiatives/agent-ingress-collapse-markets.md. +# ───────────────────────────────────────────────────────────────────────────── + +name: Agent Ingress + +on: + pull_request: + # opened/reopened/synchronize ← dev-lead(base=main), pr-auto-review, pr-review; + # ready_for_review ← pr-auto-review, pr-review; review_requested ← pr-review-mention. + types: [opened, reopened, synchronize, ready_for_review, review_requested] + pull_request_review: + # submitted ← dev-lead, pr-auto-review, pr-review; dismissed ← pr-auto-review, pr-review. + types: [submitted, dismissed] + pull_request_review_comment: + types: [created] + issue_comment: + types: [created] + issues: + types: [labeled] + check_run: + types: [completed] + check_suite: + types: [completed] + workflow_run: + workflows: ["CI"] + types: [completed] + workflow_dispatch: + inputs: + pr_url: + description: "Optional: review a single PR URL instead of enumerating" + required: false + type: string + dry_run: + description: "If true, never submit reviews or comments" + required: false + default: "false" + type: string + force_review: + description: "If true, bypass idempotency and re-review at the same head SHA" + required: false + default: "false" + type: string + repository_dispatch: + types: [dev-lead-ci-failure, dev-lead-reviews-retry, dev-lead-issue-retry, pr-review-mention] + +permissions: {} + +jobs: + # ── dev-lead ──────────────────────────────────────────────────────────────── + dev-lead: + if: >- + (github.event_name == 'pull_request' + && contains(fromJSON('["opened","reopened","synchronize"]'), github.event.action) + && github.event.pull_request.base.ref == 'main') + || (github.event_name == 'pull_request_review' && github.event.action == 'submitted') + || github.event_name == 'pull_request_review_comment' + || github.event_name == 'issue_comment' + || github.event_name == 'issues' + || github.event_name == 'check_run' + || (github.event_name == 'repository_dispatch' + && contains(fromJSON('["dev-lead-ci-failure","dev-lead-reviews-retry","dev-lead-issue-retry"]'), github.event.action)) + uses: petry-projects/.github-private/.github/workflows/dev-lead-reusable.yml@dev-lead/v139-stable # NOSONAR(githubactions:S7637) first-party channel ref + with: + agent_ref: dev-lead/v139-stable + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable + permissions: + contents: write + pull-requests: write + issues: write + actions: read + checks: read + statuses: read + + # ── pr-auto-review ────────────────────────────────────────────────────────── + pr-auto-review: + if: >- + (github.event_name == 'pull_request' + && contains(fromJSON('["opened","reopened","synchronize","ready_for_review"]'), github.event.action)) + || (github.event_name == 'pull_request_review' + && contains(fromJSON('["submitted","dismissed"]'), github.event.action)) + || github.event_name == 'check_suite' + || github.event_name == 'workflow_run' + # Workflow-level concurrency in the source stub → job-level here, bounded per + # ADR-0010: literal role prefix, payload parts only (no run_id), literal bool. + # PR events keep their per-PR slot; check_suite / workflow_run use their own + # event ids so runs on a shared commit never cancel another PR's review (§4b). + concurrency: + group: >- + pr-auto-review-${{ + github.event.pull_request.number + || github.event.check_suite.id + || github.event.workflow_run.id + || 'none' + }} + cancel-in-progress: true + permissions: + pull-requests: read + checks: read + actions: read + uses: petry-projects/.github/.github/workflows/pr-auto-review-reusable.yml@pr-auto-review/v1-stable # NOSONAR(githubactions:S7637) first-party channel ref + secrets: + GH_PAT_WORKFLOWS: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + + # ── pr-review ───────────────────────────────────────────────────────────────── + pr-review: + # Subscription filter AND the source stub's check_suite-with-no-PR skip, both + # pure event predicates over the delivered payload. + if: >- + ( + (github.event_name == 'pull_request' + && contains(fromJSON('["ready_for_review","reopened","synchronize"]'), github.event.action)) + || (github.event_name == 'pull_request_review' + && contains(fromJSON('["submitted","dismissed"]'), github.event.action)) + || github.event_name == 'check_suite' + || github.event_name == 'workflow_dispatch' + || (github.event_name == 'repository_dispatch' && github.event.action == 'pr-review-mention') + ) + && (github.event_name != 'check_suite' || github.event.check_suite.pull_requests[0] != null) + # Job-level concurrency, bounded per ADR-0010: payload parts only (no inputs.*, + # no run_id). A sweep with no PR shares the 'enumerate' slot. The fallback is + # NOT 'batch', because the pinned reusable declares pr-review-batch (§4b). + concurrency: + group: >- + pr-review-${{ + (github.event.pull_request && github.event.pull_request.number) + || (github.event.check_suite && github.event.check_suite.pull_requests[0] && github.event.check_suite.pull_requests[0].number) + || github.event.inputs.pr_url + || (github.event.client_payload && github.event.client_payload.pr_url) + || 'enumerate' + }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + checks: read + uses: petry-projects/.github-private/.github/workflows/pr-review.yml@pr-review/stable # NOSONAR(githubactions:S7637) first-party channel ref # DEFECTIVE PIN — see §5 + with: + agent_ref: pr-review/stable + pr_url: ${{ inputs.pr_url || '' }} + dry_run: ${{ inputs.dry_run || '' }} + force_review: ${{ inputs.force_review || '' }} + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable + + # ── pr-review-mention ────────────────────────────────────────────────────────── + pr-review-mention: + if: >- + ( + (github.event_name == 'pull_request' && github.event.action == 'review_requested') + || github.event_name == 'issue_comment' + || github.event_name == 'pull_request_review_comment' + ) + && github.event.sender.type != 'Bot' + permissions: + pull-requests: write + uses: petry-projects/.github/.github/workflows/pr-review-mention-reusable.yml@pr-review-mention/v2-stable # NOSONAR(githubactions:S7637) first-party channel ref + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable + + # ── ci-failure-analyst ───────────────────────────────────────────────────────── + ci-failure-analyst: + if: >- + github.event_name == 'check_run' + && github.event.check_run.conclusion == 'failure' + && !startsWith(github.event.check_run.name, 'CI Failure Analyst') + concurrency: + group: "ci-failure-analyst-${{ github.event.check_run.head_sha }}" + cancel-in-progress: false + permissions: + pull-requests: write + issues: write + actions: read + contents: read + checks: read + uses: petry-projects/.github-private/.github/workflows/ci-failure-analyst-reusable.yml@b58510275dd1cbbd13c0733c15265d51fd63b992 # DEFECTIVE PIN — bare SHA, see §5 + secrets: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/tests/fleet_stub_drift.bats b/tests/fleet_stub_drift.bats index 3a6248df9..a605c2fab 100644 --- a/tests/fleet_stub_drift.bats +++ b/tests/fleet_stub_drift.bats @@ -260,6 +260,13 @@ INGRESS_NO_DEVLEAD="${BATS_TEST_DIRNAME}/fixtures/agent-ingress/collapsed-no-dev [ "$output" = "MISSING" ] } +# A role with no published per-role canonical (#1729: pr-review, +# ci-failure-analyst) cannot compare a legacy file, so it is MISSING, not DRIFTED. +@test "classify_role_drift: no ingress and no legacy canonical is MISSING, never DRIFTED" { + run classify_role_drift "no" "irrelevant-block-sha" "" "" "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + [ "$output" = "MISSING" ] +} + # --------------------------------------------------------------------------- # stub_drift_row_role — role rows flow through the SAME 4-field TSV shape as # whole-file rows, so the report/alert path is shared. The emitted SHAs are the diff --git a/tests/fleet_stub_registry.bats b/tests/fleet_stub_registry.bats new file mode 100644 index 000000000..eee405ba0 --- /dev/null +++ b/tests/fleet_stub_registry.bats @@ -0,0 +1,288 @@ +#!/usr/bin/env bats +# Tests for the collapsed-role STUB_REGISTRY rows (#1729 AC11(b)) and the AC4 +# no-resurrection proof. +# +# The five petry-projects/markets roles that collapsed into agent-ingress.yml +# (markets#513) are registered in BOTH scripts/fleet_monitor.sh and +# scripts/fleet_stub_remediate.sh. These tests drive the real fleet_monitor.sh +# and fleet_stub_remediate.sh drivers against a `gh` shim that serves files from +# a fixture tree, so no live API call is made. Run: +# bats tests/fleet_stub_registry.bats +# +# tests/fixtures/agent-ingress/markets-agent-ingress.yml is petry-projects/markets +# .github/workflows/agent-ingress.yml verbatim at 5fe86cbc (the merged collapse). + +MONITOR="${BATS_TEST_DIRNAME}/../scripts/fleet_monitor.sh" +REMEDIATE="${BATS_TEST_DIRNAME}/../scripts/fleet_stub_remediate.sh" +MARKETS_INGRESS="${BATS_TEST_DIRNAME}/fixtures/agent-ingress/markets-agent-ingress.yml" +INGRESS_STUB=".github/workflows/agent-ingress.yml" +INGRESS_CANON="standards/workflows/agent-ingress.yml" +ROLES=(dev-lead pr-auto-review pr-review pr-review-mention ci-failure-analyst) + +setup() { + # shellcheck source=scripts/fleet_stub_drift.sh + source "${BATS_TEST_DIRNAME}/../scripts/fleet_stub_drift.sh" + + WORK="$(mktemp -d "${BATS_TEST_TMPDIR}/work.XXXXXX")" + # FAKE_ROOT/// is what the shim serves for a contents read; + # an absent file answers HTTP 404, exactly like the contents API. + FAKE_ROOT="${WORK}/root"; export FAKE_ROOT + CALLS="${WORK}/calls.log"; export CALLS + ORG="petry-projects"; export ORG + STUB_BIN="${WORK}/bin" + mkdir -p "$FAKE_ROOT" "$STUB_BIN" + export PATH="$STUB_BIN:$PATH" + unset GITHUB_ENV GITHUB_STEP_SUMMARY + + cat > "$STUB_BIN/gh" <<'SHIM' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$CALLS" +if [ "$1" = "pr" ]; then + # `pr list` finds no open remediation PR; `pr create` is logged above. + exit 0 +fi +[ "$1" = "api" ] || exit 0 +shift +raw=0; jq_expr=""; method="GET"; path="" +while [ $# -gt 0 ]; do + case "$1" in + -H) [ "$2" = "Accept: application/vnd.github.raw" ] && raw=1; shift 2 ;; + --jq) jq_expr="$2"; shift 2 ;; + --method|-X) method="$2"; shift 2 ;; + --field|-f|-F|--input) shift 2 ;; + -*) shift ;; + *) [ -z "$path" ] && path="$1"; shift ;; + esac +done +case "$path" in + "orgs/${ORG}") echo '{}' ;; + "orgs/${ORG}/repos"*) for r in ${FAKE_REPOS:-}; do echo "$r"; done ;; + repos/*/contents/*) + [ "$method" = "GET" ] || { echo '{}'; exit 0; } + rest="${path#repos/}"; repo="${rest%%/contents/*}" + file="${rest#*/contents/}"; file="${file%%\?*}" + f="${FAKE_ROOT}/${repo}/${file}" + if [ ! -f "$f" ]; then + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + fi + if [ "$raw" -eq 1 ]; then cat "$f"; else git hash-object "$f"; fi ;; + repos/*/git/ref/heads/*) echo "basesha0000000000000000000000000000000000" ;; + repos/*/actions/workflows\?*) + # Section 2 lists workflows as an array; 2e asks for the ingress id. + case "$jq_expr" in first\(*) ;; *) echo '[]' ;; esac ;; + repos/*/actions/*) ;; + repos/*/*) ;; + repos/*) echo "main" ;; +esac +exit 0 +SHIM + chmod +x "$STUB_BIN/gh" +} + +teardown() { + [ -n "${WORK:-}" ] && rm -rf "$WORK" + return 0 +} + +# put_file — copy stdin into the fixture tree. +put_file() { + mkdir -p "$(dirname "${FAKE_ROOT}/$1/$2")" + cat > "${FAKE_ROOT}/$1/$2" +} + +# drop_job — print stdin with the block removed. +drop_job() { + awk -v job="$1" ' + function indent(s) { match(s, /^ */); return RLENGTH } + !injob && $0 ~ ("^ " job ":([ \t]|$)") { injob = 1; next } + injob && ($0 ~ /^[ \t]*$/ || indent($0) > 2) { next } + { injob = 0; print } + ' +} + +# publish_canonicals — the canonical ingress (the markets file) plus the three +# per-role legacy canonicals petry-projects/.github publishes. +publish_canonicals() { + put_file "petry-projects/.github" "$INGRESS_CANON" < "$MARKETS_INGRESS" + local r + for r in dev-lead pr-auto-review pr-review-mention; do + printf 'name: %s canonical legacy stub\n' "$r" \ + | put_file "petry-projects/.github" "standards/workflows/${r}.yml" + done +} + +# run_monitor — run fleet_monitor.sh over the given repos in $WORK. +run_monitor() { + export FAKE_REPOS="$*" + cd "$WORK" + run bash "$MONITOR" +} + +# role_counts