Files
cozystack/.github/workflows/api-review-gate.yaml
T
2026-09-24 16:55:04 +03:00

162 lines
7.0 KiB
YAML

name: API Review Gate
# Requires a review from a designated API owner (see API_OWNERS below) when a
# pull request makes a "sizeable" change to the Cozystack API surface: a new
# API group, a new resource, or a breaking change to an existing resource's
# schema. Detection is deterministic (cmd/api-gate); no LLM or network calls.
#
# The job runs on every PR (no path filter) so it can always report a status
# and therefore be marked as a required check in branch protection without
# leaving PRs that touch no API stuck in "pending". When nothing sizeable
# changed it passes immediately.
on:
pull_request:
types: [opened, synchronize, reopened]
# Re-evaluate when a review is submitted or dismissed so an approval by an
# API owner flips the check from failing to passing without a new push.
pull_request_review:
types: [submitted, dismissed]
concurrency:
group: api-review-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
api-review-gate:
name: Require API owner review for sizeable API changes
runs-on: ubuntu-22.04
env:
# Space-separated GitHub logins allowed to satisfy the gate. Edit here to
# change who can approve sizeable API changes.
API_OWNERS: "lllamnyp kvaps"
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Checkout head
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
# Do not leave a usable token in the repo's git config: the PR-authored
# build and api-gate steps below must not have access to it. The token
# is scoped to the approval step alone, which is the only step that
# calls the GitHub API.
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
cache: true
- name: Materialize base checkout at the merge base
run: |
set -euo pipefail
# Diff against the PR's actual merge base, not the base branch tip:
# otherwise unrelated commits landing on the base branch while the PR
# is open would invent or hide API findings.
git fetch --no-tags origin "${BASE_REF}"
merge_base="$(git merge-base "${HEAD_SHA}" "origin/${BASE_REF}")"
echo "Merge base: ${merge_base}"
git worktree add --detach "${RUNNER_TEMP}/base" "${merge_base}"
- name: Build api-gate
run: |
set -euo pipefail
# Build the detector from a trusted checkout so a PR cannot neuter the
# gate by editing its own detector code — the tool analyzes the head as
# *data*, not as code. This selection stays inline in the workflow (an
# API-owner-protected file that is always present) rather than in a
# helper script: a helper would be read from the untrusted PR head,
# which both breaks PRs whose head predates the helper and lets a PR
# steer the selection.
#
# Precedence: prefer the merge-base checkout; when the merge base
# predates the commit that introduced the gate (cmd/api-gate absent
# there and in the equally old head) fall back to the base branch tip,
# which is equally trusted (protected branch, not PR-authored) and
# current — this unblocks PRs whose merge base is older than the gate
# without forcing a rebase. Bootstrap from head only while the tool
# exists nowhere on the base branch yet; in that window CODEOWNERS on
# cmd/api-gate, internal/apigate, and this workflow keeps tampering
# behind an API-owner review.
if [ -d "${RUNNER_TEMP}/base/cmd/api-gate" ]; then
build_dir="${RUNNER_TEMP}/base"
echo "Building api-gate from the trusted merge-base checkout."
elif git cat-file -e "origin/${BASE_REF}:cmd/api-gate" 2>/dev/null; then
git worktree add --detach "${RUNNER_TEMP}/base-tip" "origin/${BASE_REF}"
build_dir="${RUNNER_TEMP}/base-tip"
echo "Building api-gate from the ${BASE_REF} tip; the merge base predates the gate."
else
build_dir="${GITHUB_WORKSPACE}"
echo "::warning::api-gate not present on the base branch yet; building from head (bootstrap). CODEOWNERS enforces API-owner review on the gate's own files."
fi
( cd "${build_dir}" && go build -o "${RUNNER_TEMP}/api-gate" ./cmd/api-gate )
- name: Detect sizeable API changes
id: detect
run: |
set -uo pipefail
set +e
"${RUNNER_TEMP}/api-gate" \
--base "${RUNNER_TEMP}/base" \
--head "${GITHUB_WORKSPACE}" \
--format markdown > "${RUNNER_TEMP}/report.md"
code=$?
set -e
echo "code=${code}" >> "$GITHUB_OUTPUT"
cat "${RUNNER_TEMP}/report.md"
case "${code}" in
0) echo "sizeable=false" >> "$GITHUB_OUTPUT" ;;
2) echo "sizeable=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::api-gate failed with exit code ${code}"; exit "${code}" ;;
esac
- name: Check for an API owner approval
if: steps.detect.outputs.sizeable == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
{
echo "## API Review Gate"
echo
cat "${RUNNER_TEMP}/report.md"
} >> "$GITHUB_STEP_SUMMARY"
# An APPROVED review by any API owner satisfies the gate, but only
# each reviewer's *latest* review *on the current head commit* counts:
# this drops approvals that a later commit invalidated, and approvals
# an owner subsequently superseded with CHANGES_REQUESTED. GitHub
# forbids self-approval, so a sizeable PR authored by an owner still
# needs the other owner.
approvals="$(
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate |
jq -rs --arg head "${HEAD_SHA}" '
add
| map(select(.commit_id == $head))
| sort_by(.submitted_at // "")
| group_by(.user.login)
| map(last)
| .[]
| select(.state == "APPROVED")
| .user.login
'
)"
for owner in ${API_OWNERS}; do
if grep -qxi "${owner}" <<<"${approvals}"; then
echo "Sizeable API change approved by @${owner}."
exit 0
fi
done
echo "::error::This PR makes a sizeable API change (new group, new resource, or breaking change) and needs an approving review from one of: ${API_OWNERS}. See the job summary for details."
exit 1