From 120b71c5640101c533b791ccb4a5263a23a9485f Mon Sep 17 00:00:00 2001 From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)" Date: Fri, 8 May 2026 20:29:40 -0700 Subject: [PATCH] feat(actions): add audit-force-merge composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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. --- .gitea/actions/audit-force-merge/action.yml | 55 +++++++++ .gitea/actions/audit-force-merge/audit.sh | 118 ++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 .gitea/actions/audit-force-merge/action.yml create mode 100755 .gitea/actions/audit-force-merge/audit.sh diff --git a/.gitea/actions/audit-force-merge/action.yml b/.gitea/actions/audit-force-merge/action.yml new file mode 100644 index 0000000..1d22053 --- /dev/null +++ b/.gitea/actions/audit-force-merge/action.yml @@ -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" diff --git a/.gitea/actions/audit-force-merge/audit.sh b/.gitea/actions/audit-force-merge/audit.sh new file mode 100755 index 0000000..d6295ed --- /dev/null +++ b/.gitea/actions/audit-force-merge/audit.sh @@ -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."