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