§SOP-6 force-merge detector, hosted as a Gitea Actions composite action so it can be vendored into every org repo via a single `uses:` line instead of copy-pasting the bash. Source of truth for the audit script logic. Why composite vs reusable workflow: Gitea 1.22.6 doesn't support cross-repo `uses: org/repo/.gitea/workflows/X.yml@ref`. Cross-repo reusable workflows landed in go-gitea/gitea#32562 (1.26.0, Oct 2025) and have not been backported. Composite actions resolve via the actions-fetch path which works cross-repo against a public callee. Re-evaluate when operator host runs Gitea ≥ 1.26. Consumer workflow shape: on: pull_request_target: types: [closed] jobs: audit: if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - uses: molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main with: gitea-token: ${{ secrets.SOP_TIER_CHECK_TOKEN }} repo: ${{ github.repository }} pr-number: ${{ github.event.pull_request.number }} required-checks: | sop-tier-check / tier-check (pull_request) No actions/checkout step needed in the consumer — the audit script does pure API calls, never reads working tree. Removing checkout is also a small security win (PR head code never loaded). Verified end-to-end on internal#123 + molecule-core#150 with the inline copies (which this PR will replace via consumer-side stub PRs once merged). Tier: low.
119 lines
4.5 KiB
Bash
Executable File
119 lines
4.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# audit-force-merge — detect a §SOP-6 force-merge on a closed PR, emit
|
|
# `incident.force_merge` to stdout as structured JSON.
|
|
#
|
|
# Invoked by the `audit-force-merge` composite action defined alongside
|
|
# this script (action.yml). Caller workflows fire on
|
|
# `pull_request_target: closed` and gate on `merged == true`. See
|
|
# action.yml for the supported inputs.
|
|
#
|
|
# Vector's docker_logs source picks up runner stdout; the JSON gets
|
|
# shipped to Loki on molecule-canonical-obs, indexable by event_type.
|
|
# Query example:
|
|
#
|
|
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
|
|
#
|
|
# A force-merge is detected when a merged PR had at least one of the
|
|
# caller-declared required-status-check contexts in a state other than
|
|
# "success" at the PR HEAD. That's exactly what the Gitea
|
|
# force_merge:true API call lets through, so it's a faithful detector
|
|
# of the override path.
|
|
#
|
|
# Required env (set by the composite action via inputs):
|
|
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS
|
|
#
|
|
# REQUIRED_CHECKS is newline-separated context names. Declared by the
|
|
# caller (mirror of branch protection's status_check_contexts) rather
|
|
# than fetched from /branch_protections, which requires admin scope —
|
|
# the audit identity is intentionally read-only (least-privilege; see
|
|
# memory/feedback_least_privilege_via_workflow_env).
|
|
|
|
set -euo pipefail
|
|
|
|
: "${GITEA_TOKEN:?required}"
|
|
: "${GITEA_HOST:?required}"
|
|
: "${REPO:?required}"
|
|
: "${PR_NUMBER:?required}"
|
|
: "${REQUIRED_CHECKS:?required (newline-separated context names)}"
|
|
|
|
OWNER="${REPO%%/*}"
|
|
NAME="${REPO##*/}"
|
|
API="https://${GITEA_HOST}/api/v1"
|
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
|
|
|
# 1. Fetch the PR. If not merged, no-op.
|
|
PR=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
|
|
MERGED=$(echo "$PR" | jq -r '.merged // false')
|
|
if [ "$MERGED" != "true" ]; then
|
|
echo "::notice::PR #${PR_NUMBER} closed without merge — no audit emission."
|
|
exit 0
|
|
fi
|
|
|
|
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
|
|
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
|
|
TITLE=$(echo "$PR" | jq -r '.title // ""')
|
|
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
|
|
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
|
|
|
|
if [ -z "$MERGE_SHA" ]; then
|
|
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
|
|
exit 0
|
|
fi
|
|
|
|
# 2. Required status checks declared in the workflow env.
|
|
REQUIRED="$REQUIRED_CHECKS"
|
|
if [ -z "${REQUIRED//[[:space:]]/}" ]; then
|
|
echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable."
|
|
exit 0
|
|
fi
|
|
|
|
# 3. Status-check state at the PR HEAD (where checks ran). The merge
|
|
# commit doesn't get its own checks; we evaluate the PR's last
|
|
# commit, which is what branch protection compared against.
|
|
STATUS=$(curl -sS -H "$AUTH" \
|
|
"${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status")
|
|
declare -A CHECK_STATE
|
|
while IFS=$'\t' read -r ctx state; do
|
|
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
|
|
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
|
|
|
|
# 4. For each required check, was it green at merge? YAML block scalars
|
|
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
|
|
FAILED_CHECKS=()
|
|
while IFS= read -r req; do
|
|
trimmed="${req#"${req%%[![:space:]]*}"}" # ltrim
|
|
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rtrim
|
|
[ -z "$trimmed" ] && continue
|
|
state="${CHECK_STATE[$trimmed]:-missing}"
|
|
if [ "$state" != "success" ]; then
|
|
FAILED_CHECKS+=("${trimmed}=${state}")
|
|
fi
|
|
done <<< "$REQUIRED"
|
|
|
|
if [ "${#FAILED_CHECKS[@]}" -eq 0 ]; then
|
|
echo "::notice::PR #${PR_NUMBER} merged with all required checks green — not a force-merge."
|
|
exit 0
|
|
fi
|
|
|
|
# 5. Emit structured audit event.
|
|
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
|
|
|
|
# Print as a single-line JSON so Vector's parse_json transform can pick
|
|
# it up cleanly from docker_logs.
|
|
jq -nc \
|
|
--arg event_type "incident.force_merge" \
|
|
--arg ts "$NOW" \
|
|
--arg repo "$REPO" \
|
|
--argjson pr "$PR_NUMBER" \
|
|
--arg title "$TITLE" \
|
|
--arg base "$BASE_BRANCH" \
|
|
--arg merged_by "$MERGED_BY" \
|
|
--arg merge_sha "$MERGE_SHA" \
|
|
--argjson failed_checks "$FAILED_JSON" \
|
|
'{event_type: $event_type, ts: $ts, repo: $repo, pr: $pr, title: $title,
|
|
base_branch: $base, merged_by: $merged_by, merge_sha: $merge_sha,
|
|
failed_checks: $failed_checks}'
|
|
|
|
echo "::warning::FORCE-MERGE detected on PR #${PR_NUMBER} by ${MERGED_BY}: ${#FAILED_CHECKS[@]} required check(s) not green at merge time."
|