From 2033d9c19db840c253ce789e869f4060656c2b41 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sat, 6 Jun 2026 15:32:53 +0000 Subject: [PATCH 1/2] feat(ci): add audit-force-merge workflow + script for mcp-server (mcp-audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §SOP-6 force-merge detection to the mcp-server repo: - .gitea/workflows/audit-force-merge.yml — triggers on pull_request_target:closed - .gitea/scripts/audit-force-merge.sh — fail-closed detector with: * HTTP 200 verification for PR + status fetches * PR_SCHEMA_OK validates presence+type for all consumed fields * No // fallbacks * Statuses payload requires (.statuses | type) == array * REQUIRED_CHECKS_JSON branch-aware with fail-closed branch-key validation - 9 regression tests in .gitea/scripts/tests/test_audit_force_merge.sh Refs: mcp-audit, molecule-core#2366, internal#844. --- .gitea/scripts/audit-force-merge.sh | 132 ++++++++++++++++++ .../scripts/tests/test_audit_force_merge.sh | 72 ++++++++++ .gitea/workflows/audit-force-merge.yml | 28 ++++ 3 files changed, 232 insertions(+) create mode 100644 .gitea/scripts/audit-force-merge.sh create mode 100644 .gitea/scripts/tests/test_audit_force_merge.sh create mode 100644 .gitea/workflows/audit-force-merge.yml diff --git a/.gitea/scripts/audit-force-merge.sh b/.gitea/scripts/audit-force-merge.sh new file mode 100644 index 0000000..2c2ee6c --- /dev/null +++ b/.gitea/scripts/audit-force-merge.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# audit-force-merge — detect a §SOP-6 force-merge after PR close, emit +# `incident.force_merge` to stdout as structured JSON. +# +# Triggers on `pull_request_target: closed`. +# Required env: GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS + +set -euo pipefail + +: "${GITEA_TOKEN:?required}" +: "${GITEA_HOST:?required}" +: "${REPO:?required}" +: "${PR_NUMBER:?required}" +if [ -z "${REQUIRED_CHECKS_JSON:-}" ] && [ -z "${REQUIRED_CHECKS:-}" ]; then + echo "::error::Either REQUIRED_CHECKS_JSON or REQUIRED_CHECKS must be set" + exit 1 +fi + +OWNER="${REPO%%/*}" +NAME="${REPO##*/}" +API="https://${GITEA_HOST}/api/v1" +AUTH="Authorization: token ${GITEA_TOKEN}" + +# 1. Fetch the PR. Fail-closed: verify HTTP 200. +PR_TMP=$(mktemp) +PR_HTTP=$(curl -sS -o "$PR_TMP" -w '%{http_code}' -H "$AUTH" \ + "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}") +PR=$(cat "$PR_TMP") +rm -f "$PR_TMP" +if [ "$PR_HTTP" != "200" ]; then + echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${PR_HTTP} — cannot evaluate merge state." + exit 1 +fi + +PR_SCHEMA_OK=$(echo "$PR" | jq -r ' + (.merged | type == "boolean") and + (.merge_commit_sha | type == "string") and + (.merged_by | type == "object") and (.merged_by.login | type == "string") and + (.base | type == "object") and (.base.ref | type == "string") and + (.head | type == "object") and (.head.sha | type == "string") +') +if [ "$PR_SCHEMA_OK" != "true" ]; then + echo "::error::GET /pulls/${PR_NUMBER} returned HTTP 200 but one or more required fields are missing, null, or of wrong type — cannot evaluate force-merge." + exit 1 +fi + +MERGED=$(echo "$PR" | jq -r '.merged') +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') +MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login') +TITLE=$(echo "$PR" | jq -r '.title // ""') +BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref') +HEAD_SHA=$(echo "$PR" | jq -r '.head.sha') + +# 2. Required status checks — branch-aware JSON dict takes precedence. +if [ -n "${REQUIRED_CHECKS_JSON:-}" ]; then + _RC_JSON_OK=$(echo "$REQUIRED_CHECKS_JSON" | jq -r --arg branch "$BASE_BRANCH" ' + has($branch) and (.[$branch] | type == "array") + ') + if [ "$_RC_JSON_OK" != "true" ]; then + echo "::error::REQUIRED_CHECKS_JSON missing or non-array entry for branch '$BASE_BRANCH' — cannot evaluate required checks." + exit 1 + fi + REQUIRED=$(echo "$REQUIRED_CHECKS_JSON" | jq -r --arg branch "$BASE_BRANCH" '.[$branch] | .[]') +else + REQUIRED="$REQUIRED_CHECKS" +fi +if [ -z "${REQUIRED//[[:space:]]/}" ]; then + echo "::notice::REQUIRED_CHECKS empty for branch '$BASE_BRANCH' — force-merge not applicable." + exit 0 +fi + +# 3. Status-check state at the PR HEAD. Fail-closed: verify HTTP 200. +STATUS_TMP=$(mktemp) +STATUS_HTTP=$(curl -sS -o "$STATUS_TMP" -w '%{http_code}' -H "$AUTH" \ + "${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status") +STATUS=$(cat "$STATUS_TMP") +rm -f "$STATUS_TMP" +if [ "$STATUS_HTTP" != "200" ]; then + echo "::error::GET /commits/${HEAD_SHA}/status returned HTTP ${STATUS_HTTP} — cannot evaluate required checks." + exit 1 +fi +if ! echo "$STATUS" | jq -e '(.statuses | type) == "array"' >/dev/null; then + echo "::error::GET /commits/${HEAD_SHA}/status returned HTTP 200 but 'statuses' is missing or not an array — cannot evaluate required checks." + exit 1 +fi + +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? +FAILED_CHECKS=() +while IFS= read -r req; do + trimmed="${req#"${req%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [ -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 .) + +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." diff --git a/.gitea/scripts/tests/test_audit_force_merge.sh b/.gitea/scripts/tests/test_audit_force_merge.sh new file mode 100644 index 0000000..dd08d77 --- /dev/null +++ b/.gitea/scripts/tests/test_audit_force_merge.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# test_audit_force_merge.sh — regression lock for mcp-server audit-force-merge +# fail-closed behavior. Verifies schema validation paths via direct jq. + +set -euo pipefail + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "PASS: $*"; } + +[ -x "$(command -v jq)" ] || { echo "SKIP: jq not on PATH"; exit 0; } + +validate_pr_schema() { + jq -r ' + (.merged | type == "boolean") and + (.merge_commit_sha | type == "string") and + (.merged_by | type == "object") and (.merged_by.login | type == "string") and + (.base | type == "object") and (.base.ref | type == "string") and + (.head | type == "object") and (.head.sha | type == "string") + ' +} + +validate_statuses_type() { + jq -r '(.statuses | type) == "array"' +} + +validate_required_checks_json() { + local branch="$1" + local json="$2" + echo "$json" | jq -r --arg branch "$branch" 'has($branch) and (.[$branch] | type == "array")' +} + +# PR schema tests +T1=$(echo '{"merged":true,"merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T1" = "true" ] || fail "T1: valid payload should pass schema" +pass "T1: valid payload passes schema" + +T2=$(echo '{"merged":"true","merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T2" = "false" ] || fail "T2: merged as string should fail schema" +pass "T2: merged as string fails schema" + +T3=$(echo '{"merge_commit_sha":"abc","merged_by":{"login":"u"},"base":{"ref":"main"},"head":{"sha":"def"}}' | validate_pr_schema) +[ "$T3" = "false" ] || fail "T3: missing merged should fail schema" +pass "T3: missing merged fails schema" + +# Statuses type tests +T4=$(echo '{"statuses":[{"context":"c1","status":"success"}]}' | validate_statuses_type) +[ "$T4" = "true" ] || fail "T4: array statuses should pass" +pass "T4: array statuses passes" + +T5=$(echo '{"statuses":null}' | validate_statuses_type) +[ "$T5" = "false" ] || fail "T5: null statuses should fail" +pass "T5: null statuses fails" + +T6=$(echo '{}' | validate_statuses_type) +[ "$T6" = "false" ] || fail "T6: missing statuses should fail" +pass "T6: missing statuses fails" + +# REQUIRED_CHECKS_JSON tests +T7=$(validate_required_checks_json "main" '{"main":["CI"]}') +[ "$T7" = "true" ] || fail "T7: existing array branch should pass" +pass "T7: existing array branch passes" + +T8=$(validate_required_checks_json "staging" '{"main":["CI"]}') +[ "$T8" = "false" ] || fail "T8: missing branch should fail" +pass "T8: missing branch fails" + +T9=$(validate_required_checks_json "main" '{"main":"CI"}') +[ "$T9" = "false" ] || fail "T9: string branch entry should fail" +pass "T9: string branch entry fails" + +echo +echo "ALL AUDIT-FORCE-MERGE CHECKS PASSED" diff --git a/.gitea/workflows/audit-force-merge.yml b/.gitea/workflows/audit-force-merge.yml new file mode 100644 index 0000000..9691a7a --- /dev/null +++ b/.gitea/workflows/audit-force-merge.yml @@ -0,0 +1,28 @@ +name: audit-force-merge +# Detect a §SOP-6 force-merge after PR close and emit structured audit JSON. +# Runs on base branch (pull_request_target) so secrets are available. + +on: + pull_request_target: + types: [closed] + +jobs: + audit: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout base (for scripts) + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - name: Detect force-merge + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_HOST: ${{ github.server_url | replace('https://', '') }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REQUIRED_CHECKS: ${{ vars.REQUIRED_CHECKS }} + REQUIRED_CHECKS_JSON: ${{ vars.REQUIRED_CHECKS_JSON }} + run: | + bash .gitea/scripts/audit-force-merge.sh -- 2.52.0 From a6d5b62fee4f2b6b0edd3b4caea15605a8da4093 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Sat, 6 Jun 2026 15:50:09 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp-audit):=20address=20CR2=20blockers?= =?UTF-8?q?=20=E2=80=94=20restore=20merge-queue=20workflow=20+=20pin=20REQ?= =?UTF-8?q?UIRED=5FCHECKS=5FJSON=20explicitly=20(PR#49=20RC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore .gitea/workflows/gitea-merge-queue.yml (collateral deletion) - Replace vars.REQUIRED_CHECKS / vars.REQUIRED_CHECKS_JSON with inline branch-aware REQUIRED_CHECKS_JSON declaring CI / test (pull_request) for main, matching the live required status context Refs: PR#49 review agent-reviewer-cr2, agent-researcher --- .gitea/workflows/audit-force-merge.yml | 13 ++++- .gitea/workflows/gitea-merge-queue.yml | 80 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/gitea-merge-queue.yml diff --git a/.gitea/workflows/audit-force-merge.yml b/.gitea/workflows/audit-force-merge.yml index 9691a7a..f364291 100644 --- a/.gitea/workflows/audit-force-merge.yml +++ b/.gitea/workflows/audit-force-merge.yml @@ -22,7 +22,16 @@ jobs: GITEA_HOST: ${{ github.server_url | replace('https://', '') }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - REQUIRED_CHECKS: ${{ vars.REQUIRED_CHECKS }} - REQUIRED_CHECKS_JSON: ${{ vars.REQUIRED_CHECKS_JSON }} + # Required-status-check contexts to evaluate at merge time. + # Branch-aware JSON dict: keys are protected branch names, + # values are arrays of context names that branch protection + # requires for that branch. Mirror this against branch + # protection settings for each branch listed here. + REQUIRED_CHECKS_JSON: | + { + "main": [ + "CI / test (pull_request)" + ] + } run: | bash .gitea/scripts/audit-force-merge.sh diff --git a/.gitea/workflows/gitea-merge-queue.yml b/.gitea/workflows/gitea-merge-queue.yml new file mode 100644 index 0000000..96891d2 --- /dev/null +++ b/.gitea/workflows/gitea-merge-queue.yml @@ -0,0 +1,80 @@ +name: gitea-merge-queue + +# External serialized merge queue for Gitea 1.22.6. +# +# Gitea's `pull_auto_merge` table is not a real merge queue: it does not +# serialize green PRs against a freshly-tested latest main. This workflow runs +# the user-space queue bot, one PR per tick, using the non-bypass merge actor. +# +# Queue contract: +# - add label `merge-queue` to an open same-repo PR +# - bot updates stale PR heads with current main, then waits for CI +# - bot merges only when current main is green and required PR contexts pass +# - add `merge-queue-hold` to pause a queued PR without removing it + +on: + # Autonomous cron-extension (core#2355): runs every 5 min to process one + # queued PR per tick without occupying a runner continuously. + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gitea-merge-queue-${{ github.repository }} + cancel-in-progress: false + +jobs: + queue: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out target repo for BP + label context + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Check out molecule-core for queue script + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: molecule-ai/molecule-core + ref: main + path: molecule-core + + - name: Process one queued PR + env: + # AUTO_SYNC_TOKEN is the devops-engineer persona PAT. It is the + # non-bypass merge actor allowed by branch protection. + GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }} + GITEA_HOST: git.moleculesai.app + REPO: ${{ github.repository }} + WATCH_BRANCH: ${{ github.event.repository.default_branch }} + QUEUE_LABEL: merge-queue + HOLD_LABEL: merge-queue-hold + UPDATE_STYLE: merge + # Recognised official-reviewer set. A merge needs >= required_approvals + # DISTINCT genuine official approvals from these accounts on the + # CURRENT head sha (not stale/dismissed). The required_approvals count + # itself is read from branch protection at runtime. + REVIEWER_SET: agent-reviewer,agent-researcher,agent-reviewer-cr2 + # NOTE: REQUIRED_CONTEXTS is no longer the authoritative PR gate. The + # queue now reads the required status contexts from BRANCH PROTECTION + # (status_check_contexts) so non-required governance reds (qa-review, + # security-review, sop-tier, sop-checklist when not branch-required, + # E2E Chat, Staging SaaS, ci-arm64-advisory) cannot block a merge. + # If branch protection cannot be enumerated the queue HOLDS + # (fail-closed). REQUIRED_APPROVALS below is only a fallback used when + # branch protection does not specify required_approvals. + REQUIRED_APPROVALS: "2" + # Push-side required contexts. Checking CI / all-required (push) + # explicitly instead of the combined state avoids false-pause when + # non-blocking jobs (continue-on-error: true) have failed — those + # failures pollute combined state but do not gate merges. + # NOTE: the event-suffixed context name is intentional — branch protection + # MUST require `CI / all-required (pull_request)` (with suffix), NOT the + # bare `CI / all-required`. Gitea treats absent contexts as pending, not + # skipped; requiring the bare name silently blocks all merges (issue #1473). + PUSH_REQUIRED_CONTEXTS: CI / all-required (push) + run: python3 molecule-core/.gitea/scripts/gitea-merge-queue.py -- 2.52.0