forked from molecule-ai/molecule-core
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb5b0a182 | |||
| fabf45216d | |||
| a50cda1a85 | |||
| a526dabf04 | |||
| 4534e922c8 | |||
| 427d5b04ed | |||
| a93c4ce177 | |||
| b3041c13d3 | |||
| e1214ca0b4 |
@@ -0,0 +1,170 @@
|
||||
# sop-tier-check — canonical Gitea Actions workflow for §SOP-6 enforcement.
|
||||
#
|
||||
# Copy this file to `.gitea/workflows/sop-tier-check.yml` in any repo that
|
||||
# wants the §SOP-6 PR gate enforced. Pair with branch protection on the
|
||||
# protected branch:
|
||||
# required_status_checks: ["sop-tier-check"]
|
||||
# required_approving_reviews: 1
|
||||
# approving_review_teams: ["ceo", "managers", "engineers"]
|
||||
#
|
||||
# What it does:
|
||||
# 1. Reads the PR's `tier:*` label (low | medium | high). Fails if absent
|
||||
# or ambiguous.
|
||||
# 2. Reads every approving review on the PR.
|
||||
# 3. For each approver, queries Gitea team membership.
|
||||
# 4. Marks the check success only if at least one approver is in a team
|
||||
# whose tier-tag covers the PR's tier label, AND the approver is not
|
||||
# the author.
|
||||
#
|
||||
# Tier → eligible-team mapping (mirror of dev-sop §SOP-6):
|
||||
# tier:low → engineers, managers, ceo
|
||||
# tier:medium → managers, ceo
|
||||
# tier:high → ceo
|
||||
#
|
||||
# Author identity is excluded automatically; Gitea's review system already
|
||||
# rejects self-reviews, but this workflow re-checks defensively in case the
|
||||
# native rule is bypassed (admin override, branch-protection edit, etc.).
|
||||
#
|
||||
# Force-merge: Owners-team override remains available out-of-band via the
|
||||
# Gitea merge API; force-merge writes `incident.force_merge` to
|
||||
# structure_events per §Persistent structured logging gate (Phase 3).
|
||||
|
||||
name: sop-tier-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed, edited]
|
||||
|
||||
jobs:
|
||||
tier-check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Verify tier label + reviewer team membership
|
||||
env:
|
||||
# SOP_TIER_CHECK_TOKEN is the read-only `sop-tier-bot` PAT,
|
||||
# provisioned with read:org scope and added to ceo/managers/
|
||||
# engineers teams (a Gitea team-membership probe requires the
|
||||
# caller to be a member of the team being probed). The auto-
|
||||
# injected GITHUB_TOKEN's scope is repo-level only and cannot
|
||||
# query org team membership, hence the dedicated secret.
|
||||
# Falls back to GITHUB_TOKEN so the workflow at least starts and
|
||||
# surfaces a clear error when the secret is missing.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::Neither GITEA_TOKEN nor GITHUB_TOKEN is available. Add a GITEA_TOKEN secret with org-membership read scope to enable team-based approval gating."
|
||||
exit 1
|
||||
fi
|
||||
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-check the token resolves a user; surfaces token-scope problems
|
||||
# early instead of failing on a downstream call with no context.
|
||||
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
|
||||
echo "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
|
||||
echo "eligible_teams=$ELIGIBLE"
|
||||
|
||||
# Resolve team-name → team-id once. The /orgs/{org}/teams/{slug}/...
|
||||
# endpoints don't exist on Gitea 1.22; we have to use /teams/{id}.
|
||||
# Fail loud on missing team rather than treating it as "user not in
|
||||
# team" — that'd mask a misconfigured deployment.
|
||||
ORG_TEAMS_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/teams")
|
||||
echo "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||
echo "teams-list body (first 300 chars):"
|
||||
head -c 300 "$ORG_TEAMS_FILE"; echo
|
||||
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."
|
||||
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"
|
||||
echo "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
|
||||
echo "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
|
||||
echo "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}")
|
||||
echo " 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 (probe HTTP codes above)."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"
|
||||
@@ -589,12 +589,6 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
importStart := time.Now()
|
||||
emitOrgEvent(c.Request.Context(), "org.import.started", map[string]any{
|
||||
"name": body.Template.Name,
|
||||
"dir": body.Dir,
|
||||
"mode": body.Mode,
|
||||
})
|
||||
|
||||
var tmpl OrgTemplate
|
||||
var orgBaseDir string // base directory for files_dir resolution
|
||||
@@ -635,6 +629,19 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Emit started AFTER the YAML is loaded so payload.name carries the
|
||||
// resolved template name (was: empty when caller passed `dir` instead
|
||||
// of inline `template`). Pre-parse error paths above return without
|
||||
// emitting — semantically "we couldn't even start an import" — so
|
||||
// every started event is guaranteed a paired completed/failed below
|
||||
// (no orphan started rows in structure_events).
|
||||
importStart := time.Now()
|
||||
emitOrgEvent(c.Request.Context(), "org.import.started", map[string]any{
|
||||
"name": tmpl.Name,
|
||||
"dir": body.Dir,
|
||||
"mode": body.Mode,
|
||||
})
|
||||
|
||||
// Required-env preflight — refuses import when any required_env is
|
||||
// missing from global_secrets. No bypass: the prior `force: true`
|
||||
// escape hatch was removed (issue #2290) because it was the silent
|
||||
|
||||
@@ -46,7 +46,11 @@
|
||||
# 2. Fetch fresh token from platform API.
|
||||
# 3. If platform is unreachable, fall back to GITHUB_TOKEN / GH_TOKEN
|
||||
# env var (set at container start, valid for up to 60 min).
|
||||
# 4. If all fail, exit 1 so git falls through to the next credential
|
||||
# 4. If env var is unset, read static-token file at
|
||||
# ${CONFIGS_DIR}/.github-token. Operator escape hatch for incidents
|
||||
# when the platform endpoint is broken; not managed by the platform.
|
||||
# Never auto-cached, so API recovery is detected immediately.
|
||||
# 5. If all fail, exit 1 so git falls through to the next credential
|
||||
# helper in the chain (if any).
|
||||
#
|
||||
# # gh CLI integration
|
||||
@@ -197,7 +201,25 @@ _fetch_token_from_api() {
|
||||
echo "${token}"
|
||||
}
|
||||
|
||||
# _fetch_token — return a fresh token using cache > API > env fallback chain.
|
||||
# _read_static_token — output static-token-file contents if present and
|
||||
# non-empty. Returns 1 if file missing or empty. Never writes to cache —
|
||||
# operator escape hatch; we want API recovery to be detected on the very
|
||||
# next call without 50-min stale-cache stickiness on the workaround.
|
||||
_read_static_token() {
|
||||
local static_file="${CONFIGS_DIR}/.github-token"
|
||||
if [ ! -f "${static_file}" ]; then
|
||||
return 1
|
||||
fi
|
||||
local static_token
|
||||
static_token=$(cat "${static_file}" 2>/dev/null | tr -d '[:space:]')
|
||||
if [ -z "${static_token}" ]; then
|
||||
return 1
|
||||
fi
|
||||
echo "${static_token}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# _fetch_token — return a fresh token using cache > API > env > static fallback chain.
|
||||
# Outputs the raw token string on success; exits non-zero if all sources fail.
|
||||
_fetch_token() {
|
||||
# 1. Try cache first.
|
||||
@@ -222,6 +244,16 @@ _fetch_token() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Static-token file fallback — operator escape hatch for when
|
||||
# the platform API is broken AND no env var is set.
|
||||
# Manually written by infra; never auto-cached so API recovery
|
||||
# is detected on the very next call.
|
||||
static_token=$(_read_static_token 2>/dev/null) && {
|
||||
echo "[molecule-git-token-helper] API + env exhausted, using static-token file" >&2
|
||||
echo "${static_token}"
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "[molecule-git-token-helper] all token sources exhausted" >&2
|
||||
return 1
|
||||
}
|
||||
@@ -240,20 +272,38 @@ case "${ACTION}" in
|
||||
# No-op — the platform manages token lifecycle.
|
||||
;;
|
||||
_fetch_token)
|
||||
# Return raw token (cache > API > env fallback).
|
||||
# Return raw token (cache > API > env > static fallback).
|
||||
_fetch_token
|
||||
;;
|
||||
_refresh_gh)
|
||||
# Refresh cache AND update gh CLI auth in one shot.
|
||||
# Called by molecule-gh-token-refresh.sh background daemon.
|
||||
# Force-bypass cache to get a definitely fresh token.
|
||||
api_token=$(_fetch_token_from_api) || {
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed" >&2
|
||||
exit 1
|
||||
}
|
||||
_write_cache "${api_token}"
|
||||
# On API failure, fall through env → static-file like _fetch_token does,
|
||||
# but do NOT write the cache (those aren't API-issued tokens).
|
||||
api_token=$(_fetch_token_from_api) || api_token=""
|
||||
chosen_token=""
|
||||
if [ -n "${api_token}" ]; then
|
||||
_write_cache "${api_token}"
|
||||
chosen_token="${api_token}"
|
||||
else
|
||||
env_token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
|
||||
if [ -n "${env_token}" ]; then
|
||||
chosen_token="${env_token}"
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API failed, using env GITHUB_TOKEN" >&2
|
||||
else
|
||||
static_token=$(_read_static_token 2>/dev/null) && {
|
||||
chosen_token="${static_token}"
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API failed + env unset, using static-token file" >&2
|
||||
}
|
||||
fi
|
||||
if [ -z "${chosen_token}" ]; then
|
||||
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed and no fallback available" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Update gh CLI auth — gh auth login reads token from stdin.
|
||||
echo "${api_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || {
|
||||
echo "${chosen_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || {
|
||||
echo "[molecule-git-token-helper] _refresh_gh: gh auth login failed (non-fatal)" >&2
|
||||
}
|
||||
# Also update GH_TOKEN file for scripts that source it.
|
||||
@@ -265,7 +315,7 @@ case "${ACTION}" in
|
||||
# function); shadow with a uniquely-named global instead.
|
||||
_gh_prev_umask=$(umask)
|
||||
umask 077
|
||||
printf '%s' "${api_token}" > "${gh_token_file}.tmp"
|
||||
printf '%s' "${chosen_token}" > "${gh_token_file}.tmp"
|
||||
mv -f "${gh_token_file}.tmp" "${gh_token_file}"
|
||||
umask "${_gh_prev_umask}"
|
||||
unset _gh_prev_umask
|
||||
|
||||
Reference in New Issue
Block a user