diff --git a/.github/workflows/apply-repo-settings-reusable.yml b/.github/workflows/apply-repo-settings-reusable.yml new file mode 100644 index 000000000..10c64c512 --- /dev/null +++ b/.github/workflows/apply-repo-settings-reusable.yml @@ -0,0 +1,92 @@ +# Reusable repo-settings + branch-policy self-heal — single source of truth for the org. +# Repo-level apply-repo-settings.yml stubs call this so the compliance self-heal runs +# from ONE script/workflow pair instead of per-repo forks (petry-projects/.github#984). +# Standard: https://github.com/petry-projects/.github/blob/main/standards/github-settings.md +# https://github.com/petry-projects/.github/blob/main/standards/ruleset-remediation-runbook.md +# +# Applies, to the CALLING repo: +# - merge settings, security_and_analysis, CodeQL default setup, labels, and +# check-suite auto-trigger preferences (scripts/apply-repo-settings.sh) +# - the branch-policy compliance rulesets pr-quality + code-quality +# (scripts/apply-rulesets.sh) — the recurring finding this workflow exists to +# fix (e.g. pr-quality's require_last_push_approval flipping to false). +# +# Credential: the check-suites/preferences endpoint and the ruleset admin calls are +# legacy — they accept ONLY a classic PAT (or GitHub App token) whose owner has +# repo-admin. Fine-grained PATs and GITHUB_TOKEN are rejected (403). The org secret +# GH_PAT_DON_PETRY holds that classic admin PAT; the preflight below fails loud, +# naming the secret, when it is empty/out-of-scope rather than dying obscurely deep +# in the script. +name: Apply repo settings (Reusable) + +on: + workflow_call: + inputs: + dry_run: + description: "Preview changes without applying (sets DRY_RUN=true)" + type: boolean + required: false + default: false + checkout_ref: + description: >- + Ref of petry-projects/.github to check out for the scripts, so the + logic runs at the same version as this reusable. Callers pin their + ring's channel tag (mirrors dev-lead's agent_ref). Empty = default branch. + type: string + required: false + default: "" + secrets: + GH_PAT_DON_PETRY: + description: "Classic PAT with repo scope whose owner has admin on target repos (check-suites/rulesets APIs reject fine-grained PATs and GITHUB_TOKEN)" + required: false + +permissions: {} # no default permissions; each job grants only what it needs + +jobs: + apply: + name: Apply settings and rulesets + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Preflight — require the admin PAT + env: + GH_PAT_DON_PETRY: ${{ secrets.GH_PAT_DON_PETRY }} + run: | + set -euo pipefail + if [[ -z "${GH_PAT_DON_PETRY:-}" ]]; then + _msg="The GH_PAT_DON_PETRY org secret is empty or out of scope for ${GITHUB_REPOSITORY}." + _msg+=" It must be a CLASSIC PAT with repo scope whose owner has admin on this repo" + _msg+=" — fine-grained PATs and GITHUB_TOKEN are rejected by the check-suites/rulesets APIs." + _msg+=" See standards/github-settings.md." + echo "::error title=Missing GH_PAT_DON_PETRY::${_msg}" + { + echo "## Apply repo settings — preflight failed" + echo "" + echo "\`GH_PAT_DON_PETRY\` is empty or out of scope for \`${GITHUB_REPOSITORY}\`." + echo "" + echo "Set it as an **org secret** (classic PAT, \`repo\` scope, owner has admin) with visibility covering this repo. See \`standards/github-settings.md\` §Organization-Level Secrets." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "GH_PAT_DON_PETRY present — proceeding." + + - name: Checkout petry-projects/.github (scripts at the pinned channel) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: petry-projects/.github + ref: ${{ inputs.checkout_ref }} + persist-credentials: false + + - name: Apply settings and rulesets to the calling repo + env: + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + repo="${GITHUB_REPOSITORY##*/}" + echo "Applying standard settings to ${repo} (dry_run=${DRY_RUN}) ..." + bash scripts/apply-repo-settings.sh "$repo" + echo "Applying branch-policy rulesets (pr-quality, code-quality) to ${repo} ..." + bash scripts/apply-rulesets.sh "$repo" diff --git a/.github/workflows/apply-repo-settings-tests.yml b/.github/workflows/apply-repo-settings-tests.yml index 276c5cf16..1629a08c4 100644 --- a/.github/workflows/apply-repo-settings-tests.yml +++ b/.github/workflows/apply-repo-settings-tests.yml @@ -7,14 +7,24 @@ on: pull_request: paths: - 'scripts/apply-repo-settings.sh' + - 'scripts/apply-rulesets.sh' - 'test/scripts/apply-repo-settings/**' - '.github/workflows/apply-repo-settings-tests.yml' + - '.github/workflows/apply-repo-settings-reusable.yml' + - '.github/workflows/apply-repo-settings.yml' + - 'standards/workflows/apply-repo-settings.yml' + - 'standards/canary-rings.json' push: branches: [main] paths: - 'scripts/apply-repo-settings.sh' + - 'scripts/apply-rulesets.sh' - 'test/scripts/apply-repo-settings/**' - '.github/workflows/apply-repo-settings-tests.yml' + - '.github/workflows/apply-repo-settings-reusable.yml' + - '.github/workflows/apply-repo-settings.yml' + - 'standards/workflows/apply-repo-settings.yml' + - 'standards/canary-rings.json' permissions: {} @@ -33,11 +43,36 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install bats, shellcheck, jq + - name: Install bats, shellcheck, jq, and yq run: | set -euo pipefail sudo apt-get update -qq sudo apt-get install -y --no-install-recommends bats shellcheck jq + # Install a pinned mikefarah/yq so the suite is reproducible and not + # dependent on whatever version ships in the runner image. + YQ_VERSION="v4.44.3" + # --proto '=https' --proto-redir '=https' enforces HTTPS on both the + # initial request and any redirects (GitHub releases redirect to + # objects.githubusercontent.com). + # --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors + # rides out the transient network/5xx blips from that redirect target + # that otherwise flake this suite; the budget stays bounded so a + # genuinely-down mirror still fails the job fast. + curl -sSfL --proto '=https' --proto-redir '=https' --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ + -o /tmp/yq_linux_amd64 + curl -sSfL --proto '=https' --proto-redir '=https' --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums" \ + -o /tmp/yq_checksums + curl -sSfL --proto '=https' --proto-redir '=https' --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors \ + "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/checksums_hashes_order" \ + -o /tmp/yq_checksums_order + # The checksums file puts filename first; use checksums_hashes_order to + # find the SHA-256 column position dynamically. + SHA256_COL=$(( $(grep -n "^SHA-256$" /tmp/yq_checksums_order | cut -d: -f1) + 1 )) + EXPECTED_SHA="$(grep "^yq_linux_amd64 " /tmp/yq_checksums | awk -v col="${SHA256_COL}" '{print $col}')" + echo "${EXPECTED_SHA} /tmp/yq_linux_amd64" | sha256sum -c - + sudo install -m 755 /tmp/yq_linux_amd64 /usr/local/bin/yq - name: shellcheck run: shellcheck --severity=warning -x scripts/apply-repo-settings.sh diff --git a/.github/workflows/apply-repo-settings.yml b/.github/workflows/apply-repo-settings.yml new file mode 100644 index 000000000..4bbdf913f --- /dev/null +++ b/.github/workflows/apply-repo-settings.yml @@ -0,0 +1,38 @@ +# Apply repo settings — host dogfood caller (ring0). +# .github hosts the reusable, so it self-heals through a LOCAL ./ ref (always the +# current version under test), mirroring dependabot-automerge.yml's dogfood +# convention (#541). Fleet repos adopt standards/workflows/apply-repo-settings.yml, +# which pins the released channel tag instead. +name: Apply repo settings + +on: + schedule: + # Weekly self-heal. Offset from other crons to spread org API load. + - cron: '17 6 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: "Preview changes without applying" + type: boolean + required: false + default: false + push: + branches: [main] + paths: + - '.github/workflows/apply-repo-settings.yml' + +permissions: {} + +concurrency: + group: apply-repo-settings-${{ github.ref }} + cancel-in-progress: false + +jobs: + apply: + permissions: + contents: read + uses: ./.github/workflows/apply-repo-settings-reusable.yml # local ref — always current (ring0 dogfood, #541) + with: + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + checkout_ref: ${{ github.sha }} # dogfood the exact version under test + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable diff --git a/standards/canary-rings.json b/standards/canary-rings.json index f4167882e..e97cf510b 100644 --- a/standards/canary-rings.json +++ b/standards/canary-rings.json @@ -364,6 +364,70 @@ } } }, + "apply-repo-settings": { + "host": "petry-projects/.github", + "reusable": ".github/workflows/apply-repo-settings-reusable.yml", + "run_workflow": "Apply repo settings", + "rings": [ + { + "channel": "next", + "order": 0, + "members": [ + "petry-projects/.github-private" + ] + }, + { + "channel": "ring0", + "order": 1, + "members": [ + "petry-projects/.github" + ] + }, + { + "channel": "ring1", + "order": 2, + "members": [ + "petry-projects/markets", + "petry-projects/TalkTerm", + "petry-projects/bmad-bgreat-suite" + ] + }, + { + "channel": "stable", + "order": 3, + "members": [ + "*" + ] + } + ], + "gate": { + "_standard": "petry-projects/.github#548 — graduated dwell/sample gate over a per-candidate cumulative window. These are registry-configurable per-transition knobs; the values below are the #548 defaults.", + "_cadence_note": "This workflow is weekly-cron (petry-projects/.github#984), so sample floors starve during soak. next->ring0 waives the sample when no caller run exists yet; ring members feed the gate via a manual workflow_dispatch after the stub lands. Do NOT paper over a starved gate with --override.", + "baseline_window_days": 14, + "baseline_spike_cap_multiple": 3, + "benign_failure_classes": [], + "control": { + "allow_pre_existing": false + }, + "transitions": { + "next->ring0": { + "dwell_hours": 4, + "sample_fraction_permille": 250, + "sample_clamp_min": 3, + "sample_clamp_max": 15, + "waive_sample_if_no_caller": true + }, + "ring0->ring1": { + "dwell_hours": 8, + "waive_sample": true + }, + "ring1->stable": { + "dwell_hours": 12, + "sample_min": 1 + } + } + } + }, "dependabot-rebase": { "host": "petry-projects/.github", "reusable": ".github/workflows/dependabot-rebase-reusable.yml", diff --git a/standards/ci-standards.md b/standards/ci-standards.md index 09078b8b9..9660abe43 100644 --- a/standards/ci-standards.md +++ b/standards/ci-standards.md @@ -310,6 +310,7 @@ filename doesn't carry the `-reusable.yml` suffix (grandfathered exception: | [`initiative-driver.yml`](workflows/initiative-driver.yml) | 1 | Dispatches the central initiative-driver to release ready sub-issues of `initiative:auto` epics to dev-lead — **required org-wide** (#844) | | [`pr-review-mention.yml`](workflows/pr-review-mention.yml) | 1 | Trigger the pr-review agent when `@donpetry-bot` is mentioned or `donpetry-bot` is assigned as reviewer | | [`persona-mention.yml`](workflows/persona-mention.yml) | 1 | Route `@petry-projects/` mentions to the addressed persona — one router for **all** personas ([persona-standards.md §4.1](persona-standards.md)) | +| [`apply-repo-settings.yml`](workflows/apply-repo-settings.yml) | 1 | Weekly branch-policy compliance self-heal — applies standard repo settings + `pr-quality`/`code-quality` rulesets ([github-settings.md](github-settings.md), [ruleset-remediation-runbook.md](ruleset-remediation-runbook.md)). Requires org secret `GH_PAT_DON_PETRY`. | | [`copilot-setup-steps.yml`](workflows/copilot-setup-steps.yml) | 2 | Pre-install tools and dependencies for Copilot cloud agent sessions | **Adapt only when the template genuinely requires repo-specific content** (e.g., a diff --git a/standards/github-settings.md b/standards/github-settings.md index 29ac03b34..e89a1b4c6 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -435,14 +435,22 @@ no PATCH is needed and no finding is raised until the app is first seen in the r **Additional AI engines** (Gemini, Copilot) — if a repo activates an alternative `DEV_LEAD_ENGINE`: Verify that the corresponding app has `auto_trigger_checks: false` set if/when it first creates a check run. -**Applying manually** (requires a classic PAT with `repo` scope — OAuth app tokens are rejected by this API endpoint): +**Applying** — the fleet self-heals weekly via the **`Apply repo settings`** workflow +(the org reusable `apply-repo-settings-reusable.yml`, adopted per repo through the thin +caller stub `standards/workflows/apply-repo-settings.yml`). To apply on demand, dispatch it: ```bash -GH_TOKEN= bash scripts/apply-repo-settings.sh -# or for all org repos: -GH_TOKEN= bash scripts/apply-repo-settings.sh --all +# Dispatch the self-heal for one repo (add -f dry_run=true to preview): +gh workflow run "Apply repo settings" --repo petry-projects/ ``` +The workflow authenticates with the org secret **`GH_PAT_DON_PETRY`** (classic PAT, `repo` +scope, owner has repo-admin). The check-suites/preferences and ruleset APIs are legacy and +reject fine-grained PATs and `GITHUB_TOKEN` (403), which is why a classic admin PAT is +required. Running the scripts by hand (`GH_TOKEN= bash +scripts/apply-repo-settings.sh `) remains possible for break-glass, but the +dispatched workflow is the standard path. + ### Other Integrations | Integration | Purpose | Scope | @@ -464,6 +472,7 @@ all repos automatically — no per-repo setup needed: | `APP_PRIVATE_KEY` | GitHub App private key for Dependabot auto-merge | | `CLAUDE_CODE_OAUTH_TOKEN` | Authentication for Claude Code Action and dev-lead agent (default engine) | | `DON_PETRY_BOT_GH_PAT` | Classic PAT (repo scope) owned by donpetry-bot; required by `pr-review-mention-reusable.yml` to post review-mention comments as the bot identity | +| `GH_PAT_DON_PETRY` | Classic PAT owned by donpetry-bot with **`repo` scope AND repo-admin on target repos**; used by the `Apply repo settings` self-heal (`apply-repo-settings-reusable.yml`) for the check-suites/preferences and ruleset admin APIs, which are legacy and **reject fine-grained PATs and `GITHUB_TOKEN` (403)**. Visibility must cover the whole fleet (including public repos) or the workflow preflight fails loud. Distinct from `DON_PETRY_BOT_GH_PAT` above (repo-scope only, no admin, review-mention identity) — do not conflate the two. | | `GH_PAT_WORKFLOWS` | Classic PAT with `repo` scope; required for cross-repo script access and dev-lead to push workflow files | | `GITLEAKS_LICENSE` | Gitleaks license key required for `secret-scan` job in organization repositories (see [ci-standards.md](ci-standards.md#4-secret-scanning-ciymll--gitleaks-job)) | | `SONAR_TOKEN` | SonarCloud analysis authentication | diff --git a/standards/ruleset-remediation-runbook.md b/standards/ruleset-remediation-runbook.md index 9fef53e49..60acf65c9 100644 --- a/standards/ruleset-remediation-runbook.md +++ b/standards/ruleset-remediation-runbook.md @@ -12,6 +12,17 @@ weekly compliance audit (`check_ruleset_bypass_actors()` and > `administration:write`) that the CI `GITHUB_TOKEN` does not carry — so these > findings are detected and filed every week but never auto-applied. Run this > runbook with an admin token to close them. +> +> **Weekly self-heal (preferred first line).** The `Apply repo settings` workflow +> (org reusable [`apply-repo-settings-reusable.yml`](../.github/workflows/apply-repo-settings-reusable.yml), +> adopted per repo via [`standards/workflows/apply-repo-settings.yml`](workflows/apply-repo-settings.yml)) +> now runs `apply-rulesets.sh` on a **weekly cron**, converging `pr-quality` / +> `code-quality` to the codified `standards/rulesets/*.json` — this is what fixes the +> recurring `require_last_push_approval` drift (petry-projects/.github#984). It +> authenticates with the classic admin PAT `GH_PAT_DON_PETRY`. To remediate now +> without waiting for the cron, dispatch it: `gh workflow run "Apply repo settings" +> --repo petry-projects/`. This runbook remains the path for **bypass-actor** +> and **legacy-ruleset** findings, which `apply-rulesets.sh` does not cover. See [`github-settings.md` § Repository Rulesets](github-settings.md#repository-rulesets) for the policy this enforces: diff --git a/standards/workflows/apply-repo-settings.yml b/standards/workflows/apply-repo-settings.yml new file mode 100644 index 000000000..64702782f --- /dev/null +++ b/standards/workflows/apply-repo-settings.yml @@ -0,0 +1,55 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: petry-projects/.github/standards/workflows/apply-repo-settings.yml +# Standard: petry-projects/.github/standards/github-settings.md +# petry-projects/.github/standards/ruleset-remediation-runbook.md +# Reusable: petry-projects/.github/.github/workflows/apply-repo-settings-reusable.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All settings/ruleset logic lives in the +# reusable workflow above; consumer repos carry NO script copy. +# • You MAY change: the cron offset only (stagger org API load). +# • You MUST NOT change: the `uses:` line, `secrets: inherit`, the job-level +# `permissions:` block, or the `checkout_ref` forward — reusable workflows can +# be granted no more permissions than the calling job has, and forwarding an +# input the pinned channel does not declare breaks every run at startup (#1034). +# • If you need different behaviour, open a PR against the reusable in the +# central repo. +# ───────────────────────────────────────────────────────────────────────────── +# +# Apply repo settings — thin caller for the org-level compliance self-heal. +# To adopt: copy this file verbatim to .github/workflows/apply-repo-settings.yml. +# Required org secret (inherited): +# GH_PAT_DON_PETRY — classic PAT, repo scope, owner has admin on this repo. +name: Apply repo settings + +on: + schedule: + # Weekly self-heal. The cron offset MAY be changed to stagger org API load. + - cron: '17 6 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: "Preview changes without applying" + type: boolean + required: false + default: false + push: + branches: [main] + paths: + - '.github/workflows/apply-repo-settings.yml' + +permissions: {} + +concurrency: + group: apply-repo-settings-${{ github.ref }} + cancel-in-progress: false + +jobs: + apply: + permissions: + contents: read + uses: petry-projects/.github/.github/workflows/apply-repo-settings-reusable.yml@apply-repo-settings/v1-stable # NOSONAR(githubactions:S7637) first-party channel ref + with: + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} + checkout_ref: apply-repo-settings/v1-stable # keep in lockstep with the uses: channel pin + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable diff --git a/test/scripts/apply-repo-settings/reusable-contract.bats b/test/scripts/apply-repo-settings/reusable-contract.bats new file mode 100644 index 000000000..9a3918e8c --- /dev/null +++ b/test/scripts/apply-repo-settings/reusable-contract.bats @@ -0,0 +1,156 @@ +#!/usr/bin/env bats +# Contract tests for the apply-repo-settings reusable workflow + its thin caller +# stubs (petry-projects/.github#984). +# +# The reusable (.github/workflows/apply-repo-settings-reusable.yml) is the single +# source of truth for the branch-policy compliance self-heal; the fleet adopts it +# through a thin caller stub (standards/workflows/apply-repo-settings.yml) and the +# host repo dogfoods it via a local-ref caller (.github/workflows/apply-repo-settings.yml). +# +# These tests encode the two invariants that break the self-heal when violated: +# 1. The caller-input contract (incident #1034): every `with:` key a stub +# forwards MUST be a `workflow_call` input the reusable declares, or the +# first real trigger dies at startup_failure. +# 2. Scope: the reusable must actually run BOTH the settings applier AND the +# ruleset applier — the rulesets (pr-quality's require_last_push_approval) +# are the recurring finding this issue exists to fix; settings alone do not +# touch them. + +REPO_ROOT="$(cd -- "${BATS_TEST_DIRNAME}/../../.." && pwd)" +REUSABLE="${REPO_ROOT}/.github/workflows/apply-repo-settings-reusable.yml" +STUB="${REPO_ROOT}/standards/workflows/apply-repo-settings.yml" +HOST_CALLER="${REPO_ROOT}/.github/workflows/apply-repo-settings.yml" +REGISTRY="${REPO_ROOT}/standards/canary-rings.json" + +# ── the reusable is a well-formed pure reusable ─────────────────────────────── +@test "reusable exists and declares workflow_call" { + [ -f "$REUSABLE" ] + run yq '.on | has("workflow_call")' "$REUSABLE" + [ "$output" = "true" ] +} + +@test "reusable is a PURE reusable (workflow_call is its only trigger)" { + # Per ci-standards.md, a pure reusable's `on:` declares workflow_call and nothing + # else, and the file carries the -reusable.yml suffix. + run yq '.on | keys | length' "$REUSABLE" + [ "$output" = "1" ] +} + +@test "reusable declares the dry_run and checkout_ref inputs" { + run yq '.on.workflow_call.inputs | has("dry_run")' "$REUSABLE" + [ "$output" = "true" ] + run yq '.on.workflow_call.inputs | has("checkout_ref")' "$REUSABLE" + [ "$output" = "true" ] +} + +@test "reusable declares the GH_PAT_DON_PETRY secret" { + run yq '.on.workflow_call.secrets | has("GH_PAT_DON_PETRY")' "$REUSABLE" + [ "$output" = "true" ] +} + +@test "reusable has a loud preflight naming the GH_PAT_DON_PETRY secret" { + # markets-style: an empty/missing secret must fail loud, naming the secret in an + # error annotation, not a buried generic failure. + run grep -q 'GH_PAT_DON_PETRY' "$REUSABLE" + [ "$status" -eq 0 ] + run grep -qE '::error' "$REUSABLE" + [ "$status" -eq 0 ] +} + +@test "reusable runs BOTH apply-repo-settings.sh AND apply-rulesets.sh" { + # Assert on the step run: blocks only, so comments in the file don't satisfy this. + local run_blocks + run_blocks="$(yq '[.jobs[].steps[].run // ""] | join("\n")' "$REUSABLE")" + echo "$run_blocks" | grep -qF 'bash scripts/apply-repo-settings.sh' + echo "$run_blocks" | grep -qF 'bash scripts/apply-rulesets.sh' +} + +@test "reusable checks out petry-projects/.github at the checkout_ref input" { + run yq '[.jobs[].steps[] | select(.uses | contains("checkout")) | .with.repository] | contains(["petry-projects/.github"])' "$REUSABLE" + [ "$output" = "true" ] + run grep -q 'inputs.checkout_ref' "$REUSABLE" + [ "$status" -eq 0 ] +} + +# ── caller-input contract (#1034): stub forwards only declared inputs ────────── +@test "every with: key the canonical stub forwards is a declared reusable input" { + [ -f "$STUB" ] + local declared forwarded key + declared="$(yq '(.on.workflow_call.inputs // {}) | keys | .[]?' "$REUSABLE")" + forwarded="$(yq '(.jobs[].with // {}) | keys | .[]?' "$STUB")" + [ -n "$forwarded" ] || skip "stub forwards no inputs" + while IFS= read -r key || [ -n "$key" ]; do + key="${key%$'\r'}" + [ -z "$key" ] && continue + if ! grep -qxF "$key" <<<"$declared"; then + echo "stub forwards undeclared input: $key (declared: $declared)" + return 1 + fi + done <<<"$forwarded" +} + +# ── canonical stub shape ────────────────────────────────────────────────────── +@test "canonical stub pins the major-scoped apply-repo-settings channel with the S7637 marker" { + run grep -E 'uses:.*apply-repo-settings-reusable\.yml@apply-repo-settings/v[0-9]+-stable.*NOSONAR\(githubactions:S7637\) first-party channel ref' "$STUB" + [ "$status" -eq 0 ] +} + +@test "canonical stub uses secrets: inherit with the S7635 marker" { + run grep -E '^[[:space:]]*secrets:[[:space:]]+inherit.*NOSONAR\(githubactions:S7635\)' "$STUB" + [ "$status" -eq 0 ] +} + +@test "canonical stub is named 'Apply repo settings' to match the registry run_workflow" { + run yq '.name' "$STUB" + [ "$output" = "Apply repo settings" ] +} + +# ── host dogfood caller ─────────────────────────────────────────────────────── +@test "host caller dogfoods via a local ./ reusable ref" { + [ -f "$HOST_CALLER" ] + run grep -qE 'uses:[[:space:]]*\./\.github/workflows/apply-repo-settings-reusable\.yml' "$HOST_CALLER" + [ "$status" -eq 0 ] +} + +@test "host caller is named 'Apply repo settings' and runs weekly" { + run yq '.name' "$HOST_CALLER" + [ "$output" = "Apply repo settings" ] + run yq '.on | has("schedule")' "$HOST_CALLER" + [ "$output" = "true" ] + run yq '.on | has("workflow_dispatch")' "$HOST_CALLER" + [ "$output" = "true" ] +} + +# ── registry registration (avoids DRIFT[unregistered]) ──────────────────────── +@test "apply-repo-settings is registered in canary-rings.json" { + run yq -oy '.agents["apply-repo-settings"].host' "$REGISTRY" + [ "$output" = "petry-projects/.github" ] + run yq -oy '.agents["apply-repo-settings"].run_workflow' "$REGISTRY" + [ "$output" = "Apply repo settings" ] +} + +@test "registry entry declares next/ring0/ring1/stable rings" { + run yq -oy '[.agents["apply-repo-settings"].rings[].channel] | sort | join(",")' "$REGISTRY" + [ "$output" = "next,ring0,ring1,stable" ] +} + +@test "registry entry has correct reusable path, ring order, and membership" { + run yq -oy '.agents["apply-repo-settings"].reusable' "$REGISTRY" + [ "$output" = ".github/workflows/apply-repo-settings-reusable.yml" ] + # Ring orders must be monotonically staged: next=0, ring0=1, ring1=2, stable=3 + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "next") | .order' "$REGISTRY" + [ "$output" = "0" ] + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "ring0") | .order' "$REGISTRY" + [ "$output" = "1" ] + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "ring1") | .order' "$REGISTRY" + [ "$output" = "2" ] + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "stable") | .order' "$REGISTRY" + [ "$output" = "3" ] + # Membership anchors: host-private in next, host in ring0, wildcard in stable + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "next") | .members[0]' "$REGISTRY" + [ "$output" = "petry-projects/.github-private" ] + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "ring0") | .members[0]' "$REGISTRY" + [ "$output" = "petry-projects/.github" ] + run yq -oy '.agents["apply-repo-settings"].rings[] | select(.channel == "stable") | .members[0]' "$REGISTRY" + [ "$output" = "*" ] +}