Files
cozystack/.github/workflows/api-review-gate.yaml
Aleksei Sviridkin 5eee22fe28 ci(workflows): cache Go modules and fall back to direct in Go jobs
The unit and controller test job has no module cache and runs on an
ephemeral runner, so every run fetches every module from
proxy.golang.org, and one transient stream reset fails the job.

It now restores the module cache keyed on every go.sum in the tree and
prefetches the modules of every tracked go.mod through a bounded retry,
hack/go-mod-download.sh. The cache is written from main by the build
cache warmer under the same key, because a cache saved by a PR run is
visible to that PR only, while one saved on the default branch is
readable from every PR.

Every job that runs Go on the runner sets
GOPROXY=https://proxy.golang.org|direct so any proxy error falls back
to the origin. The release jobs get only that, not a cache.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-01 19:37:00 +03:00

164 lines
7.1 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 }}
# The pipe falls back to direct on any proxy error (hack/common-envs.mk).
GOPROXY: "https://proxy.golang.org|direct"
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