feat(actions): add audit-force-merge composite action #5

Merged
dev-lead merged 1 commits from feat/audit-force-merge-composite-action into main 2026-05-09 03:30:02 +00:00
2 changed files with 173 additions and 0 deletions
Showing only changes of commit 120b71c564 - Show all commits

View File

@ -0,0 +1,55 @@
name: 'Audit force-merge'
description: >-
§SOP-6 force-merge audit. Detects PRs merged with required-status-checks
not green at HEAD SHA and emits incident.force_merge JSON to runner
stdout. Vector docker_logs source ships the line to Loki on
molecule-canonical-obs (per reference_obs_stack_phase1).
# Why a composite action and not a reusable workflow:
# Gitea 1.22.6 does NOT support cross-repo `uses: org/repo/.gitea/
# workflows/X.yml@ref`. Cross-repo reusable workflows landed in
# go-gitea/gitea PR #32562 in Gitea 1.26.0 (Oct 2025). On 1.22.x the
# clone fails because act_runner mints a caller-scoped GITEA_TOKEN.
# Composite actions resolve via the actions-fetch path which works
# cross-repo on 1.22 against a public callee — that's us. Re-evaluate
# this choice when the operator host upgrades to Gitea ≥ 1.26.
inputs:
gitea-token:
description: >-
PAT for sop-tier-bot (or equivalent read-only audit identity).
Needs read:user,read:repository,read:issue scopes — admin scope
is intentionally NOT required.
required: true
gitea-host:
description: 'Gitea host'
required: false
default: 'git.moleculesai.app'
repo:
description: 'owner/name; typically ${{ github.repository }}'
required: true
pr-number:
description: 'PR number; typically ${{ github.event.pull_request.number }}'
required: true
required-checks:
description: >-
Newline-separated required-status-check context names. Mirror
of branch protection's status_check_contexts. Declared at the
caller because /branch_protections requires admin scope which
this audit identity intentionally does not hold (least-privilege).
When the required-check set changes, update both branch
protection AND this input.
required: true
runs:
using: composite
steps:
- name: Detect force-merge + emit audit event
shell: bash
env:
GITEA_TOKEN: ${{ inputs.gitea-token }}
GITEA_HOST: ${{ inputs.gitea-host }}
REPO: ${{ inputs.repo }}
PR_NUMBER: ${{ inputs.pr-number }}
REQUIRED_CHECKS: ${{ inputs.required-checks }}
run: bash "$GITHUB_ACTION_PATH/audit.sh"

View File

@ -0,0 +1,118 @@
#!/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."