All checks were successful
sop-tier-check / tier-check (pull_request) Successful in 1s
Mirrors the canonical refactor: workflow YAML shrinks (env+invocation), logic moves to .gitea/scripts/sop-tier-check.sh, debug echoes gated on SOP_DEBUG, checkout@v6 pinned to base.sha. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
5.3 KiB
Bash
Executable File
150 lines
5.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# sop-tier-check — verify a Gitea PR satisfies the §SOP-6 approval gate.
|
|
#
|
|
# Reads the PR's tier label, walks approving reviewers, and checks each
|
|
# approver's Gitea team membership against the tier's eligible-team set.
|
|
# Marks pass only when at least one non-author approver is in an eligible
|
|
# team.
|
|
#
|
|
# Invoked from `.gitea/workflows/sop-tier-check.yml`. The workflow sets
|
|
# the env vars below; this script does no IO outside of stdout/stderr +
|
|
# the Gitea API.
|
|
#
|
|
# Required env:
|
|
# GITEA_TOKEN — bot PAT with read:organization,read:user,
|
|
# read:issue,read:repository scopes
|
|
# GITEA_HOST — e.g. git.moleculesai.app
|
|
# REPO — owner/name (from github.repository)
|
|
# PR_NUMBER — int (from github.event.pull_request.number)
|
|
# PR_AUTHOR — login (from github.event.pull_request.user.login)
|
|
#
|
|
# Optional:
|
|
# SOP_DEBUG=1 — print per-API-call diagnostic lines (HTTP codes,
|
|
# raw response bodies). Default: off.
|
|
#
|
|
# Stale-status caveat: Gitea Actions does not always re-fire workflows
|
|
# on `labeled` / `pull_request_review:submitted` events. If the
|
|
# sop-tier-check status is stale (e.g. red after labels/approvals were
|
|
# added), push an empty commit to the PR branch to force a synchronize
|
|
# event, OR re-request reviews. Tracked: internal#46.
|
|
|
|
set -euo pipefail
|
|
|
|
debug() {
|
|
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
|
echo " [debug] $*" >&2
|
|
fi
|
|
}
|
|
|
|
# Validate env
|
|
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
|
: "${GITEA_HOST:?GITEA_HOST required}"
|
|
: "${REPO:?REPO required (owner/name)}"
|
|
: "${PR_NUMBER:?PR_NUMBER required}"
|
|
: "${PR_AUTHOR:?PR_AUTHOR required}"
|
|
|
|
OWNER="${REPO%%/*}"
|
|
NAME="${REPO##*/}"
|
|
API="https://${GITEA_HOST}/api/v1"
|
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
|
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
|
|
|
# Sanity: token resolves to a user
|
|
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
|
|
if [ -z "$WHOAMI" ]; then
|
|
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
|
|
exit 1
|
|
fi
|
|
echo "::notice::token resolves to user: $WHOAMI"
|
|
|
|
# 1. Read tier label
|
|
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
|
|
TIER=""
|
|
for L in $LABELS; do
|
|
case "$L" in
|
|
tier:low|tier:medium|tier:high)
|
|
if [ -n "$TIER" ]; then
|
|
echo "::error::Multiple tier labels: $TIER + $L. Apply exactly one."
|
|
exit 1
|
|
fi
|
|
TIER="$L"
|
|
;;
|
|
esac
|
|
done
|
|
if [ -z "$TIER" ]; then
|
|
echo "::error::PR has no tier:low|tier:medium|tier:high label. Apply one before merge."
|
|
exit 1
|
|
fi
|
|
debug "tier=$TIER"
|
|
|
|
# 2. Tier → eligible teams
|
|
case "$TIER" in
|
|
tier:low) ELIGIBLE="engineers managers ceo" ;;
|
|
tier:medium) ELIGIBLE="managers ceo" ;;
|
|
tier:high) ELIGIBLE="ceo" ;;
|
|
esac
|
|
debug "eligible_teams=$ELIGIBLE"
|
|
|
|
# Resolve team-name → team-id once. /orgs/{org}/teams/{slug}/... endpoints
|
|
# don't exist on Gitea 1.22; we have to use /teams/{id}.
|
|
ORG_TEAMS_FILE=$(mktemp)
|
|
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
|
|
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
|
"${API}/orgs/${OWNER}/teams")
|
|
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
|
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
|
echo " [debug] teams-list body (first 300 chars):" >&2
|
|
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
|
|
fi
|
|
if [ "$HTTP_CODE" != "200" ]; then
|
|
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope. Add a SOP_TIER_CHECK_TOKEN secret with read:organization scope at the org level."
|
|
exit 1
|
|
fi
|
|
declare -A TEAM_ID
|
|
for T in $ELIGIBLE; do
|
|
ID=$(jq -r --arg t "$T" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
|
|
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
|
|
VISIBLE=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
|
|
echo "::error::Team \"$T\" not found in org $OWNER. Teams visible: $VISIBLE"
|
|
exit 1
|
|
fi
|
|
TEAM_ID[$T]="$ID"
|
|
debug "team-id: $T → $ID"
|
|
done
|
|
|
|
# 3. Read approving reviewers
|
|
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
|
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
|
if [ -z "$APPROVERS" ]; then
|
|
echo "::error::No approving reviews. Tier $TIER requires approval from {$ELIGIBLE} (non-author)."
|
|
exit 1
|
|
fi
|
|
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
|
|
|
|
# 4. For each approver: check non-author + team membership (by id)
|
|
OK=""
|
|
for U in $APPROVERS; do
|
|
if [ "$U" = "$PR_AUTHOR" ]; then
|
|
debug "skip self-review by $U"
|
|
continue
|
|
fi
|
|
for T in $ELIGIBLE; do
|
|
ID="${TEAM_ID[$T]}"
|
|
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
|
"${API}/teams/${ID}/members/${U}")
|
|
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
|
|
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
|
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
|
OK="yes"
|
|
break
|
|
fi
|
|
done
|
|
[ -n "$OK" ] && break
|
|
done
|
|
|
|
if [ -z "$OK" ]; then
|
|
echo "::error::Tier $TIER requires approval from a non-author member of {$ELIGIBLE}. Got approvers: $APPROVERS — none of them satisfied team membership. Set SOP_DEBUG=1 to see per-probe HTTP codes."
|
|
exit 1
|
|
fi
|
|
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"
|