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.
This commit is contained in:
Adam Moussa 2026-06-18 16:08:33 -04:00
parent 6714382a29
commit 70439c3b21
2 changed files with 158 additions and 0 deletions

View file

@ -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 "<caller_job_id> / <reusable_job_name>". 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(', ')}.`);

View file

@ -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