diff --git a/.github/workflows/renovate-automerge.yml b/.github/workflows/renovate-automerge.yml new file mode 100644 index 00000000..246dd27f --- /dev/null +++ b/.github/workflows/renovate-automerge.yml @@ -0,0 +1,26 @@ +name: Renovate Auto-merge + +on: + workflow_run: + workflows: + - Consumer Validation + - Dependency Review + - PAT ban — no new unapproved secrets + - actionlint + - Unit Tests + types: [completed] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.workflow_run.conclusion == 'success' + uses: ./.github/workflows/reusable-renovate-automerge.yml + with: + head_sha: ${{ github.event.workflow_run.head_sha }} + base_branch: main + secrets: + app_id: ${{ secrets.MERGERAPTOR_APP_ID }} + private_key: ${{ secrets.MERGERAPTOR_PRIVATE_KEY }} diff --git a/.github/workflows/reusable-renovate-automerge.yml b/.github/workflows/reusable-renovate-automerge.yml index 278e434e..90e1e3c0 100644 --- a/.github/workflows/reusable-renovate-automerge.yml +++ b/.github/workflows/reusable-renovate-automerge.yml @@ -20,8 +20,10 @@ # uses: projectbluefin/actions/.github/workflows/reusable-renovate-automerge.yml@v1 # with: # head_sha: ${{ github.event.workflow_run.head_sha }} -# # Optional — REQUIRED when base_branch uses a merge queue: merge-queue -# # groups created by github-actions[bot] never dispatch required checks +# # Optional — only needed when the base branch review-bypass rules +# # exclude github-actions[bot] and require the MergeRaptor app identity. +# # REQUIRED when base_branch uses a merge queue: merge-queue groups +# # created by github-actions[bot] never dispatch required checks # # (GITHUB_TOKEN events do not trigger workflows), so the queue entry # # wedges at AWAITING_CHECKS until it times out. Pass GitHub App # # credentials so the merge is performed by the app instead: @@ -47,24 +49,22 @@ on: default: "testing" required: false secrets: - token: - description: > - Optional GitHub token with merge permissions. Use when the base - branch has review-bypass rules that exclude github-actions[bot] - (e.g. a mergeraptor app token). Falls back to github.token when - not provided. - required: false app_id: description: > - Optional GitHub App ID. When set together with private_key, a - short-lived app token is minted and used for the merge. Required - for merge-queue base branches: queue groups created by - github-actions[bot] never dispatch required checks. Takes - precedence over token. + Optional GitHub App ID used to mint the merge token for protected + branches that exclude github-actions[bot] from review bypass. Also + required for merge-queue base branches, where github-actions[bot] + queue groups never dispatch required checks. required: false private_key: description: > - Private key for app_id. + Optional GitHub App private key used with app_id to mint the merge + token for protected branches. + required: false + token: + description: > + Optional GitHub token with merge permissions. Falls back to + github.token when no app token or explicit token is provided. required: false permissions: @@ -75,37 +75,59 @@ jobs: automerge: name: Auto-merge Renovate PRs runs-on: ubuntu-latest + env: + APP_ID: ${{ secrets.app_id }} + PRIVATE_KEY: ${{ secrets.private_key }} steps: - - name: Mint app token + - name: Generate MergeRaptor token + if: ${{ env.APP_ID != '' && env.PRIVATE_KEY != '' }} id: app-token - if: ${{ secrets.app_id != '' }} uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: - app-id: ${{ secrets.app_id }} - private-key: ${{ secrets.private_key }} + app-id: ${{ env.APP_ID }} + private-key: ${{ env.PRIVATE_KEY }} permission-contents: write permission-pull-requests: write - - name: Find Renovate PR for this commit + - name: Find qualifying Renovate PR for this commit id: find-pr env: GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token || github.token }} HEAD_SHA: ${{ inputs.head_sha }} BASE_BRANCH: ${{ inputs.base_branch }} run: | - PR_NUMBER=$(gh pr list \ - --repo "${{ github.repository }}" \ - --base "$BASE_BRANCH" \ - --state open \ - --json number,headRefOid,author \ - --jq ".[] | select(.headRefOid == \"$HEAD_SHA\") | select(.author.login == \"renovate[bot]\" or .author.login == \"app/mergeraptor\") | .number" \ - | head -1) + PR_NUMBER=$(gh api graphql -f query=" + query(\$owner: String!, \$repo: String!, \$base: String!) { + repository(owner: \$owner, name: \$repo) { + pullRequests(first: 100, states: OPEN, baseRefName: \$base) { + nodes { + number + headRefOid + author { login } + autoMergeRequest { + enabledAt + enabledBy { login } + } + } + } + } + }" \ + -f owner="${GITHUB_REPOSITORY_OWNER}" \ + -f repo="${GITHUB_REPOSITORY#*/}" \ + -f base="$BASE_BRANCH" \ + | jq -r --arg head "$HEAD_SHA" '.data.repository.pullRequests.nodes[] + | select(.headRefOid == $head) + | select(.author.login == "app/mergeraptor" or .author.login == "renovate[bot]") + | select(.autoMergeRequest != null) + | select(.autoMergeRequest.enabledBy != null) + | select(.autoMergeRequest.enabledBy.login == "app/mergeraptor" or .autoMergeRequest.enabledBy.login == "renovate[bot]") + | .number' | head -1) if [ -z "$PR_NUMBER" ]; then - echo "No open Renovate/Mergeraptor PR found for SHA $HEAD_SHA on base $BASE_BRANCH — skipping" + echo "No eligible Renovate/Mergeraptor PR found for SHA $HEAD_SHA on base $BASE_BRANCH — skipping" echo "pr_number=" >> "$GITHUB_OUTPUT" else - echo "Found Renovate/Mergeraptor PR #$PR_NUMBER" + echo "Found eligible Renovate/Mergeraptor PR #$PR_NUMBER" echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" fi @@ -113,19 +135,34 @@ jobs: if: steps.find-pr.outputs.pr_number != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token || github.token }} + PR_NUMBER: ${{ steps.find-pr.outputs.pr_number }} run: | - # Direct squash-merge. --auto is intentionally absent: - # 1. without branch protection rules, --auto fails - # (enablePullRequestAutoMerge → "Protected branch rules not configured") - # 2. --auto uses GitHub's auto-merge queue which does NOT honour - # bypass_pull_request_allowances; only direct merges do. - # On a merge-queue branch this call enqueues instead of merging - # directly; the queue entry's actor is this step's token identity, - # and github-actions[bot] entries never dispatch required checks — - # pass app_id/private_key in that case. - # CI success is already guaranteed by the workflow_run trigger condition. - gh pr merge "${{ steps.find-pr.outputs.pr_number }}" \ - --squash \ - --repo "${{ github.repository }}" \ - || echo "::warning::PR merge skipped (already merged or conflicting)" - echo "✅ Merged PR #${{ steps.find-pr.outputs.pr_number }}" + set +e + CHECKS=$(gh pr checks "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json bucket,name) + CHECKS_STATUS=$? + set -e + + if [ "$CHECKS_STATUS" -ne 0 ] && [ "$CHECKS_STATUS" -ne 1 ] && [ "$CHECKS_STATUS" -ne 8 ]; then + exit "$CHECKS_STATUS" + fi + + if [ -z "$CHECKS" ] || ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$CHECKS"; then + echo "Failed to read PR check rollup for PR #$PR_NUMBER" >&2 + exit 1 + fi + + if [ "$(jq 'length' <<<"$CHECKS")" -eq 0 ] || + [ "$(jq '[.[] | select(.bucket != "pass")] | length' <<<"$CHECKS")" -ne 0 ]; then + echo "PR #$PR_NUMBER does not have a complete successful check rollup; skipping" + exit 0 + fi + + # Direct squash-merge is intentional: the MergeRaptor installation + # token can use the protected-branch review bypass, while queue/auto + # merge cannot rely on that app-only exception. On a merge-queue + # branch this call enqueues instead of merging directly; the queue + # entry's actor is this step's token identity, and github-actions[bot] + # entries never dispatch required checks — pass app_id/private_key in + # that case. + gh pr merge "$PR_NUMBER" --squash --repo "$GITHUB_REPOSITORY" + echo "Merged PR #$PR_NUMBER" diff --git a/docs/skills/factory-operations.md b/docs/skills/factory-operations.md index 512b7508..4799e399 100644 --- a/docs/skills/factory-operations.md +++ b/docs/skills/factory-operations.md @@ -3,6 +3,9 @@ name: factory-operations description: Production gate (2-human approval), promotion cadence and merge-queue contract, factory health monitor, and Renovate auto-merge. metadata: type: reference + context7-sources: + - /actions/create-github-app-token + - /websites/cli_github_manual --- # Factory Operations Skill @@ -178,34 +181,58 @@ issues are opened. ### What it does -Renovate runs as the MergeRaptors GitHub App and opens PRs to bump pinned action SHAs and digests. Qualifying PRs auto-merge when CI passes. If auto-merge is not enabled, an agent may merge a qualifying PR when it carries the `clanker-queue` label and all required checks pass. +Renovate runs as the MergeRaptor GitHub App and opens PRs to bump pinned action SHAs and digests. Qualifying PRs auto-merge when CI passes without human review. If auto-merge is not enabled, an agent may merge a qualifying PR when it carries the `clanker-queue` label and all required checks pass. -### Config +### Review-bypass procedure + +`main` keeps required CODEOWNERS review. MergeRaptor is the only app allowed in +`required_pull_request_reviews.bypass_pull_request_allowances.apps`, and only +the CI-gated reusable workflow may mint a MergeRaptor installation token and +use it to squash-merge a Renovate-eligible PR. + +The local `renovate-automerge.yml` caller is only a thin `workflow_run` +wrapper. It forwards the completed workflow SHA, `base_branch: main`, and the +MergeRaptor app credentials to the reusable workflow, which then: + +1. Finds a Renovate/MergeRaptor PR for the completed SHA +2. Confirms the PR author and auto-merge enabler are Renovate/MergeRaptor +3. Requires a non-empty PR check rollup where every bucket is `pass` +4. Performs a direct squash merge with the app token -Two files co-exist: -- `.github/renovate.json5` - base org config (inherited from `projectbluefin/renovate-config`) -- `renovate.json` - repo-level overrides, including the `packageRules` automerge block - -The effective automerge rule in `renovate.json`: - -```json -{ - "packageRules": [ - { - "description": "Automerge chore dep updates (digest, pin, patch, minor) when CI passes", - "matchUpdateTypes": ["digest", "pin", "patch", "minor"], - "automerge": true, - "automergeType": "pr", - "automergeStrategy": "squash" - } - ] -} +Check the live branch-protection state with: + +```bash +gh api repos/projectbluefin/actions/branches/main/protection \ + --jq '.required_pull_request_reviews.bypass_pull_request_allowances' ``` -**What auto-merges:** SHA digest bumps, pin updates, patch and minor version bumps - when all CI checks pass. These are safe to auto-merge because they carry no behavior change. When handling the queue manually, the `clanker-queue` label authorizes an agent to merge only after confirming the PR is mergeable and every required check is green. +Expect exactly one bypass app allowance: MergeRaptor. No users or teams should +be present. + +### Config + +The repo-level Renovate config lives in `.github/renovate.json5`; there is no +root `renovate.json` in this repository. The checked-in config extends +`config:best-practices`, pins `baseBranchPatterns` to `main`, and automerges +pin/pinDigest updates plus GitHub Actions digest/pinDigest bumps. + +**What auto-merges:** pin/pinDigest updates, plus GitHub Actions digest/pinDigest bumps, when all CI checks pass. These are safe to auto-merge because they carry no behavior change. When handling the queue manually, the `clanker-queue` label authorizes an agent to merge only after confirming the PR is mergeable and every required check is green. **What never auto-merges:** Major version bumps and any PR that fails, has pending, or is missing required CI checks. A major bump may still be merged manually by an agent when it has `clanker-queue` and all required checks pass. +### Reusable auto-merge guardrails + +The reusable Renovate auto-merge workflow must validate **who enabled auto-merge**, not just that +auto-merge is enabled. Query `pullRequest.autoMergeRequest.enabledBy` and require it to be +`app/mergeraptor` or `renovate[bot]` in addition to the PR author check. This prevents a human +from manually enabling auto-merge on a Renovate-authored major update and accidentally bypassing +the intended review requirement. + +For final status checks, use `gh pr checks --json bucket,...` and merge **only** when the rollup is +non-empty and every `bucket` is `pass`. Treat `pending`, `fail`, `skipping`, and `cancel` as a +successful defer (`exit 0`) so the next `workflow_run` retry can re-evaluate, but still fail the +job on infrastructure/API errors that do not return a valid JSON check array. + **Consumer-validation exemption:** Renovate PRs (author login ending in `[bot]` or starting with `app/`) are automatically exempt from the consumer PR + CI run evidence requirement, even when they touch action files. See `docs/skills/consumer-validation.md`. ### Validation workflow diff --git a/docs/superpowers/plans/2026-08-04-renovate-automerge-review-exception.md b/docs/superpowers/plans/2026-08-04-renovate-automerge-review-exception.md new file mode 100644 index 00000000..ca5c15cf --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-renovate-automerge-review-exception.md @@ -0,0 +1,322 @@ +# CI-Gated Renovate Review Exception Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically merge eligible MergeRaptor Renovate updates after all PR checks pass without weakening human PR review requirements. + +**Architecture:** `main` keeps its CODEOWNERS and one-approval protection, with the MergeRaptor GitHub App as its only review-bypass actor. A local `workflow_run` caller passes that app's installation credentials to the reusable auto-merge workflow, which verifies Renovate eligibility and the complete PR check rollup before directly squash-merging. + +**Tech Stack:** GitHub branch-protection REST API, GitHub Actions reusable workflows, GitHub CLI, GitHub App installation tokens, actionlint. + +## Global Constraints + +- Preserve the one required approval and CODEOWNERS review requirement for every non-qualifying PR. +- Add only GitHub App ID `3069633` (`mergeraptor`) to the review-bypass allowance; do not add users or teams. +- Merge only `app/mergeraptor` or `renovate[bot]` PRs for which Renovate has enabled auto-merge. +- Merge only after every PR check has completed with `SUCCESS`; do not treat skipped, pending, cancelled, failed, or absent checks as passing. +- Use `MERGERAPTOR_APP_ID` and `MERGERAPTOR_PRIVATE_KEY`; do not add a PAT or a new secret. +- Surface failed `gh pr merge` commands as failures; do not replace them with warnings. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `.github/workflows/renovate-automerge.yml` | Local `workflow_run` caller that supplies the correct base branch and MergeRaptor app credentials. | +| `.github/workflows/reusable-renovate-automerge.yml` | Reusable qualification, full-check-rollup validation, and direct app-token merge logic. | +| `docs/skills/factory-operations.md` | Durable operational rule for the app-only bypass and CI-gated direct-merge behavior. | + +### Task 1: Add the app-only branch-protection bypass + +**Files:** +- Modify: GitHub `main` branch protection for `projectbluefin/actions` +- Test: GitHub `main` branch protection response + +**Interfaces:** +- Consumes: GitHub App ID `3069633`, current `GET /repos/projectbluefin/actions/branches/main/protection` response. +- Produces: `required_pull_request_reviews.bypass_pull_request_allowances.apps` containing exactly the MergeRaptor app. + +- [ ] **Step 1: Read and save the live branch-protection document** + +```bash +gh api repos/projectbluefin/actions/branches/main/protection > /tmp/actions-main-protection.json +jq '.required_pull_request_reviews' /tmp/actions-main-protection.json +``` + +Expected: one required approval, `require_code_owner_reviews: true`, and no existing bypass apps. + +- [ ] **Step 2: Build a replacement document that preserves every existing setting** + +```bash +jq ' + { + required_status_checks, + enforce_admins: .enforce_admins.enabled, + required_pull_request_reviews: { + dismiss_stale_reviews: .required_pull_request_reviews.dismiss_stale_reviews, + require_code_owner_reviews: .required_pull_request_reviews.require_code_owner_reviews, + require_last_push_approval: .required_pull_request_reviews.require_last_push_approval, + required_approving_review_count: .required_pull_request_reviews.required_approving_review_count, + bypass_pull_request_allowances: { + users: [], + teams: [], + apps: [3069633] + } + }, + restrictions, + required_linear_history: .required_linear_history.enabled, + allow_force_pushes: .allow_force_pushes.enabled, + allow_deletions: .allow_deletions.enabled, + block_creations: .block_creations.enabled, + required_conversation_resolution: .required_conversation_resolution.enabled, + lock_branch: .lock_branch.enabled, + allow_fork_syncing: .allow_fork_syncing.enabled + } +' /tmp/actions-main-protection.json > /tmp/actions-main-protection-with-mergeraptor.json +``` + +Do not change `required_status_checks`, `enforce_admins`, `restrictions`, or any other protection field from the fetched document. + +- [ ] **Step 3: Apply the branch-protection update** + +```bash +gh api --method PUT \ + repos/projectbluefin/actions/branches/main/protection \ + --input /tmp/actions-main-protection-with-mergeraptor.json +``` + +- [ ] **Step 4: Verify the live exception is narrow** + +```bash +gh api repos/projectbluefin/actions/branches/main/protection \ + --jq '.required_pull_request_reviews | { + required_approving_review_count, + require_code_owner_reviews, + bypass_pull_request_allowances + }' +``` + +Expected: one approval and CODEOWNERS remain enabled; the app list contains only `mergeraptor`, while user and team lists are empty. + +### Task 2: Add the repository-specific CI completion caller + +**Files:** +- Create: `.github/workflows/renovate-automerge.yml` +- Test: `.github/workflows/renovate-automerge.yml` via `actionlint` + +**Interfaces:** +- Consumes: `workflow_run.head_sha`, `MERGERAPTOR_APP_ID`, `MERGERAPTOR_PRIVATE_KEY`. +- Produces: a reusable workflow invocation with `head_sha` and `base_branch: main`. + +- [ ] **Step 1: Create the caller workflow** + +```yaml +name: Renovate Auto-merge + +on: + workflow_run: + workflows: + - Consumer Validation + - Dependency Review + - PAT ban — no new unapproved secrets + - actionlint + - Unit Tests + types: [completed] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.workflow_run.conclusion == 'success' + uses: ./.github/workflows/reusable-renovate-automerge.yml + with: + head_sha: ${{ github.event.workflow_run.head_sha }} + base_branch: main + secrets: + app_id: ${{ secrets.MERGERAPTOR_APP_ID }} + private_key: ${{ secrets.MERGERAPTOR_PRIVATE_KEY }} +``` + +Each listed CI workflow can trigger the caller. The reusable workflow performs the final all-check validation, so an early completion cannot merge a PR before sibling checks finish. + +- [ ] **Step 2: Run the workflow syntax check** + +```bash +actionlint .github/workflows/renovate-automerge.yml +``` + +Expected: exit status 0. + +- [ ] **Step 3: Commit the caller** + +```bash +git add .github/workflows/renovate-automerge.yml +git commit -m "ci(actions): trigger CI-gated Renovate automerge" \ + -m "Assisted-by: GPT-5.6 Terra via GitHub Copilot" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 3: Enforce qualification and complete-check validation before merging + +**Files:** +- Modify: `.github/workflows/reusable-renovate-automerge.yml` +- Test: `.github/workflows/reusable-renovate-automerge.yml` via `actionlint` + +**Interfaces:** +- Consumes: `head_sha`, `base_branch`, optional `app_id` and `private_key` workflow-call secrets. +- Produces: a direct squash merge only for an eligible, all-green Renovate PR; a nonzero exit when an attempted merge is denied. + +- [ ] **Step 1: Preserve app-token minting and make it the caller contract** + +Keep the `app_id` and `private_key` workflow-call secrets plus the +`actions/create-github-app-token` step. Set `GH_TOKEN` in every `gh` step to: + +```yaml +${{ steps.app-token.outputs.token || secrets.token || github.token }} +``` + +The app identity, not `github-actions[bot]`, must execute the direct merge. + +- [ ] **Step 2: Query the matching PR and require Renovate auto-merge eligibility** + +Replace the author-only selection with a GraphQL query that returns the PR +number, author login, and `autoMergeRequest`. Write an empty `pr_number` when +there is no open PR for `HEAD_SHA`, the author is not `app/mergeraptor` or +`renovate[bot]`, or `autoMergeRequest` is null. + +```bash +PR_NUMBER=$(gh api graphql -f query=' + query($owner: String!, $repo: String!, $head: String!, $base: String!) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN, baseRefName: $base) { + nodes { + number + headRefOid + author { login } + autoMergeRequest { enabledAt } + } + } + } + }' \ + -f owner="${GITHUB_REPOSITORY_OWNER}" \ + -f repo="${GITHUB_REPOSITORY#*/}" \ + -f head="$HEAD_SHA" \ + -f base="$BASE_BRANCH" \ + | jq -r --arg head "$HEAD_SHA" '.data.repository.pullRequests.nodes[] + | select(.headRefOid == $head) + | select(.author.login == "app/mergeraptor" or .author.login == "renovate[bot]") + | select(.autoMergeRequest != null) + | .number' | head -1) +``` + +- [ ] **Step 3: Reject any incomplete or non-successful check rollup** + +Before `gh pr merge`, inspect every check state: + +```bash +CHECKS=$(gh pr checks "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json name,state) +if [ "$(jq 'length' <<<"$CHECKS")" -eq 0 ] || + [ "$(jq '[.[] | select(.state != "SUCCESS")] | length' <<<"$CHECKS")" -ne 0 ]; then + echo "PR #$PR_NUMBER does not have a complete successful check rollup; skipping" + exit 0 +fi +``` + +This returns successfully only because a later successful `workflow_run` +event will retry it. It must not run `gh pr merge` in this state. + +- [ ] **Step 4: Make the direct merge failure visible** + +Use the existing direct squash merge without `--auto`, but remove the warning +fallback and success message after a failing command: + +```bash +gh pr merge "$PR_NUMBER" --squash --repo "$GITHUB_REPOSITORY" +echo "Merged PR #$PR_NUMBER" +``` + +- [ ] **Step 5: Run the workflow syntax check** + +```bash +actionlint .github/workflows/reusable-renovate-automerge.yml +``` + +Expected: exit status 0. + +- [ ] **Step 6: Commit the reusable workflow hardening** + +```bash +git add .github/workflows/reusable-renovate-automerge.yml +git commit -m "fix(ci): gate Renovate bypass merges on all checks" \ + -m "Assisted-by: GPT-5.6 Terra via GitHub Copilot" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 4: Document and verify the live behavior + +**Files:** +- Modify: `docs/skills/factory-operations.md` +- Test: `actionlint .github/workflows/renovate-automerge.yml .github/workflows/reusable-renovate-automerge.yml` + +**Interfaces:** +- Consumes: implemented caller and reusable workflow. +- Produces: an evergreen operator procedure for the app-only bypass. + +- [ ] **Step 1: Update the Renovate section with the exemption rule** + +State that `main` retains required reviews, MergeRaptor is the sole +branch-protection bypass app, and only the CI-gated reusable workflow may use +its installation token to merge Renovate-eligible PRs. Include the +operational check: + +```bash +gh api repos/projectbluefin/actions/branches/main/protection \ + --jq '.required_pull_request_reviews.bypass_pull_request_allowances' +``` + +- [ ] **Step 2: Validate both changed workflows** + +```bash +actionlint \ + .github/workflows/renovate-automerge.yml \ + .github/workflows/reusable-renovate-automerge.yml +``` + +Expected: exit status 0. + +- [ ] **Step 3: Open a draft consumer-validation PR and run CI** + +Open a draft PR in `projectbluefin/bluefin` targeting `testing`, using +`projectbluefin/actions@v1` as normal. Record the draft PR and successful run +URLs in the actions PR description, as required by +`docs/skills/consumer-validation.md`. + +- [ ] **Step 4: Exercise the exception with a qualifying Renovate PR** + +After the actions PR is merged and `v1` advances, confirm an existing or new +digest, pin, patch, or minor MergeRaptor PR merges after every PR check +succeeds without a human review. Confirm that a major Renovate update and a +human-authored PR remain blocked by the review rule. + +- [ ] **Step 5: Commit the documentation** + +```bash +git add docs/skills/factory-operations.md +git commit -m "docs(ci): document Renovate review bypass" \ + -m "Assisted-by: GPT-5.6 Terra via GitHub Copilot" \ + -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +## Plan Self-Review + +- **Spec coverage:** Task 1 implements the app-only bypass while preserving + human review requirements. Tasks 2 and 3 implement the local caller, + Renovate eligibility restriction, complete-check gate, and visible merge + failures. Task 4 documents and exercises both allowed and disallowed paths. +- **Placeholder scan:** No placeholders or deferred implementation decisions + remain. +- **Consistency:** The same MergeRaptor App ID, secrets, base branch, author + identities, and all-success requirement are used throughout. diff --git a/docs/superpowers/specs/2026-08-04-renovate-automerge-review-exception.md b/docs/superpowers/specs/2026-08-04-renovate-automerge-review-exception.md new file mode 100644 index 00000000..5bd6beb7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-renovate-automerge-review-exception.md @@ -0,0 +1,80 @@ +# CI-Gated Renovate Review Exception + +**Date:** 2026-08-04 +**Scope:** `main` branch protection and Renovate auto-merge workflow +**Affects:** `projectbluefin/actions` + +--- + +## Problem + +`main` requires one CODEOWNERS approval. Renovate enables GitHub auto-merge +for eligible digest, pin, patch, and minor updates, but those pull requests +remain blocked because no human approval exists. All checks can pass while the +dependency queue accumulates. + +Removing the review requirement would also remove review protection from +human-authored and non-Renovate pull requests. That is out of scope. + +## Design + +Add the MergeRaptor GitHub App, and no users or teams, to `main`'s +`bypass_pull_request_allowances.apps` branch-protection setting. + +Use the app's installation token in the direct-merge workflow. The workflow +must merge only when all of these conditions hold: + +1. The pull request author is `app/mergeraptor` or `renovate[bot]`. +2. Renovate has enabled auto-merge for that pull request. This preserves the + existing `renovate.json` allowlist of digest, pin, patch, and minor updates. +3. Every check in the pull request's check rollup has completed successfully. + +The workflow must report a failed merge command rather than converting it to a +success-shaped warning. It may exit successfully only when no qualifying pull +request exists for the completed workflow's head SHA. + +## Components + +### Branch protection + +Retain the existing one-approval and CODEOWNERS requirements. Add only the +MergeRaptor GitHub App as a bypass actor. This permits an app-token direct +merge after the workflow's checks, but does not allow ordinary users, +`github-actions[bot]`, or unrelated GitHub Apps to bypass review. + +GitHub documents this field as +`required_pull_request_reviews.bypass_pull_request_allowances.apps` in the +protected-branch API. + +### Auto-merge caller + +Add a caller for `reusable-renovate-automerge.yml` on completed PR CI +workflows. It passes the MergeRaptor app credentials already supported by the +reusable workflow and explicitly sets `base_branch: main`. + +The reusable workflow inspects the associated pull request before merging, +rather than treating a single completed workflow as proof that all required +checks passed. Repeated completion events are safe: after the first successful +merge, later events find no open matching pull request. + +## Error Handling + +- A pull request with a pending, failed, cancelled, skipped, or missing check + is not merged. +- An ineligible Renovate update (including a major version) is not merged + because Renovate did not enable auto-merge. +- A denied or failed merge is a workflow failure with the GitHub CLI error + retained in the log. + +## Verification + +1. Confirm the app appears as the sole bypass actor in `main` protection. +2. Open a qualifying Renovate dependency update and confirm it merges only + after all checks pass, without a human review. +3. Confirm a human-authored PR remains blocked without a CODEOWNERS approval. +4. Confirm a major Renovate update remains open without automatic merging. + +## Source + +GitHub protected-branch REST API documentation: +https://docs.github.com/en/rest/branches/branch-protection?apiVersion=2022-11-28#update-branch-protection diff --git a/tests/validate_reusable_renovate_automerge.sh b/tests/validate_reusable_renovate_automerge.sh new file mode 100755 index 00000000..ab643991 --- /dev/null +++ b/tests/validate_reusable_renovate_automerge.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRATCH_ROOT="${ROOT_DIR}/tests/.scratch/reusable-renovate-automerge.$$" +ORIGINAL_PATH="$PATH" + +cleanup() { + rm -rf "$SCRATCH_ROOT" +} +trap cleanup EXIT + +mkdir -p "$SCRATCH_ROOT" + +read -r -d '' FIND_PR_LOGIC <<'EOF' || true +PR_NUMBER=$(gh api graphql -f query=" + query(\$owner: String!, \$repo: String!, \$base: String!) { + repository(owner: \$owner, name: \$repo) { + pullRequests(first: 100, states: OPEN, baseRefName: \$base) { + nodes { + number + headRefOid + author { login } + autoMergeRequest { + enabledAt + enabledBy { login } + } + } + } + } + }" \ + -f owner="${GITHUB_REPOSITORY_OWNER}" \ + -f repo="${GITHUB_REPOSITORY#*/}" \ + -f base="$BASE_BRANCH" \ + | jq -r --arg head "$HEAD_SHA" '.data.repository.pullRequests.nodes[] + | select(.headRefOid == $head) + | select(.author.login == "app/mergeraptor" or .author.login == "renovate[bot]") + | select(.autoMergeRequest != null) + | select(.autoMergeRequest.enabledBy != null) + | select(.autoMergeRequest.enabledBy.login == "app/mergeraptor" or .autoMergeRequest.enabledBy.login == "renovate[bot]") + | .number' | head -1) + +if [ -z "$PR_NUMBER" ]; then + echo "No eligible Renovate/Mergeraptor PR found for SHA $HEAD_SHA on base $BASE_BRANCH — skipping" + echo "pr_number=" >> "$GITHUB_OUTPUT" +else + echo "Found eligible Renovate/Mergeraptor PR #$PR_NUMBER" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" +fi +EOF + +read -r -d '' MERGE_LOGIC <<'EOF' || true +set +e +CHECKS=$(gh pr checks "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json bucket,name) +CHECKS_STATUS=$? +set -e + +if [ "$CHECKS_STATUS" -ne 0 ] && [ "$CHECKS_STATUS" -ne 1 ] && [ "$CHECKS_STATUS" -ne 8 ]; then + exit "$CHECKS_STATUS" +fi + +if [ -z "$CHECKS" ] || ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$CHECKS"; then + echo "Failed to read PR check rollup for PR #$PR_NUMBER" >&2 + exit 1 +fi + +if [ "$(jq 'length' <<<"$CHECKS")" -eq 0 ] || + [ "$(jq '[.[] | select(.bucket != "pass")] | length' <<<"$CHECKS")" -ne 0 ]; then + echo "PR #$PR_NUMBER does not have a complete successful check rollup; skipping" + exit 0 +fi + +gh pr merge "$PR_NUMBER" --squash --repo "$GITHUB_REPOSITORY" +echo "Merged PR #$PR_NUMBER" +EOF + +fail() { + echo "not ok - $1" >&2 + exit 1 +} + +pass() { + echo "ok - $1" +} + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + [[ "$actual" == "$expected" ]] || fail "${message}: expected '${expected}', got '${actual}'" +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + [[ "$haystack" == *"$needle"* ]] || fail "${message}: missing '${needle}'" +} + +assert_file_empty() { + local path="$1" + local message="$2" + [[ ! -s "$path" ]] || fail "$message" +} + +assert_file_contains() { + local path="$1" + local needle="$2" + local message="$3" + grep -Fq "$needle" "$path" || fail "${message}: missing '${needle}'" +} + +setup_case() { + local case_name="$1" + CASE_DIR="${SCRATCH_ROOT}/${case_name}" + rm -rf "$CASE_DIR" + mkdir -p "${CASE_DIR}/bin" + + export PATH="${CASE_DIR}/bin:${ORIGINAL_PATH}" + export GITHUB_OUTPUT="${CASE_DIR}/github_output" + export GITHUB_REPOSITORY_OWNER="projectbluefin" + export GITHUB_REPOSITORY="projectbluefin/actions" + export HEAD_SHA="deadbeef" + export BASE_BRANCH="main" + export PR_NUMBER="101" + export MOCK_GRAPHQL_RESPONSE_FILE="${CASE_DIR}/graphql.json" + export MOCK_CHECKS_RESPONSE_FILE="${CASE_DIR}/checks.json" + export MOCK_MERGE_CALLS_FILE="${CASE_DIR}/merge_calls" + export MOCK_CHECKS_EXIT_STATUS="0" + export MOCK_MERGE_EXIT_STATUS="0" + + : > "$GITHUB_OUTPUT" + : > "$MOCK_GRAPHQL_RESPONSE_FILE" + : > "$MOCK_CHECKS_RESPONSE_FILE" + : > "$MOCK_MERGE_CALLS_FILE" + + cat > "${CASE_DIR}/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1" == "api" && "$2" == "graphql" ]]; then + cat "$MOCK_GRAPHQL_RESPONSE_FILE" + exit 0 +fi + +if [[ "$1" == "pr" && "$2" == "checks" ]]; then + cat "$MOCK_CHECKS_RESPONSE_FILE" + exit "$MOCK_CHECKS_EXIT_STATUS" +fi + +if [[ "$1" == "pr" && "$2" == "merge" ]]; then + printf '%s\n' "$*" >> "$MOCK_MERGE_CALLS_FILE" + exit "$MOCK_MERGE_EXIT_STATUS" +fi + +echo "unexpected gh invocation: $*" >&2 +exit 99 +EOF + chmod +x "${CASE_DIR}/bin/gh" +} + +run_snippet() { + local snippet="$1" + local stdout_file="${CASE_DIR}/stdout" + local stderr_file="${CASE_DIR}/stderr" + + set +e + bash -c "$snippet" >"$stdout_file" 2>"$stderr_file" + RUN_STATUS=$? + set -e + + RUN_STDOUT="$(<"$stdout_file")" + RUN_STDERR="$(<"$stderr_file")" +} + +test_authorized_renovate_pr_is_selected() { + setup_case "authorized-renovate-pr" + cat > "$MOCK_GRAPHQL_RESPONSE_FILE" <<'EOF' +{"data":{"repository":{"pullRequests":{"nodes":[{"number":17,"headRefOid":"deadbeef","author":{"login":"renovate[bot]"},"autoMergeRequest":{"enabledAt":"2026-08-06T18:00:00Z","enabledBy":{"login":"renovate[bot]"}}}]}}}} +EOF + + run_snippet "$FIND_PR_LOGIC" + + assert_eq "$RUN_STATUS" "0" "authorized find-pr status" + assert_contains "$RUN_STDOUT" "Found eligible Renovate/Mergeraptor PR #17" "authorized find-pr output" + assert_file_contains "$GITHUB_OUTPUT" "pr_number=17" "authorized find-pr output file" + pass "authorized Renovate auto-merge request stays eligible" +} + +test_manual_automerge_enablement_is_rejected() { + setup_case "manual-automerge-enablement" + cat > "$MOCK_GRAPHQL_RESPONSE_FILE" <<'EOF' +{"data":{"repository":{"pullRequests":{"nodes":[{"number":23,"headRefOid":"deadbeef","author":{"login":"app/mergeraptor"},"autoMergeRequest":{"enabledAt":"2026-08-06T18:00:00Z","enabledBy":{"login":"castrojo"}}}]}}}} +EOF + + run_snippet "$FIND_PR_LOGIC" + + assert_eq "$RUN_STATUS" "0" "manual enablement find-pr status" + assert_contains "$RUN_STDOUT" "No eligible Renovate/Mergeraptor PR found" "manual enablement output" + assert_file_contains "$GITHUB_OUTPUT" "pr_number=" "manual enablement output file" + pass "manual or unauthorized auto-merge enablement is rejected" +} + +run_non_pass_rollup_case() { + local case_name="$1" + local checks_json="$2" + local checks_status="$3" + local label="$4" + + setup_case "$case_name" + printf '%s\n' "$checks_json" > "$MOCK_CHECKS_RESPONSE_FILE" + export MOCK_CHECKS_EXIT_STATUS="$checks_status" + + run_snippet "$MERGE_LOGIC" + + assert_eq "$RUN_STATUS" "0" "${label} merge-step status" + assert_contains "$RUN_STDOUT" "does not have a complete successful check rollup; skipping" "${label} merge-step output" + assert_file_empty "$MOCK_MERGE_CALLS_FILE" "${label} unexpectedly attempted a merge" + pass "${label} check rollup defers without merging" +} + +test_successful_rollup_merges() { + setup_case "successful-rollup" + cat > "$MOCK_CHECKS_RESPONSE_FILE" <<'EOF' +[{"name":"Unit Tests","bucket":"pass"},{"name":"actionlint","bucket":"pass"}] +EOF + + run_snippet "$MERGE_LOGIC" + + assert_eq "$RUN_STATUS" "0" "successful merge-step status" + assert_contains "$RUN_STDOUT" "Merged PR #101" "successful merge-step output" + assert_file_contains "$MOCK_MERGE_CALLS_FILE" "pr merge 101 --squash --repo projectbluefin/actions" "successful merge command" + pass "fully passing check rollup merges the PR" +} + +test_authorized_renovate_pr_is_selected +test_manual_automerge_enablement_is_rejected +run_non_pass_rollup_case "empty-rollup" "[]" "0" "empty" +run_non_pass_rollup_case "failed-rollup" '[{"name":"Unit Tests","bucket":"fail"}]' "1" "failed" +run_non_pass_rollup_case "cancelled-rollup" '[{"name":"Unit Tests","bucket":"cancel"}]' "1" "cancelled" +run_non_pass_rollup_case "skipped-rollup" '[{"name":"Unit Tests","bucket":"skipping"}]' "1" "skipped" +run_non_pass_rollup_case "pending-rollup" '[{"name":"Unit Tests","bucket":"pending"}]' "8" "pending" +test_successful_rollup_merges