fix(git-tools): pr-merge queue guard reads CI status from the BASE repo (fork PRs, B1)
ci/woodpecker/pr/ci Pipeline was successful

For a fork PR, pr-metadata's headRepository names the fork (e.g.
stack-mos-dt-0/stack), and pr-merge passed it to ci-queue-wait via -R.
Woodpecker posts commit statuses on the BASE repository, so the guard read
statuses:null from the fork and every fork merge aborted with
state=malformed rc=3 (#1215, gate-merge-01 B1; standalone queue-wait
against the base repo returns terminal-success on the same sha). Same
family as d339e8fd.

- pr-metadata.sh: normalize baseRepository from Gitea base.repo.full_name
- pr-merge.sh: pass the BASE repo to the queue guard; head repo remains
  for head-sha identity; origin-repo fallback when metadata carries no
  base repository (correct for same-repo PRs)
- test-pr-merge-fork-ci-status.sh: fork-PR regression, CI-reachable via
  test:framework-shell beside its siblings; asserts the guard NEVER sees
  the fork and gets the base repo, head branch and exact sha
- test-pr-merge-queue-branch.sh: its old -R assertion pinned the fork
  repo, i.e. the bug; now asserts the base repo
This commit is contained in:
2026-08-20 14:57:03 -05:00
parent 6bd93a621d
commit 82ddca6521
5 changed files with 101 additions and 5 deletions
@@ -128,6 +128,7 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
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())')"
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
@@ -144,10 +145,19 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
fi
if [[ "$DRY_RUN" != true ]]; then
# CI statuses for a PR live on the BASE repo (Woodpecker posts there),
# even when the head branch lives in a fork. Reading status against the
# fork repo yields statuses:null -> malformed for every fork PR (#1215,
# gate-merge-01 B1). The head repo is used only for head-sha identity;
# when metadata carries no base repository, the origin repo is where CI
# posts and remains correct for same-repo PRs.
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
@@ -209,6 +209,10 @@ base_ref = first_non_empty(
data.get('base_ref'),
data.get('base_label'),
)
base_repo = first_non_empty(
nested(data, 'base', 'repo', 'full_name'),
nested(data, 'base', 'repo', 'name_with_owner'),
)
if not head_ref or not base_ref:
available = ', '.join(sorted(data.keys()))
@@ -229,6 +233,7 @@ normalized = {
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'baseRepository': base_repo,
'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)],
'milestone': nested(data, 'milestone', 'title') or '',
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# B1 (stack #1215, gate-merge-01): for a fork PR the merge queue guard must
# read CI status against the BASE repository. Woodpecker posts statuses on the
# base repo; pr-metadata's headRepository names the fork, and passing it to
# ci-queue-wait yields statuses:null -> state=malformed rc=3 on every fork PR.
#
# This fixture omits baseRepository entirely (the pre-B1 normalizer's shape),
# so the guard must fall back to the origin repo — and must NEVER see the fork.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-fork-ci-status}"
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":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
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
# A git repo with an origin remote, so the origin fallback resolves.
git init -q "$WORK_DIR/upstream"
git -C "$WORK_DIR/upstream" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
set +e
(
cd "$WORK_DIR/upstream"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 1215
) >/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 -- '-R stack-mos-dt-0/stack' "$CALL_LOG"; then
echo "FAIL: queue guard received the FORK repository for CI status (B1 regression)" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the base (origin) repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-B fix/b1-fork-branch' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha fedcba9876543210fedcba9876543210fedcba98' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge fork-PR CI-status repository regression passed"
@@ -15,7 +15,7 @@ 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"}'
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -52,8 +52,13 @@ if grep -q -- '-B main' "$CALL_LOG"; then
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
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard received the fork head repository (B1: statuses are posted on the base repo)" >&2
cat "$CALL_LOG" >&2
exit 1
fi