fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: coder-mos1 <[email protected]>
This commit was merged in pull request #1032.
This commit is contained in:
2026-08-01 18:55:26 +00:00
committed by mos-dt-0
parent f4fd5967fc
commit 58b971aba3
28 changed files with 1074 additions and 219 deletions
@@ -7,7 +7,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
BRANCH="main"
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: main)
-B, --branch BRANCH Branch head to inspect (default: current branch)
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
-t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
@@ -27,63 +31,65 @@ Options:
Examples:
$(basename "$0")
$(basename "$0") --purpose push -B main -t 600 -i 10
$(basename "$0") --purpose push -t 600 -i 10
EOF
}
# get_remote_host and get_gitea_token are provided by detect-platform.sh
get_state_from_status_json() {
python3 - <<'PY'
# Python source comes from -c so the provider payload remains on stdin.
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
python3 -c '
import json
import sys
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError("status payload is not an object")
except Exception:
print("unknown")
print("malformed")
raise SystemExit(0)
statuses = payload.get("statuses") or []
state = (payload.get("state") or "").lower()
raw_statuses = payload.get("statuses", [])
raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
raise SystemExit(0)
statuses = raw_statuses
state = raw_state.lower()
pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"}
success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
values = []
for item in statuses:
if not isinstance(item, dict):
continue
value = (item.get("status") or item.get("state") or "").lower()
if value:
values.append(value)
print("malformed")
raise SystemExit(0)
raw_value = item.get("status") or item.get("state")
if not isinstance(raw_value, str) or not raw_value:
print("malformed")
raise SystemExit(0)
values.append(raw_value.lower())
if not values and not state:
print("no-status")
elif any(v in pending_values for v in values):
if any(value in pending_values for value in values) or state in pending_values:
print("pending")
elif any(v in failure_values for v in values):
elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure")
elif values and all(v in success_values for v in values):
elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success")
elif not values:
print("no-status")
else:
print("unknown")
PY
'
}
print_pending_contexts() {
python3 - <<'PY'
python3 -c '
import json
import sys
@@ -104,17 +110,61 @@ for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
value = (item.get("status") or item.get("state") or "unknown").lower()
value = str(item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or ""
if value in pending_values:
found = True
if target:
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
suffix = f" ({target})" if target else ""
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
if not found:
print("[ci-queue-wait] no pending contexts")
'
}
record_cannot_assert() {
local reason="$1"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
return 70
fi
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, reason, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": "CANNOT_ASSERT",
"reason": reason,
"platform": platform,
"purpose": purpose,
"disposition": "hold" if purpose == "merge" else "degraded-pass",
"branch": branch,
"repo": repo,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
return 70
fi
if [[ "$PURPOSE" == "merge" ]]; then
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
return 75
fi
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
return 0
}
github_get_branch_head_sha() {
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
local work_root status_file checks_file
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
mkdir -p "$work_root" || return 1
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
rm -f "$status_file"
return 1
}
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
rm -f "$status_file" "$checks_file"
return 1
fi
python3 - "$status_file" "$checks_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
status_pages = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
check_pages = json.load(handle)
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
raise SystemExit(1)
# The statuses endpoint is newest-first and can contain retries for one context.
# Keep only the newest entry per context after flattening every page.
combined = []
seen_contexts = set()
for page in status_pages:
if not isinstance(page, list):
raise SystemExit(1)
for status in page:
if not isinstance(status, dict):
raise SystemExit(1)
context = status.get("context")
if not isinstance(context, str) or not context or context in seen_contexts:
continue
seen_contexts.add(context)
combined.append(status)
check_runs = []
reported_total = 0
for page in check_pages:
if not isinstance(page, dict):
raise SystemExit(1)
page_runs = page.get("check_runs") or []
total_count = page.get("total_count")
if not isinstance(page_runs, list) or not isinstance(total_count, int):
raise SystemExit(1)
reported_total = max(reported_total, total_count)
check_runs.extend(page_runs)
if len(check_runs) < reported_total:
raise SystemExit(1)
for run in check_runs:
if not isinstance(run, dict):
raise SystemExit(1)
status = run.get("status")
conclusion = run.get("conclusion")
if status != "completed":
value = "pending"
elif conclusion == "success":
value = "success"
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
value = "failure"
else:
value = "unknown"
combined.append({
"context": run.get("name") or "github-check",
"status": value,
"target_url": run.get("html_url") or run.get("details_url") or "",
})
json.dump({"state": "", "statuses": combined}, sys.stdout)
PY
local status=$?
rm -f "$status_file" "$checks_file"
return "$status"
}
gitea_get_branch_head_sha() {
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
BRANCH="$2"
shift 2
;;
-R|--repo)
TARGET_REPO="$2"
shift 2
;;
--sha)
HEAD_SHA="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
echo "Error: --repo must be OWNER/REPO." >&2
exit 1
fi
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
detect_platform > /dev/null
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
record_cannot_assert "repository-owner-unresolvable"
exit $?
fi
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
record_cannot_assert "repository-name-unresolvable"
exit $?
fi
if ! detect_platform > /dev/null; then
PLATFORM="${PLATFORM:-unknown}"
record_cannot_assert "unsupported-platform"
exit $?
fi
PLATFORM="${PLATFORM:-unknown}"
if [[ -n "$TARGET_REPO" ]]; then
OWNER="${TARGET_REPO%%/*}"
REPO="${TARGET_REPO##*/}"
fi
if [[ -z "$BRANCH" ]]; then
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
record_cannot_assert "current-branch-unresolvable"
exit $?
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
exit 1
record_cannot_assert "github-cli-unavailable"
exit $?
fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
exit 1
}
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
record_cannot_assert "remote-host-unresolvable"
exit $?
fi
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
record_cannot_assert "credential-unresolvable"
exit $?
fi
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
record_cannot_assert "unsupported-platform"
exit $?
fi
START_TS=$(date +%s)
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
exit 124
fi
if [[ "$PLATFORM" == "github" ]]; then
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
else
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
@@ -271,21 +459,24 @@ while true; do
printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC"
;;
terminal-success)
exit 0
;;
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
exit 1
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi
echo "[ci-queue-wait] no status contexts present; proceeding."
exit 0
exit 3
;;
terminal-success|terminal-failure|unknown)
# Queue guard only blocks on pending/running/queued states.
exit 0
terminal-failure|malformed|unknown)
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
*)
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
exit 0
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
esac
done
+42 -58
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
set -euo pipefail
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false
EXPECT_HEAD=""
usage() {
cat <<EOF
@@ -25,15 +25,14 @@ Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit "${1:-1}"
}
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true
shift
;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--dry-run)
DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift
;;
--expect-head)
EXPECT_HEAD="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
exit 1
fi
if [[ "$DRY_RUN" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$BASE_BRANCH" \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() {
local error_file="$1"
python3 - "$error_file" <<'PY'
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
error = handle.read()
known_empty_identity = re.search(
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
error,
flags=re.IGNORECASE | re.DOTALL,
)
raise SystemExit(0 if known_empty_identity else 1)
PY
}
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload='{"Do":"squash"}'
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
import json
import sys
head_sha, delete_branch = sys.argv[1:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
print(json.dumps(payload, separators=(",", ":")))
PY
)
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
@@ -202,7 +209,7 @@ fi
case "$PLATFORM" in
github)
cmd=(gh pr merge "$PR_NUMBER" --squash)
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
;;
@@ -211,32 +218,9 @@ case "$PLATFORM" in
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so exact-head merges use the authenticated API path.
merge_gitea_with_api "$HOST"
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -109,7 +109,7 @@ PY
detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner)
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
data.get('head_ref'),
head_ref,
)
head_sha = first_non_empty(
nested(data, 'head', 'sha'),
nested(data, 'head', 'id'),
data.get('head_sha'),
)
head_repo = first_non_empty(
nested(data, 'head', 'repo', 'full_name'),
nested(data, 'head', 'repo', 'name_with_owner'),
)
if not head_repo:
head_repo_owner = first_non_empty(
nested(data, 'head', 'repo', 'owner', 'login'),
nested(data, 'head', 'repo', 'owner', 'username'),
nested(data, 'head', 'repo', 'owner_name'),
)
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
if head_repo_owner and head_repo_name:
head_repo = f'{head_repo_owner}/{head_repo_name}'
base_ref = first_non_empty(
nested(data, 'base', 'ref'),
nested(data, 'base', 'name'),
@@ -207,6 +226,8 @@ normalized = {
'state': data.get('state'),
'author': nested(data, 'user', 'login') or '',
'headRefName': head_ref,
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
@@ -13,7 +13,7 @@
# Covers:
# (a) 404 branch-absent -> exit 0, "queue clear" message.
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
set -euo pipefail
@@ -62,7 +62,7 @@ case "$mode" in
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
500) code=500; body='{"message":"internal server error"}' ;;
no-status) code=200; body='{}' ;;
terminal-success) code=200; body='{"state":"success"}' ;;
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
*)
echo "curl stub: unknown mode=$mode" >&2
exit 2
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
)
}
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
fail=1
fi
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
set +e
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
status_c=$?
set -e
if [[ "$status_c" -eq 0 ]]; then
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
if [[ "$status_c" -ne 0 ]]; then
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" == *"queue clear"* ]]; then
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
echo "$out_c" >&2
fail=1
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b fix/github-checks
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
cat > "$STUB_DIR/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
endpoint=""
for arg in "$@"; do
[[ "$arg" == repos/* ]] && endpoint="$arg"
done
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
case "$endpoint" in
repos/acme/widgets/branches/fix/github-checks)
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
;;
repos/acme/widgets/commits/*/statuses?per_page=100)
printf '%s\n' '[[]]'
;;
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
case "${MOSAIC_GH_CHECK_MODE:?}" in
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
*) exit 2 ;;
esac
;;
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/gh"
run_guard() {
local mode="$1"
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
)
}
failures=0
assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc
set +e
output=$(run_guard "$mode" 2>&1)
rc=$?
set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
failures=$((failures + 1))
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
failures=$((failures + 1))
fi
if [[ "$output" != *"state=$expected_state"* ]]; then
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success
assert_case pending nonzero pending
assert_case failure nonzero terminal-failure
assert_case late-failure nonzero terminal-failure
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "GitHub check-runs regression failed ($failures assertions)" >&2
exit 1
fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
FEATURE_BRANCH="fix/rm-03-fixture"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
url=""
has_write_out=0
for arg in "$@"; do
case "$arg" in
-w) has_write_out=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_write_out" -eq 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
;;
*/status)
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
malformed) printf '%s' 'not-json' ;;
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
large-success)
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
;;
unreachable) exit 7 ;;
*) echo "unknown status mode" >&2; exit 2 ;;
esac
;;
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
run_guard() {
local status_mode="$1"
local audit_log="${2:-$AUDIT_LOG}"
shift 2 || true
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
if [[ "$status_mode" == "credential-unresolvable" ]]; then
export HOME="$WORK_DIR/empty-home"
mkdir -p "$HOME"
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
export MOSAIC_STUB_STATUS_MODE=success
else
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
)
}
failures=0
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
shift 4
set +e
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
rc=$?
set -e
case "$expected_rc" in
zero)
if [[ "$rc" -ne 0 ]]; then
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
failures=$((failures + 1))
fi
;;
nonzero)
if [[ "$rc" -eq 0 ]]; then
echo "FAIL $name: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
;;
not126)
if [[ "$rc" -eq 126 ]]; then
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
failures=$((failures + 1))
fi
;;
esac
if [[ "$output" != *"$required_text"* ]]; then
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/urls.log"
run_assertion success zero success 'state=terminal-success'
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
fi
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
set +e
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
merge_unreachable_rc=$?
set -e
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
failures=$((failures + 1))
fi
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A feature-branch push with no -B must inspect the checked-out feature branch.
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
failures=$((failures + 1))
fi
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
exact_sha=0123456789abcdef0123456789abcdef01234567
: > "$WORK_DIR/urls.log"
run_assertion exact-fork-head zero success 'state=terminal-success' \
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
failures=$((failures + 1))
fi
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
failures=$((failures + 1))
fi
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
audit_lines_before=$(wc -l < "$AUDIT_LOG")
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
set +e
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
unsupported_rc=$?
set -e
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
if [[ "$unsupported_rc" -ne 0 ]]; then
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
failures=$((failures + 1))
fi
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
echo "FAIL unsupported-platform: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A degraded pass is forbidden if the audit receipt cannot be written.
mkdir -p "$WORK_DIR/not-a-directory"
printf 'file' > "$WORK_DIR/not-a-directory/parent"
set +e
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
audit_failure_rc=$?
set -e
if [[ "$audit_failure_rc" -eq 0 ]]; then
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
@@ -1,5 +1,5 @@
#!/bin/bash
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
set -euo pipefail
@@ -79,15 +79,24 @@ emit_response() {
printf '200'
fi
}
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0
fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
echo "unexpected merge payload: $post_data" >&2
exit 96
fi
POST_DATA="$post_data" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["POST_DATA"])
assert payload == {
"Do": "squash",
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
}, payload
PY
emit_response '{"merged":true,"message":"mock merge complete"}'
exit 0
fi
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
echo "--- output ---" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
echo "--- mock log ---" >&2
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
if [[ "$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "$*" == *"pr merge"* ]]; then
echo 'tea network timeout' >&2
exit 2
fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
: > "$LOG_FILE"
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected arbitrary tea failure to remain blocking." >&2
exit 1
fi
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
fi
if ! grep -q 'tea network timeout' "$OUTPUT"; then
echo "Expected arbitrary tea error to be preserved in output." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
@@ -177,8 +154,8 @@ EOF
chmod +x "$MOCK_BIN/tea"
unset GITEA_LOGIN
: > "$LOG_FILE"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected the exact-head API path not to depend on a tea login." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
git remote set-url origin https://github.com/mosaicstack/stack.git
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected GitHub metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
export GITEA_LOGIN="git.mosaicstack.dev"
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected Gitea metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
exit 1
fi
echo "pr-merge.sh Gitea fallback regression passed"
echo "pr-merge.sh Gitea exact-head API regression passed"
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
# The commit whose CI was guarded must be the commit the provider atomically merges.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
SHA=0123456789abcdef0123456789abcdef01234567
make_fixture() {
local name="$1" remote="$2"
local root="$WORK_DIR/$name"
local tools="$root/tools/git"
mkdir -p "$tools" "$root/repo"
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
git -C "$root/repo" init -q
git -C "$root/repo" remote add origin "$remote"
cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH
cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$tools"/*.sh
}
rm -rf "$WORK_DIR"
make_fixture gitea https://git.example.test/acme/widgets.git
make_fixture github https://github.com/acme/widgets.git
cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/gitea.out" 2>&1
gitea_rc=$?
set -e
if [[ "$gitea_rc" -ne 0 ]]; then
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
cat "$WORK_DIR/gitea.out" >&2
exit 1
fi
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
assert payload.get("Do") == "squash", payload
assert payload.get("head_commit_id") == sys.argv[2], payload
PY
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
rm -f "$WORK_DIR/gitea-payload-stale.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
) >"$WORK_DIR/gitea-stale.out" 2>&1
stale_rc=$?
set -e
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
exit 1
fi
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
# --skip-queue-guard option must be rejected before any provider merge call.
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 99
SH
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
rm -f "$WORK_DIR/gitea-payload-bypass.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
) >"$WORK_DIR/gitea-bypass.out" 2>&1
bypass_rc=$?
set -e
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
exit 1
fi
# Dry-run is the only path that may omit the guard because it exits before the
# provider merge dispatch. Prove the exit and absence of a merge payload.
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
exit 1
fi
cat > "$WORK_DIR/github/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
SH
chmod +x "$WORK_DIR/github/gh"
(
cd "$WORK_DIR/github/repo"
export PATH="$WORK_DIR/github:$PATH"
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/github.out" 2>&1
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
cat "$WORK_DIR/github-call.log" >&2
exit 1
fi
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
set +e
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123
) >/dev/null 2>&1
rc=$?
set -e
if [[ "$rc" -ne 42 ]]; then
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
exit 1
fi
if [[ ! -s "$CALL_LOG" ]]; then
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
exit 1
fi
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-B main' "$CALL_LOG"; then
echo "FAIL: merge queue guard still received the main base branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge queue branch/repository/SHA regression passed"