Files
cozystack/.github/workflows/api-review-gate.yaml
Aleksei Sviridkin 361748ea6a fix(ci): build api-gate detector from base tip when merge base predates it
The API Review Gate builds cmd/api-gate from the PR's merge-base checkout
so a PR cannot neuter the detector by editing its own code. When the merge
base predates the commit that introduced the gate, cmd/api-gate is absent
from both the merge base and the equally old head, so the head fallback
also fails to build and the required check errors out before evaluating
any API content. This blocks every open PR whose merge base is older than
the gate, and neither a re-run nor an approving review clears it.

Add the base branch tip as an intermediate build source: prefer the
merge-base checkout, fall back to the base tip when the merge base lacks
the tool, and bootstrap from head only while the tool exists nowhere on
the base branch yet. The base tip is a protected branch, not PR-authored,
so it preserves the "analyze head as data, not code" property. The API
surface is still diffed against the merge base; only where the detector
binary is compiled changes. This unblocks the backlog without forcing
every affected PR to rebase.

The selection stays inline in the workflow rather than in a helper script:
a helper would be read from the untrusted PR head, which both reintroduces
the failure for heads that predate the helper and lets a PR steer the
selection away from the trusted build.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-03 18:39:21 +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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