Skip to content

[Phase 3] spam-pr-guard workflow in DRY_RUN, with the section 4 row and interaction contract #1204

Open

Description

@don-petry

Story

As a dev-lead,
I want ship spam-pr-guard on the fleet's standard vendored-reusable + caller-stub + canary-rings shape — the reusable in petry-projects/.github/.github/workflows/spam-pr-guard-reusable.yml, the stub template in standards/workflows/spam-pr-guard.yml, the paired tests workflow in .github/workflows/spam-pr-guard-tests.yml, the registry entry in standards/canary-rings.json, and the deploy wiring in scripts/deploy-standard-workflows.sh + scripts/lib/ring-pins.sh — plus the section-4 row and interaction contract,
so that the scorer runs on real events safely (metadata-only, no PR-code execution), ships through the canary rings next → ring0 → ring1 → stable, and the role is declared and CI-verified before any label or close is enabled.

Model: pr-auto-review (host petry-projects/.github, reusable .github/workflows/pr-auto-review-reusable.yml, stub standards/workflows/pr-auto-review.yml, registry entry standards/canary-rings.json:949-1017). The dry_run enforcement switch is a stub-level with: input, defaulting ON — Phase 5 flips it per-ring, no code change required.

Acceptance Criteria

  1. Reusable: .github/workflows/spam-pr-guard-reusable.yml is a on: workflow_call: entry point with:
    • inputs.dry_run (bool, default true) wired to env: DRY_RUN: ${{ inputs.dry_run }} (pattern .github/workflows/apply-repo-settings-reusable.yml:25-29, 85, 99);
    • inputs.agent_ref (string, default spam-pr-guard/v1-stable) with actions/checkout of repository: petry-projects/.github, ref: ${{ inputs.agent_ref }} so the scorer and lib run at the pinned channel (pattern .github/workflows/add-to-project-reusable.yml:62-99);
    • permissions: {} at the workflow top level; job-level permissions only — pull-requests: write and issues: write are required to post the needs-info / spam comment/label even while dry_run: true (writes are gated by DRY_RUN inside the staged action, not at the token level);
    • a single job that reacts to inputs.event (the stub forwards ${{ github.event_name }}) and runs scripts/spam-pr-score.sh from the checked-out agent_ref.
  2. No PR-code execution. The reusable performs no checkout of the PR head (API/metadata only; actions/checkout v7 defaults block fork-PR checkout in pull_request_target). The security invariant is called out explicitly in the interaction contract.
  3. Staged action mapping is idempotent. At most one marker-keyed comment per PR, keyed by <!-- spam-pr-guard:v1 action=<class> -->; re-edits do not post duplicates.
  4. Caller stub (template): standards/workflows/spam-pr-guard.yml is a thin stub with:
    • on: pull_request_target: types: [opened, edited, reopened] only (no schedule);
    • permissions: {} top-level; job-level permissions as above;
    • uses: petry-projects/.github/.github/workflows/spam-pr-guard-reusable.yml@spam-pr-guard/v1-stable # NOSONAR(githubactions:S7637) first-party channel ref (the # NOSONAR … first-party channel ref marker is MANDATORY — scripts/seed-repo-template.sh:60-67 refuses to emit a stub without it);
    • with: { dry_run: true, agent_ref: spam-pr-guard/v1-stable };
    • the standard SOURCE OF TRUTH / Standard / Reusable / AGENTS — READ BEFORE EDITING header block, identical in shape to standards/workflows/pr-auto-review.yml:1-81.
  5. Paired tests workflow: .github/workflows/spam-pr-guard-tests.yml is path-filtered on pull_request: and push: branches: [main] watching .github/workflows/spam-pr-guard*.yml, scripts/lib/safety-checks.sh, scripts/spam-pr-score.sh, standards/spam-pr-guard.json, tests/test_spam_pr_*.bats, tests/fixtures/spam-pr-guard/**, and standards/workflows/spam-pr-guard.yml. Concurrency spam-pr-guard-tests-${{ github.ref }}, cancel-in-progress: true, pinned actions/checkout@3d3c42e5… v7.0.1, persist-credentials: false, permissions: contents: read at the job level. Model: .github/workflows/pr-auto-review-tests.yml + the .github/workflows/safety-checks-tests.yml shipped by Phase 1.
  6. Registry entry: standards/canary-rings.json .agents["spam-pr-guard"] is added with host: petry-projects/.github, reusable: .github/workflows/spam-pr-guard-reusable.yml, run_workflow: <caller-stub display name>, rings with next: $host-sibling (resolves to .github-private via the registry's $host/$org_infra tokens — mirror pr-auto-review lines 955-983), ring0: $host, ring1: TalkTerm + bmad-bgreat-suite, stable: *. The gate block carries the standard #548 defaults (baseline_window_days, baseline_spike_cap_multiple, benign_failure_classes: [], control.allow_pre_existing: false, three transitions). The ring1→stable transition sets require_confirmation: true (see Phase 5).
  7. Ring-pins wiring: spam-pr-guard is inserted alphabetically into RING_REUSABLES=(...) at scripts/lib/ring-pins.sh:32-50; the bats case asserting RING_REUSABLES == .agents | keys passes without edit.
  8. Deploy wiring: spam-pr-guard.yml is added to DEPLOYABLE_WORKFLOWS=(...) at scripts/deploy-standard-workflows.sh:116-129. If audit treats the stub as required for the fleet, SKIP_SELF_MANAGED[".github"] and [".github-private"] are extended to include spam-pr-guard.yml so both org-infra repos reconcile under their self-managed path.
  9. Interaction contract + section-4 row. interaction-contracts/spam-pr-guard.yml is added (schema_version, role, kind: runtime, workflows, interaction.{triggers.events, emits, idempotency_key, concurrency_lane, stop_markers, budget}). budget: none is justified (deterministic, no agent churn). idempotency_key = pr_url + head_sha; per-PR concurrency lane. A Class 1 row (no timer_role) is added to docs/agentic-interaction-model.md § 4.
  10. Validators pass. validate-interaction-model, validate-interaction-contracts.py, and validate-workflow-schedules all pass. The role does not self-trigger (pull_request_target reacts to human/fork events, not its own output; §7 rule 1 satisfied with no self_trigger_guards).
  11. shellcheck, the paired tests workflow, and .github/workflows/ci.yml all pass on this PR.

Tasks / Subtasks

Dev Notes

  • pull_request_target metadata-only is the sanctioned pattern; actions/checkout v7 blocks fork checkout by default — called out as a security invariant in the contract (enhancement risk Add workflow, environment, and orchestration guidance #4).
  • Class by the section-3 discriminator: E and not S ⇒ Class 1, no timer_role. A pull_request_target single-role stub is a legacy single-role carve-out and carries no job qualifier — exactly one section-4 row. Table is a fixed-column markdown table (Workflow (path) | Class | timer_role | Justification); justification cells must be pipe-free.
  • Interaction contract shape: copy interaction-contracts/pr-review.yml / dev-lead.yml — schema_version, role, kind: runtime, workflows, interaction.{triggers.events, emits, idempotency_key, concurrency_lane, stop_markers, budget}. budget: none is acceptable here (deterministic, no agent churn) — justify it; do NOT invent a new numeric cap.
  • dry_run staging is a per-ring, per-consumer input, not a repo var. The stub carries with: { dry_run: true }; Phase 5's go/no-go flips the stub default (via the next scripts/cut-standards-release.sh cut) once require_confirmation clears on ring1→stable. Keep writes gated INSIDE the staged action by checking env.DRY_RUN rather than at the token level — a repo can still raise the stub input to override per-ring without rebuilding permissions.
  • scripts/canary-rollout.sh autocut will mint spam-pr-guard/vX.Y.Z + move spam-pr-guard/v1-next on first merge, provided CANARY_AUTO_CUT is on and the registry entry (AC feat: add Structured Logging and CQRS standards #6) is already present. Seeding (first-ever cut) is a one-time plumbing step after this PR merges, done via scripts/canary-rollout.sh (or scripts/cut-release.sh if the agent prefers the one-shot form). It is a repo mutation, not a file change, and cannot land from a PR diff.
  • The reusable's own permissions.contents: read is required at the job level because of the agent_ref checkout; pull-requests: write and issues: write are required to post comments/labels. DRY_RUN=true short-circuits the write calls — the token holds the scope but the code does not spend it.
  • If the Phase 2 burst signal needs a read beyond pull-requests + issues (org-wide open-PR search), grant only the minimal additional read (or pass a declared PAT via secrets.inherit) — do not broaden write permissions; keep the metadata-only, least-privilege posture.

Project Structure Notes

Four new files (reusable, stub, tests workflow, interaction contract) + three edits (standards/canary-rings.json, scripts/lib/ring-pins.sh, scripts/deploy-standard-workflows.sh) + one doc row (docs/agentic-interaction-model.md § 4). Consumes scripts/lib/safety-checks.sh (Phase 1) and scripts/spam-pr-score.sh + standards/spam-pr-guard.json (Phase 2).

References

Likely target surface

  • .github/workflows/spam-pr-guard-reusable.yml
  • .github/workflows/spam-pr-guard-tests.yml
  • standards/workflows/spam-pr-guard.yml
  • standards/canary-rings.json
  • scripts/lib/ring-pins.sh
  • scripts/deploy-standard-workflows.sh
  • interaction-contracts/spam-pr-guard.yml
  • docs/agentic-interaction-model.md

Story prepared by the BMAD Scrum Master (Bob) for epic #1200. Realigned to the vendored-reusable + caller-stub + canary-rings shape (per discussion with the user, 2026-10-01). Status: ready-for-dev (blocked_by #1203).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions