mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
22b9abf231
Three defects that the v6.4.3-rc.1 cut exposed, each with its contract note and proof: 1. scripts/release-preflight-worker.sh published the smoke stack on host port 7655 and probed it directly. pulse-dev also hosts the maintainer's dogfood Pulse instance on 127.0.0.1:7655 and a second instance on 17655, so the release smoke failed to start after every other stage had passed on the qualified head. The worker now honours PULSE_RELEASE_PREFLIGHT_E2E_PORT, otherwise takes the first candidate host port pair it verifies free and fails fast when none is, probes health and update status on that port, and hands Playwright the same base URL. 2. .github/workflows/publish-helm-chart.yml resolved the chart version before checking out the repository, butde41ea1883made that step call scripts/write_github_output.py, so every chart publish since failed with "can't open file". Run 33580123246 hit it after all other staging passed; the checkout now precedes the resolver. 3. docs/release-control/control_plane.json maps the 6.4.3 version prefix to release/v6.4. Run 33579042375, dispatched from main, failed inside the compiler dispatch because main advanced one minute after the pipeline pinned its source SHA; with pull requests landing every few minutes a candidate cannot hold its SHA between prepare and compile. release/v6.4 was created from main at the exact-SHA-qualified commit56e51e622eand carries these same fixes; v6.4.3-rc.1 published from it. The deployment-installability contract records the worker port and toolchain rules and the Helm checkout order; release_preflight_test.py and build_release_assets_test.go pin them.
343 lines
12 KiB
Bash
Executable File
343 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: release-preflight-worker.sh <40-character-source-sha> <rehearsal|release>
|
|
|
|
Runs the portable, high-cost release checks for an exact pushed commit in a
|
|
dedicated Linux amd64 checkout. The checkout and dependency caches persist
|
|
between runs; release credentials and signing keys are neither required nor
|
|
accepted.
|
|
EOF
|
|
}
|
|
|
|
SOURCE_SHA="${1:-}"
|
|
PROFILE="${2:-}"
|
|
|
|
if [ "$SOURCE_SHA" = "--help" ] || [ "$SOURCE_SHA" = "-h" ]; then
|
|
usage
|
|
exit 0
|
|
fi
|
|
if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Error: source SHA must be a lowercase 40-character Git commit id." >&2
|
|
exit 2
|
|
fi
|
|
if [ "$PROFILE" != "rehearsal" ] && [ "$PROFILE" != "release" ]; then
|
|
echo "Error: profile must be rehearsal or release." >&2
|
|
exit 2
|
|
fi
|
|
|
|
WORKER_ROOT="${PULSE_RELEASE_PREFLIGHT_ROOT:-/opt/pulse-release-worker}"
|
|
REPOSITORY_URL="${PULSE_RELEASE_PREFLIGHT_REPOSITORY_URL:-https://github.com/rcourtman/Pulse.git}"
|
|
REPOSITORY_DIR="${WORKER_ROOT}/repo"
|
|
CACHE_DIR="${WORKER_ROOT}/cache"
|
|
RECEIPT_DIR="${WORKER_ROOT}/receipts"
|
|
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-${SOURCE_SHA:0:12}-${PROFILE}"
|
|
RUN_DIR="${WORKER_ROOT}/tmp/${RUN_ID}"
|
|
# Go places test temp directories under GOTMPDIR, and several packages bind
|
|
# unix sockets under t.TempDir at the 108-byte sun_path limit. GitHub runners
|
|
# leave GOTMPDIR unset so those fixtures resolve under /tmp; the worker must
|
|
# match that layout exactly or the socket tests fail with "bind: invalid
|
|
# argument". Set PULSE_RELEASE_PREFLIGHT_GO_TMP_DIR only for a deliberately
|
|
# isolated, equally short directory.
|
|
GO_TMP_DIR="${PULSE_RELEASE_PREFLIGHT_GO_TMP_DIR:-}"
|
|
TIMINGS_FILE="${RUN_DIR}/timings.tsv"
|
|
TEST_DATA_DIR="${WORKER_ROOT}/test-data/${PROFILE}"
|
|
# The smoke stacks publish the Pulse server and agent ports on the host. A
|
|
# worker may also host long-running Pulse instances (pulse-dev keeps the
|
|
# dogfood instance on 7655 and a second instance on 17655), and a collision
|
|
# fails the smoke only after every other stage has passed. Honour an explicit
|
|
# override, otherwise take the first candidate pair with both ports free.
|
|
smoke_port_pair_is_free() {
|
|
local listeners
|
|
listeners="$(ss -Hltn 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -u)"
|
|
! printf '%s\n' "$listeners" | grep -qx "$1" && ! printf '%s\n' "$listeners" | grep -qx "$2"
|
|
}
|
|
if [ -n "${PULSE_RELEASE_PREFLIGHT_E2E_PORT:-}" ]; then
|
|
PULSE_E2E_PORT="$PULSE_RELEASE_PREFLIGHT_E2E_PORT"
|
|
PULSE_E2E_AGENT_PORT="${PULSE_RELEASE_PREFLIGHT_E2E_AGENT_PORT:-$((PULSE_E2E_PORT + 1))}"
|
|
else
|
|
PULSE_E2E_PORT=""
|
|
for candidate in 27655 28655 29655 31655; do
|
|
if smoke_port_pair_is_free "$candidate" "$((candidate + 1))"; then
|
|
PULSE_E2E_PORT="$candidate"
|
|
PULSE_E2E_AGENT_PORT="$((candidate + 1))"
|
|
break
|
|
fi
|
|
done
|
|
if [ -z "$PULSE_E2E_PORT" ]; then
|
|
echo "Error: no free host port pair for the smoke stack; set PULSE_RELEASE_PREFLIGHT_E2E_PORT." >&2
|
|
exit 3
|
|
fi
|
|
fi
|
|
export PULSE_E2E_PORT PULSE_E2E_AGENT_PORT
|
|
export PULSE_E2E_BASE_URL="http://localhost:${PULSE_E2E_PORT}"
|
|
|
|
# The worker is invoked over a non-login ssh shell, so /etc/profile.d is not
|
|
# sourced and an infra-managed mise toolchain (Node 24, Go) would be shadowed by
|
|
# any stale system binary on PATH. Activate mise shims when they are installed.
|
|
if [ -x "$HOME/.local/bin/mise" ]; then
|
|
eval "$("$HOME/.local/bin/mise" activate bash --shims)"
|
|
fi
|
|
|
|
for command_name in git go node npm docker curl flock timeout python3; do
|
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
|
echo "Error: required worker command is missing: ${command_name}" >&2
|
|
exit 3
|
|
fi
|
|
done
|
|
if ! docker compose version >/dev/null 2>&1; then
|
|
echo "Error: Docker Compose v2 is required on the worker." >&2
|
|
exit 3
|
|
fi
|
|
if [ "$(node -p "process.versions.node.split('.')[0]")" != "24" ]; then
|
|
echo "Error: the worker must use Node.js 24 to match the release workflows." >&2
|
|
exit 3
|
|
fi
|
|
|
|
mkdir -p \
|
|
"$CACHE_DIR/go-build" \
|
|
"$CACHE_DIR/go-mod" \
|
|
"$CACHE_DIR/npm" \
|
|
"$RECEIPT_DIR" \
|
|
"$RUN_DIR" \
|
|
"$(dirname "$TEST_DATA_DIR")"
|
|
|
|
exec 9>"${WORKER_ROOT}/worker.lock"
|
|
if ! flock -n 9; then
|
|
echo "Error: another release preflight is already using this worker." >&2
|
|
exit 5
|
|
fi
|
|
WALL_STARTED="$(date +%s)"
|
|
|
|
# A preflight compiles and tests only. Keep publication and signing authority
|
|
# out of the worker even if its login shell happens to define these names.
|
|
unset GH_TOKEN GITHUB_TOKEN PULSE_LICENSE_PRIVATE_KEY PULSE_UPDATE_SIGNING_KEY
|
|
|
|
export GOCACHE="$CACHE_DIR/go-build"
|
|
export GOMODCACHE="$CACHE_DIR/go-mod"
|
|
if [ -n "$GO_TMP_DIR" ]; then
|
|
mkdir -p "$GO_TMP_DIR"
|
|
export GOTMPDIR="$GO_TMP_DIR"
|
|
fi
|
|
export npm_config_cache="$CACHE_DIR/npm"
|
|
# Match the canonical workflow's isolated single-repository checkout. Tests
|
|
# that explicitly require private sibling repositories use this signal to
|
|
# apply their documented hosted-CI skip instead of inventing local evidence.
|
|
export GITHUB_ACTIONS=true
|
|
export CI=true
|
|
|
|
phase() {
|
|
local name="$1"
|
|
shift
|
|
local started finished
|
|
started="$(date +%s)"
|
|
echo
|
|
echo "==> ${name}"
|
|
"$@"
|
|
finished="$(date +%s)"
|
|
printf '%s\t%s\n' "$name" "$((finished - started))" >> "$TIMINGS_FILE"
|
|
echo "<== ${name}: $((finished - started))s"
|
|
}
|
|
|
|
cleanup() {
|
|
if [ -n "$GO_TMP_DIR" ]; then
|
|
rm -rf "$GO_TMP_DIR"
|
|
fi
|
|
if [ -d "$REPOSITORY_DIR/tests/integration" ]; then
|
|
(
|
|
cd "$REPOSITORY_DIR/tests/integration"
|
|
docker compose -f docker-compose.test.yml down -v >/dev/null 2>&1 || true
|
|
)
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if [ ! -d "$REPOSITORY_DIR/.git" ]; then
|
|
phase clone git clone "$REPOSITORY_URL" "$REPOSITORY_DIR"
|
|
fi
|
|
|
|
phase fetch git -C "$REPOSITORY_DIR" fetch --force --no-tags origin "$SOURCE_SHA"
|
|
FETCHED_SHA="$(git -C "$REPOSITORY_DIR" rev-parse 'FETCH_HEAD^{commit}')"
|
|
if [ "$FETCHED_SHA" != "$SOURCE_SHA" ]; then
|
|
echo "Error: origin returned ${FETCHED_SHA}, expected ${SOURCE_SHA}." >&2
|
|
exit 4
|
|
fi
|
|
git -C "$REPOSITORY_DIR" checkout --detach --force "$SOURCE_SHA"
|
|
git -C "$REPOSITORY_DIR" clean -ffdx
|
|
|
|
cd "$REPOSITORY_DIR"
|
|
EXPECTED_GO="$(awk '/^toolchain go/ { sub(/^toolchain /, ""); print; exit }' go.mod)"
|
|
ACTUAL_GO="$(go env GOVERSION)"
|
|
if [ -n "$EXPECTED_GO" ] && [ "$ACTUAL_GO" != "$EXPECTED_GO" ]; then
|
|
echo "Error: worker Go toolchain is ${ACTUAL_GO}; exact-SHA source requires ${EXPECTED_GO}." >&2
|
|
exit 3
|
|
fi
|
|
|
|
phase frontend-dependencies npm --prefix frontend-modern ci
|
|
phase frontend-build npm --prefix frontend-modern run build
|
|
|
|
rm -rf internal/api/frontend-modern
|
|
mkdir -p internal/api/frontend-modern
|
|
cp -R frontend-modern/dist internal/api/frontend-modern/
|
|
run_frontend_static_quality() {
|
|
phase frontend-lint npm --prefix frontend-modern run lint
|
|
phase frontend-headers npm --prefix frontend-modern run lint:headers
|
|
phase frontend-duplication npm --prefix frontend-modern run lint:cpd
|
|
phase frontend-types npm --prefix frontend-modern run type-check
|
|
}
|
|
|
|
run_frontend_tests() {
|
|
phase frontend-tests npm --prefix frontend-modern test
|
|
}
|
|
|
|
run_backend() {
|
|
rm -rf "$TEST_DATA_DIR"
|
|
mkdir -p "$TEST_DATA_DIR"
|
|
if [ "$PROFILE" = "rehearsal" ]; then
|
|
phase backend-serial env PULSE_DATA_DIR="$TEST_DATA_DIR" go test -p 1 ./...
|
|
else
|
|
phase backend-race-sharded ./scripts/run-release-backend-tests.sh \
|
|
--data-root "$TEST_DATA_DIR" \
|
|
--api-shards auto
|
|
fi
|
|
}
|
|
|
|
run_integration_prep() {
|
|
phase integration-dependencies npm --prefix tests/integration ci
|
|
PLAYWRIGHT_VERSION="$(node -p "require('./tests/integration/node_modules/@playwright/test/package.json').version")"
|
|
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
|
|
phase playwright-image docker pull "$PLAYWRIGHT_IMAGE"
|
|
phase mock-github-image docker build --tag pulse-mock-github:test tests/integration/mock-github-server
|
|
}
|
|
|
|
# Static frontend checks and integration preparation are bounded enough to
|
|
# overlap safely. Keep the full frontend and race-enabled backend test suites
|
|
# serial: both saturate this worker, and concurrent execution can turn healthy
|
|
# monitoring tests into load-induced release-gate failures.
|
|
parallel_pids=()
|
|
run_frontend_static_quality &
|
|
parallel_pids+=("$!")
|
|
run_integration_prep &
|
|
parallel_pids+=("$!")
|
|
|
|
remaining="${#parallel_pids[@]}"
|
|
while [ "$remaining" -gt 0 ]; do
|
|
if wait -n; then
|
|
remaining=$((remaining - 1))
|
|
else
|
|
status=$?
|
|
kill "${parallel_pids[@]}" >/dev/null 2>&1 || true
|
|
wait "${parallel_pids[@]}" >/dev/null 2>&1 || true
|
|
exit "$status"
|
|
fi
|
|
done
|
|
|
|
run_frontend_tests
|
|
run_backend
|
|
|
|
PLAYWRIGHT_VERSION="$(node -p "require('./tests/integration/node_modules/@playwright/test/package.json').version")"
|
|
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
|
|
|
|
VERSION="$(tr -d '\r\n' < VERSION)"
|
|
if [ "$PROFILE" = "rehearsal" ]; then
|
|
phase pulse-image docker build \
|
|
--build-arg "VERSION=${VERSION}" \
|
|
--platform linux/amd64 \
|
|
--target runtime \
|
|
--tag pulse:test \
|
|
.
|
|
else
|
|
phase pulse-image docker build \
|
|
--build-arg GO_BUILD_TAGS= \
|
|
--build-arg "VERSION=${VERSION}" \
|
|
--platform linux/amd64 \
|
|
--target e2e_runtime \
|
|
--tag pulse:test \
|
|
.
|
|
fi
|
|
run_playwright() {
|
|
docker run --rm \
|
|
--network host \
|
|
--ipc host \
|
|
--user "$(id -u):$(id -g)" \
|
|
--env CI=true \
|
|
--env HOME=/tmp \
|
|
--env "PULSE_E2E_DIAGNOSTIC=${PULSE_E2E_DIAGNOSTIC:-}" \
|
|
--env "PLAYWRIGHT_BASE_URL=${PULSE_E2E_BASE_URL}" \
|
|
--volume "$REPOSITORY_DIR/tests/integration:/work" \
|
|
--workdir /work \
|
|
"$PLAYWRIGHT_IMAGE" \
|
|
npx playwright test "$@"
|
|
}
|
|
|
|
run_rehearsal_smoke() {
|
|
cd tests/integration
|
|
export MOCK_CHECKSUM_ERROR=false
|
|
export MOCK_NETWORK_ERROR=false
|
|
export MOCK_RATE_LIMIT=false
|
|
export MOCK_STALE_RELEASE=false
|
|
export PULSE_E2E_DIAGNOSTIC=1
|
|
docker compose -f docker-compose.test.yml up -d --wait
|
|
timeout 60 sh -c 'until curl -fsS ${PULSE_E2E_BASE_URL}/api/health >/dev/null; do sleep 2; done'
|
|
run_playwright tests/00-diagnostic.spec.ts --project=chromium --reporter=list
|
|
local status
|
|
status="$(curl -s -o "$RUN_DIR/update-status.json" -w '%{http_code}' ${PULSE_E2E_BASE_URL}/api/updates/status || true)"
|
|
case "$status" in
|
|
200|401|403) ;;
|
|
*)
|
|
echo "Unexpected /api/updates/status response: ${status}" >&2
|
|
cat "$RUN_DIR/update-status.json" >&2 || true
|
|
return 1
|
|
;;
|
|
esac
|
|
docker compose -f docker-compose.test.yml down -v
|
|
}
|
|
|
|
run_release_smoke() {
|
|
cd tests/integration
|
|
export MOCK_CHECKSUM_ERROR=false
|
|
export MOCK_NETWORK_ERROR=false
|
|
export MOCK_RATE_LIMIT=false
|
|
export MOCK_STALE_RELEASE=false
|
|
export PULSE_E2E_BOOTSTRAP_TOKEN=0123456789abcdef0123456789abcdef0123456789abcdef
|
|
docker compose -f docker-compose.test.yml up -d
|
|
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q healthy; do sleep 2; done'
|
|
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q healthy; do sleep 2; done'
|
|
timeout 60 sh -c 'until curl -fsS ${PULSE_E2E_BASE_URL}/api/health >/dev/null; do sleep 2; done'
|
|
run_playwright tests/95-release-smoke.spec.ts --project=chromium --reporter=list
|
|
docker compose -f docker-compose.test.yml down -v
|
|
}
|
|
|
|
if [ "$PROFILE" = "rehearsal" ]; then
|
|
phase rehearsal-smoke run_rehearsal_smoke
|
|
else
|
|
phase release-smoke run_release_smoke
|
|
fi
|
|
|
|
FINISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
WALL_SECONDS="$(($(date +%s) - WALL_STARTED))"
|
|
TOTAL_SECONDS="$(awk -F '\t' '{ total += $2 } END { print total + 0 }' "$TIMINGS_FILE")"
|
|
RECEIPT_PATH="${RECEIPT_DIR}/${RUN_ID}.json"
|
|
{
|
|
printf '{\n'
|
|
printf ' "schema_version": 2,\n'
|
|
printf ' "source_sha": "%s",\n' "$SOURCE_SHA"
|
|
printf ' "profile": "%s",\n' "$PROFILE"
|
|
printf ' "architecture": "%s",\n' "$(uname -m)"
|
|
printf ' "finished_at": "%s",\n' "$FINISHED_AT"
|
|
printf ' "wall_seconds": %s,\n' "$WALL_SECONDS"
|
|
printf ' "total_phase_seconds": %s,\n' "$TOTAL_SECONDS"
|
|
printf ' "result": "success"\n'
|
|
printf '}\n'
|
|
} > "$RECEIPT_PATH"
|
|
|
|
echo
|
|
echo "Exact-SHA release preflight passed."
|
|
echo "Source SHA: ${SOURCE_SHA}"
|
|
echo "Profile: ${PROFILE}"
|
|
echo "Wall time: ${WALL_SECONDS}s"
|
|
echo "Phase time: ${TOTAL_SECONDS}s"
|
|
echo "Receipt: ${RECEIPT_PATH}"
|