From 70439c3b21dbe2c74c68b3195dbf956a5b229755 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 18 Jun 2026 16:08:33 -0400 Subject: [PATCH] Add multi-team approval gate reusable workflow CODEOWNERS cannot require approval from two distinct teams on the same files (multiple owners on a line is any-one/OR; separate lines are last-match-wins). shoc-backend and shoc-frontend-new need every PR to be approved by BOTH the external dev team and the internal-dev senior group, so a custom gate is the only way to express that AND. callable-required-review.yaml passes only when every team named in all-of-teams has at least one approving review. Team membership is an org read the default GITHUB_TOKEN cannot do, so the caller supplies a GitHub App token (Members: read + Pull requests: read); no write scopes are needed because the job's own pass/fail is the required status check. It re-evaluates on pull_request and pull_request_review, and ignores author self-reviews and non-decisive COMMENTED reviews. Actions are SHA-pinned (Dependabot github-actions ecosystem already covers this repo). The all-of-teams input is read via env, never interpolated into the script body, to avoid Actions script injection. --- .../workflows/callable-required-review.yaml | 156 ++++++++++++++++++ README.md | 2 + 2 files changed, 158 insertions(+) create mode 100644 .github/workflows/callable-required-review.yaml diff --git a/.github/workflows/callable-required-review.yaml b/.github/workflows/callable-required-review.yaml new file mode 100644 index 0000000..972b62a --- /dev/null +++ b/.github/workflows/callable-required-review.yaml @@ -0,0 +1,156 @@ +name: Required review + +# Reusable "require approval from every named team" gate for Sea-Haven-Industries repos. +# +# GitHub branch protection / CODEOWNERS cannot express a compound AND across two +# teams (multiple owners on one CODEOWNERS line = any-one/OR; separate lines = +# last-match-wins). This workflow fills that gap: given `all-of-teams`, it passes +# only when EVERY listed team has supplied at least one approving review on the PR. +# A caller wires it as a required status check so the PR cannot merge until each +# team has signed off. +# +# First consumer (planned): shoc-backend / shoc-frontend-new with +# `all-of-teams: "luby,internal-dev"` — one approval from the external dev team AND +# one from the internal-dev senior group on every PR. +# +# Token: team membership is an ORGANIZATION read, which the default GITHUB_TOKEN +# cannot do. The caller passes a GitHub App's credentials (`app_id` / +# `app_private_key`); the App needs `Organization → Members: read` and +# `Repository → Pull requests: read`. We use the App token for ALL API calls here +# (listing reviews + listing team members) so a single least-privilege token covers +# the job. No write scopes are needed — the job's own pass/fail IS the check. +# +# Caller contract (per-repo `required-review.yml`): +# on: +# pull_request: { types: [opened, reopened, synchronize] } +# pull_request_review: { types: [submitted, dismissed, edited] } +# permissions: { contents: read } +# jobs: +# required-review: +# uses: Sea-Haven-Industries/.github/.github/workflows/callable-required-review.yaml@main +# with: { all-of-teams: "luby,internal-dev" } +# secrets: +# app_id: ${{ secrets.REVIEW_POLICY_APP_ID }} +# app_private_key: ${{ secrets.REVIEW_POLICY_APP_PRIVATE_KEY }} +# +# Required-status-check naming is load-bearing: the org/repo ruleset matches the +# required context against the check-run name, which for a caller of a reusable +# workflow is " / ". With the caller job id +# `required-review` and the job below named `required-review`, the context is +# `required-review / required-review` — register THAT exact string in the ruleset. +# +# The `pull_request_review` trigger re-runs the gate as reviews arrive/dismiss, so +# the check flips red->green the moment both teams have approved (and back if an +# approval is dismissed). On `pull_request` (no approvals yet) it reports failing, +# which is the correct "waiting for required reviews" state. + +on: + workflow_call: + inputs: + all-of-teams: + description: "Comma-separated org team slugs; each must supply >=1 approving review." + required: true + type: string + secrets: + app_id: + description: "App ID of the org GitHub App (needs Organization: Members read + Pull requests read)." + required: true + app_private_key: + description: "PEM private key for that GitHub App." + required: true + +permissions: + contents: read + +# Hardcoded prefix (NOT github.workflow, which resolves to the caller's name and +# would collide across sibling jobs) + PR number, so concurrent runs for the same +# PR cancel but different PRs do not. +concurrency: + group: callable-required-review-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + required-review: + name: required-review + runs-on: ubuntu-latest + steps: + - name: Mint app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.app_id }} + private-key: ${{ secrets.app_private_key }} + owner: ${{ github.repository_owner }} + + - name: Enforce all-of-teams approvals + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + # Passed via env, never interpolated into the script body, to avoid + # GitHub Actions script injection. + ALL_OF_TEAMS: ${{ inputs.all-of-teams }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const teams = (process.env.ALL_OF_TEAMS || '') + .split(',').map((t) => t.trim()).filter(Boolean); + if (teams.length === 0) { + core.setFailed('all-of-teams input resolved to no team slugs.'); + return; + } + + const pr = context.payload.pull_request; + if (!pr) { + core.setFailed('No pull_request in event context; trigger on pull_request / pull_request_review.'); + return; + } + + const { owner, repo } = context.repo; + const org = owner; + const author = ((pr.user && pr.user.login) || '').toLowerCase(); + + // Reduce reviews to each reviewer's EFFECTIVE state. Only APPROVED / + // CHANGES_REQUESTED / DISMISSED change a reviewer's standing state + // (COMMENTED does not). listReviews returns ascending by submission, + // so the last decisive state wins. A PR author's own review never + // counts (GitHub does not record self-approval anyway). + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + const effective = new Map(); + for (const r of reviews) { + const login = ((r.user && r.user.login) || '').toLowerCase(); + if (!login || login === author) continue; + if (!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) continue; + effective.set(login, r.state); + } + const approvers = new Set( + [...effective].filter(([, state]) => state === 'APPROVED').map(([login]) => login) + ); + + const missing = []; + for (const team of teams) { + const members = await github.paginate(github.rest.teams.listMembersInOrg, { + org, + team_slug: team, + per_page: 100, + }); + const memberLogins = new Set(members.map((m) => m.login.toLowerCase())); + const satisfied = [...approvers].some((a) => memberLogins.has(a)); + core.info( + `Team ${team}: ${satisfied ? 'satisfied' : 'MISSING approval'} ` + + `(members=${memberLogins.size}, current approvers=${approvers.size})` + ); + if (!satisfied) missing.push(team); + } + + if (missing.length > 0) { + core.setFailed( + `Missing required approving review from team(s): ${missing.join(', ')}. ` + + `Every one of these teams must approve before merge: ${teams.join(', ')}.` + ); + return; + } + core.info(`All required team approvals present: ${teams.join(', ')}.`); diff --git a/README.md b/README.md index 13331c8..fa2757b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Organization-level GitHub configuration for Sea Haven Industries. **`.github/workflows/cd-cdk.yaml`** — Reusable CD workflow for CDK repos (TypeScript and Python). Runs `cdk deploy --all` with OIDC credentials. Supports optional Python setup for Python CDK repos and QEMU emulation for cross-platform Docker builds. +**`.github/workflows/callable-required-review.yaml`** — Reusable PR review gate that requires an approving review from **every** team named in its `all-of-teams` input (a compound AND that CODEOWNERS cannot express). Wired as a required status check, it blocks merge until each listed team has signed off. Resolves team membership with a caller-supplied GitHub App token (`app_id` / `app_private_key`; App needs Organization `Members: read` + repository `Pull requests: read`). Re-evaluates on `pull_request` and `pull_request_review`. Planned first use: `shoc-backend` / `shoc-frontend-new` with `all-of-teams: "luby,internal-dev"`. The required-status-check context for a caller job id `required-review` is `required-review / required-review`. + **`.github/workflows/compliance-audit.yaml`** — Scheduled weekly audit (Mondays 10am ET) that checks all org repos for compliance with Sea Haven conventions. Creates GitHub issues on repos with violations. Can also be triggered manually via `workflow_dispatch`. Uses the `claude-code-ci` GitHub App + `ANTHROPIC_API_KEY` (see Setup). ### PR Reviews