#!/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."