mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 10:37:21 +09:00
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>
162 lines
7.0 KiB
YAML
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
|