mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 07:03:11 +00:00
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:
parent
6714382a29
commit
70439c3b21
2 changed files with 158 additions and 0 deletions
156
.github/workflows/callable-required-review.yaml
vendored
Normal file
156
.github/workflows/callable-required-review.yaml
vendored
Normal 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(', ')}.`);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue