diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 150ac91e..1c861cca 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -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}" diff --git a/packages/mosaic/framework/tools/git/pr-metadata.sh b/packages/mosaic/framework/tools/git/pr-metadata.sh index 626ae7d2..e625cfda 100755 --- a/packages/mosaic/framework/tools/git/pr-metadata.sh +++ b/packages/mosaic/framework/tools/git/pr-metadata.sh @@ -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 '', diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh new file mode 100755 index 00000000..cf4b9465 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-pr-merge-fork-ci-status.sh @@ -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" diff --git a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh index 00ed3b4e..405011e5 100644 --- a/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh +++ b/packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh @@ -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 diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 6a125d2a..9548f844 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*",