Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc67590a96 |
@@ -193,16 +193,19 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
await expect(
|
||||
runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
}),
|
||||
).rejects.toThrow('Gateway configuration failed');
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Gateway configuration failed'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -85,6 +85,10 @@ function Get-QueueState {
|
||||
$state = "$($Payload.state)".ToLowerInvariant()
|
||||
}
|
||||
|
||||
if ($pending -contains $state) { return "pending" }
|
||||
if ($failure -contains $state) { return "terminal-failure" }
|
||||
if ($success -contains $state) { return "terminal-success" }
|
||||
|
||||
$values = @()
|
||||
$statuses = @()
|
||||
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
|
||||
@@ -97,15 +101,7 @@ function Get-QueueState {
|
||||
if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
|
||||
}
|
||||
|
||||
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
||||
# state of "pending" alongside statuses:null / total_count:0 (a commit
|
||||
# with no CI at all), and honoring that aggregate would poll to the
|
||||
# timeout. With zero contexts there is nothing to wait on.
|
||||
if ($values.Count -eq 0) { return "no-status" }
|
||||
|
||||
if ($pending -contains $state) { return "pending" }
|
||||
if ($failure -contains $state) { return "terminal-failure" }
|
||||
if ($success -contains $state) { return "terminal-success" }
|
||||
if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" }
|
||||
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
|
||||
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
|
||||
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
|
||||
|
||||
@@ -52,11 +52,7 @@ except Exception:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
|
||||
# Gitea returns "statuses": null (not []) for a commit with zero status
|
||||
# contexts -- e.g. any repo with no CI configured. Treat null as empty.
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
if raw_statuses is None:
|
||||
raw_statuses = []
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
@@ -79,18 +75,14 @@ for item in statuses:
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
|
||||
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate
|
||||
# state of "pending" alongside total_count:0, and an aggregate with no
|
||||
# contexts behind it must not read as an in-flight pipeline (it would poll
|
||||
# to the timeout). With zero contexts there is nothing to wait on.
|
||||
if not values:
|
||||
print("no-status")
|
||||
elif any(value in pending_values for value in values) or state in pending_values:
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif all(value in success_values for value in values) and state in {"", "success"}:
|
||||
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")
|
||||
'
|
||||
@@ -473,18 +465,9 @@ while true; do
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
exit 3
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
fi
|
||||
# A head with zero status contexts has no CI queue to wait on.
|
||||
# For push, that is queue-clear (a repo with no CI must remain
|
||||
# pushable) -- mirroring record_cannot_assert's dispositions
|
||||
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
|
||||
# no-status there may just mean CI has not reported yet.
|
||||
if [[ "$PURPOSE" == "push" ]]; then
|
||||
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
terminal-failure|malformed|unknown)
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for ci-queue-wait.sh's zero-status-context handling.
|
||||
#
|
||||
# Gitea's combined-status endpoint returns, for a commit with NO status
|
||||
# contexts (e.g. a repo with no CI configured at all):
|
||||
# {"state":"pending","sha":"...","total_count":0,"statuses":null,...}
|
||||
# -- statuses is JSON null (not []), and state is a synthetic "pending"
|
||||
# even though nothing is running. Captured live from a Gitea 1.22 host,
|
||||
# 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline).
|
||||
#
|
||||
# Before the fix, `payload.get("statuses", [])` received null, failed the
|
||||
# isinstance(list) check, and the guard reported ASSERTED_NOT_READY
|
||||
# state=malformed (exit 3) -- blocking every push to a CI-less repo. Had
|
||||
# null been tolerated, the synthetic aggregate "pending" would instead
|
||||
# have polled to the timeout (exit 124). The fix must:
|
||||
# 1. treat statuses:null as an empty list, and
|
||||
# 2. classify zero status VALUES as "no-status" regardless of the
|
||||
# synthetic aggregate state, and
|
||||
# 3. treat no-status for --purpose push (without --require-status) as
|
||||
# queue-clear (exit 0) -- a repo with no CI has no queue to wait on;
|
||||
# this mirrors record_cannot_assert's disposition table
|
||||
# (push=degraded-pass, merge=hold).
|
||||
#
|
||||
# Covers:
|
||||
# (a) statuses:null + synthetic state:pending, purpose=push
|
||||
# -> exit 0, queue-clear/no-status message (THE live fault).
|
||||
# (b) same payload, purpose=merge -> still fail-closed (exit 3).
|
||||
# (c) same payload, push + --require-status -> still fail-closed (exit 3).
|
||||
# (d) statuses:[] + state:"" -> same as (a) (exit 0).
|
||||
# (e) a real pending context -> still polls (times out, 124),
|
||||
# proving the relaxation didn't swallow genuine pending states.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
|
||||
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" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh):
|
||||
# branch lookup answers 200 with a fixed SHA; the status endpoint's payload
|
||||
# is selected by MOSAIC_STUB_STATUS_MODE.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_w=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*/status)
|
||||
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
|
||||
;;
|
||||
*)
|
||||
echo "curl stub: unrecognized URL: $url" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$mode" in
|
||||
# Verbatim shape of the live Gitea response (repository object elided).
|
||||
gitea-null-statuses)
|
||||
body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}'
|
||||
;;
|
||||
empty-statuses)
|
||||
body='{"state":"","statuses":[]}'
|
||||
;;
|
||||
real-pending)
|
||||
body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}'
|
||||
;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '%s' "$body"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
run_ci_queue_wait() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
|
||||
# (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0.
|
||||
set +e
|
||||
out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1)
|
||||
status_a=$?
|
||||
set -e
|
||||
if [[ "$status_a" -ne 0 ]]; then
|
||||
echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2
|
||||
echo "$out_a" >&2
|
||||
fail=1
|
||||
elif [[ "$out_a" == *"malformed"* ]]; then
|
||||
echo "FAIL(a): null statuses must not be classified as malformed" >&2
|
||||
echo "$out_a" >&2
|
||||
fail=1
|
||||
elif [[ "$out_a" != *"no-status"* ]]; then
|
||||
echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2
|
||||
echo "$out_a" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (b) Same payload, purpose=merge -> hold, fail-closed exit 3.
|
||||
set +e
|
||||
out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1)
|
||||
status_b=$?
|
||||
set -e
|
||||
if [[ "$status_b" -ne 3 ]]; then
|
||||
echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2
|
||||
echo "$out_b" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) Same payload, push + --require-status -> strictness opt-in still fails.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -ne 3 ]]; then
|
||||
echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push.
|
||||
set +e
|
||||
out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1)
|
||||
status_d=$?
|
||||
set -e
|
||||
if [[ "$status_d" -ne 0 ]]; then
|
||||
echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2
|
||||
echo "$out_d" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (e) A REAL pending context must still block: polls to timeout, exit 124.
|
||||
set +e
|
||||
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1)
|
||||
status_e=$?
|
||||
set -e
|
||||
if [[ "$status_e" -ne 124 ]]; then
|
||||
echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2
|
||||
echo "$out_e" >&2
|
||||
fail=1
|
||||
elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then
|
||||
echo "FAIL(e): expected the pending context to be reported, got:" >&2
|
||||
echo "$out_e" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "ci-queue-wait no-status regression passed (5/5 cases)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -68,8 +68,6 @@ export async function quickStartPath(
|
||||
|
||||
// Gateway config + bootstrap
|
||||
if (!options.skipGateway) {
|
||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
|
||||
try {
|
||||
const configResult = await gatewayConfigStage(prompter, state, {
|
||||
host: options.gatewayHost ?? 'localhost',
|
||||
@@ -81,11 +79,9 @@ export async function quickStartPath(
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
if (headlessRun) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
const message = 'Gateway configuration failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
@@ -93,9 +89,9 @@ export async function quickStartPath(
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
|
||||
@@ -348,18 +348,21 @@ async function runFinishPath(
|
||||
providerType: state.providerType,
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
const message = 'Gateway configuration failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
@@ -420,9 +423,9 @@ async function runHeadlessPath(
|
||||
});
|
||||
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
const message = 'Gateway configuration failed in headless mode — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
@@ -430,9 +433,9 @@ async function runHeadlessPath(
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
@@ -477,18 +480,21 @@ async function runKeepPath(
|
||||
skipInstall: options.skipGatewayNpmInstall,
|
||||
});
|
||||
|
||||
if (configResult.ready && configResult.host && configResult.port) {
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||
const message = 'Gateway configuration failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||
host: configResult.host,
|
||||
port: configResult.port,
|
||||
});
|
||||
if (!bootstrapResult.completed) {
|
||||
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||
prompter.warn(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
finalizeResult.showSummary();
|
||||
} catch (err) {
|
||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
|
||||
+4
-1
@@ -762,9 +762,12 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
||||
if "$MOSAIC_CMD" wizard; then
|
||||
ok "Wizard complete."
|
||||
else
|
||||
warn "Wizard exited non-zero."
|
||||
fail "Wizard failed; installation is incomplete."
|
||||
echo " Completed: framework and CLI installation"
|
||||
echo " Failed: gateway configuration or admin bootstrap"
|
||||
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
||||
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user