diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 451c565e4..8bd9847e2 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -308,6 +308,7 @@ jobs: MOCK_NETWORK_ERROR: "false" MOCK_RATE_LIMIT: "false" MOCK_STALE_RELEASE: "false" + PULSE_E2E_DIAGNOSTIC: "1" run: | docker compose -f docker-compose.test.yml up -d --wait diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 0a9f93881..f153594a0 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -9609,7 +9609,21 @@ ], "coverage_gaps": [], "candidate_lanes": [], - "work_claims": [], + "work_claims": [ + { + "id": "release-v6-2-2-rc-1-lane-l1", + "agent_id": "release-v6.2.2-rc.1", + "summary": "Accelerate exact-SHA release qualification with the remote amd64 builder before v6.2.2-rc.1", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-08-12T14:53:53Z", + "heartbeat_at": "2026-08-12T14:55:19Z", + "expires_at": "2026-08-12T18:55:19Z", + "work_item": { + "kind": "lane", + "id": "L1" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index efb53f995..95f7110a6 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -470,7 +470,22 @@ upgrade, update, release, or artifact-selection behavior. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` + Operators may configure an external Linux amd64 worker for the two trigger + helpers. `scripts/run-release-preflight.sh` must resolve an immutable pushed + commit, stream the worker implementation stored in that commit over SSH, + and select either the rehearsal or release profile. + `scripts/release-preflight-worker.sh` must use a dedicated checkout and + persistent dependency/build caches while running the portable frontend, + backend, image-build, and browser-smoke checks that historically caused + costly hosted-workflow restarts. It must not receive signing keys, package + publication credentials, or any other release authority. An unconfigured + worker remains an operator acceleration choice, not a new canonical release + gate; the typed release gates and the self-contained GitHub release workflow + remain authoritative. + The rehearsal diagnostic spec is opt-in by design, so both the hosted + rehearsal and its worker profile must set `PULSE_E2E_DIAGNOSTIC=1`; invoking + that spec while leaving it skipped is not browser proof. Normal releases are single-build promotions. The exact pushed SHA must produce one release candidate with the policy-required native signing lanes through `.github/workflows/build-release-candidate.yml` while independent @@ -792,7 +807,7 @@ upgrade, update, release, or artifact-selection behavior. the helper must wait for the Organization selector to hold the requested org before a scenario navigates onward, so an interrupted org-list bootstrap cannot fall back to `default` and mask the scoped UI under test. -6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` +6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, stable-promotion rehearsal summaries, or the optional exact-SHA external-worker acceleration through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level `docs/RELEASE_NOTES.md` index must continue to point at the current shipped diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index e30454d8b..742e94149 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4209,6 +4209,7 @@ "scripts/lib/hot-dev-auth.sh", "scripts/lib/hot-dev-runtime.sh", "scripts/pulse-auto-update.sh", + "scripts/release-preflight-worker.sh", "scripts/release_asset_common.sh", "scripts/release_candidate_manifest.py", "scripts/release_control/customer_promotion_lease.sh", @@ -4219,6 +4220,7 @@ "scripts/release_control/resolve_release_promotion.py", "scripts/release_control/validate_artifact_release_line.py", "scripts/release_ldflags.sh", + "scripts/run-release-preflight.sh", "scripts/run_cloud_public_signup_smoke.sh", "scripts/run_demo_public_browser_smoke.sh", "scripts/run_hosted_staging_smoke.sh", @@ -4352,6 +4354,7 @@ "pulse-pro:scripts/validate_paid_runtime_distribution.py", "scripts/check-workflow-dispatch-inputs.py", "scripts/generate-release-notes.sh", + "scripts/release-preflight-worker.sh", "scripts/release_control/customer_promotion_lease.sh", "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", "scripts/release_control/mobile_release_gate.py", @@ -4360,6 +4363,7 @@ "scripts/release_control/render_release_body.py", "scripts/release_control/resolve_release_promotion.py", "scripts/release_control/validate_artifact_release_line.py", + "scripts/run-release-preflight.sh", "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "scripts/trigger-stable-patch.sh", @@ -4372,6 +4376,7 @@ "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", diff --git a/scripts/release-preflight-worker.sh b/scripts/release-preflight-worker.sh new file mode 100755 index 000000000..5b0241c0c --- /dev/null +++ b/scripts/release-preflight-worker.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: release-preflight-worker.sh <40-character-source-sha> + +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}" +TIMINGS_FILE="${RUN_DIR}/timings.tsv" +TEST_DATA_DIR="${WORKER_ROOT}/test-data/${PROFILE}" + +for command_name in git go node npm docker curl flock timeout; 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]")" != "20" ]; then + echo "Error: the worker must use Node.js 20 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 + +# 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" +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 [ -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 go/, ""); 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-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 +phase frontend-tests npm --prefix frontend-modern test +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/ +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 env PULSE_DATA_DIR="$TEST_DATA_DIR" make test +fi + +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" + +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 +phase mock-github-image docker build --tag pulse-mock-github:test tests/integration/mock-github-server + +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:-}" \ + --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 http://localhost:7655/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}' http://localhost:7655/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 http://localhost:7655/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)" +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": 1,\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 ' "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 "Phase time: ${TOTAL_SECONDS}s" +echo "Receipt: ${RECEIPT_PATH}" diff --git a/scripts/release_control/internal/release_preflight_test.py b/scripts/release_control/internal/release_preflight_test.py new file mode 100755 index 000000000..0f0a90574 --- /dev/null +++ b/scripts/release_control/internal/release_preflight_test.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import pathlib +import subprocess +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] + + +class ReleasePreflightTest(unittest.TestCase): + def run_script( + self, *args: str, env: dict[str, str] | None = None + ) -> subprocess.CompletedProcess[str]: + command_env = os.environ.copy() + if env: + command_env.update(env) + return subprocess.run( + [str(ROOT / "scripts/run-release-preflight.sh"), *args], + cwd=ROOT, + env=command_env, + check=False, + capture_output=True, + text=True, + ) + + def test_plan_resolves_exact_sha_and_wsl_transport(self) -> None: + sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + result = self.run_script( + "--profile", + "rehearsal", + "--host", + "test-worker", + "--wsl-distro", + "Ubuntu", + "--plan", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"SHA: {sha}", result.stdout) + self.assertIn("Runtime: WSL Ubuntu", result.stdout) + + def test_missing_optional_worker_is_a_non_gate(self) -> None: + result = self.run_script( + "--profile", + "release", + "--if-configured", + env={ + "PULSE_RELEASE_PREFLIGHT_HOST": "", + "PULSE_RELEASE_PREFLIGHT_WSL_DISTRO": "", + "GIT_CONFIG_NOSYSTEM": "1", + }, + ) + configured_host = subprocess.run( + ["git", "config", "--get", "pulse.releasePreflightHost"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + if configured_host.returncode == 0: + self.skipTest("repository has a local release-preflight host configured") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("canonical hosted checks", result.stdout) + + def test_worker_rejects_non_exact_sha_before_touching_worker_state(self) -> None: + result = subprocess.run( + [str(ROOT / "scripts/release-preflight-worker.sh"), "HEAD", "rehearsal"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("40-character Git commit id", result.stderr) + + def test_dispatch_helpers_select_the_matching_profiles(self) -> None: + dry_run = (ROOT / "scripts/trigger-release-dry-run.sh").read_text() + release = (ROOT / "scripts/trigger-release.sh").read_text() + workflow = (ROOT / ".github/workflows/release-dry-run.yml").read_text() + self.assertIn("--profile rehearsal", dry_run) + self.assertIn("--profile release", release) + self.assertIn("--if-configured", dry_run) + self.assertIn("--if-configured", release) + self.assertIn('PULSE_E2E_DIAGNOSTIC: "1"', workflow) + + def test_worker_has_no_publication_or_signing_authority(self) -> None: + worker = (ROOT / "scripts/release-preflight-worker.sh").read_text() + runner = (ROOT / "scripts/run-release-preflight.sh").read_text() + self.assertIn("unset GH_TOKEN GITHUB_TOKEN", worker) + self.assertIn("export GITHUB_ACTIONS=true", worker) + self.assertIn("export CI=true", worker) + self.assertNotIn("docker push", worker) + self.assertNotIn("gh release", worker) + self.assertIn("mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble", worker) + self.assertIn( + 'git show "${SOURCE_SHA}:scripts/release-preflight-worker.sh"', runner + ) + self.assertIn("is not reachable from a fetched origin branch", runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index fa98fa249..8ffcef24d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3717,6 +3717,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", @@ -3756,6 +3757,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", @@ -3802,6 +3804,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", @@ -3841,6 +3844,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", @@ -3880,6 +3884,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", @@ -3919,6 +3924,7 @@ class SubsystemLookupTest(unittest.TestCase): "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", diff --git a/scripts/run-release-preflight.sh b/scripts/run-release-preflight.sh new file mode 100755 index 000000000..055ca52ca --- /dev/null +++ b/scripts/run-release-preflight.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: run-release-preflight.sh [options] + +Options: + --sha SHA Exact pushed commit (default: HEAD) + --profile PROFILE rehearsal or release (required) + --host HOST SSH host (or PULSE_RELEASE_PREFLIGHT_HOST / git config) + --wsl-distro NAME Execute in this WSL distribution on the SSH host + --if-configured Succeed without running when no host is configured + --plan Print the resolved execution plan without connecting + -h, --help Show this help + +Persistent repository configuration: + git config pulse.releasePreflightHost + git config pulse.releasePreflightWslDistro +EOF +} + +SOURCE_REF="HEAD" +PROFILE="" +HOST="${PULSE_RELEASE_PREFLIGHT_HOST:-}" +WSL_DISTRO="${PULSE_RELEASE_PREFLIGHT_WSL_DISTRO:-}" +IF_CONFIGURED=false +PLAN_ONLY=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --sha) + SOURCE_REF="${2:-}" + shift 2 + ;; + --profile) + PROFILE="${2:-}" + shift 2 + ;; + --host) + HOST="${2:-}" + shift 2 + ;; + --wsl-distro) + WSL_DISTRO="${2:-}" + shift 2 + ;; + --if-configured) + IF_CONFIGURED=true + shift + ;; + --plan) + PLAN_ONLY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$PROFILE" != "rehearsal" ] && [ "$PROFILE" != "release" ]; then + echo "Error: --profile must be rehearsal or release." >&2 + exit 2 +fi + +REPOSITORY_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPOSITORY_ROOT" +SOURCE_SHA="$(git rev-parse --verify "${SOURCE_REF}^{commit}")" +if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Error: could not resolve an exact source SHA from ${SOURCE_REF}." >&2 + exit 2 +fi + +if [ -z "$HOST" ]; then + HOST="$(git config --get pulse.releasePreflightHost || true)" +fi +if [ -z "$WSL_DISTRO" ]; then + WSL_DISTRO="$(git config --get pulse.releasePreflightWslDistro || true)" +fi +if [ -n "$WSL_DISTRO" ] && [[ ! "$WSL_DISTRO" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Error: WSL distribution names may contain only letters, digits, dot, underscore, and hyphen." >&2 + exit 2 +fi +if [ -z "$HOST" ]; then + if [ "$IF_CONFIGURED" = true ]; then + echo "No accelerated release-preflight worker is configured; continuing with canonical hosted checks." + exit 0 + fi + echo "Error: configure --host, PULSE_RELEASE_PREFLIGHT_HOST, or pulse.releasePreflightHost." >&2 + exit 2 +fi + +echo "Accelerated exact-SHA release preflight" +echo " Host: ${HOST}" +echo " SHA: ${SOURCE_SHA}" +echo " Profile: ${PROFILE}" +if [ -n "$WSL_DISTRO" ]; then + echo " Runtime: WSL ${WSL_DISTRO}" +else + echo " Runtime: native remote shell" +fi + +if [ "$PLAN_ONLY" = true ]; then + exit 0 +fi + +git fetch origin --quiet +if ! git for-each-ref --format='%(refname)' --contains "$SOURCE_SHA" refs/remotes/origin/ | grep -q .; then + echo "Error: ${SOURCE_SHA} is not reachable from a fetched origin branch." >&2 + exit 2 +fi + +if ! git cat-file -e "${SOURCE_SHA}:scripts/release-preflight-worker.sh" 2>/dev/null; then + echo "Error: ${SOURCE_SHA} does not contain scripts/release-preflight-worker.sh." >&2 + exit 2 +fi + +if [ -n "$WSL_DISTRO" ]; then + git show "${SOURCE_SHA}:scripts/release-preflight-worker.sh" | \ + ssh "$HOST" wsl.exe -d "$WSL_DISTRO" -- bash -s -- "$SOURCE_SHA" "$PROFILE" +else + git show "${SOURCE_SHA}:scripts/release-preflight-worker.sh" | \ + ssh "$HOST" bash -s -- "$SOURCE_SHA" "$PROFILE" +fi diff --git a/scripts/trigger-release-dry-run.sh b/scripts/trigger-release-dry-run.sh index 952b3704e..5f553f5cb 100755 --- a/scripts/trigger-release-dry-run.sh +++ b/scripts/trigger-release-dry-run.sh @@ -57,7 +57,7 @@ echo "✓ On required branch (${REQUIRED_BRANCH})" git fetch origin --quiet LOCAL=$(git rev-parse @) -REMOTE=$(git rev-parse @{u}) +REMOTE=$(git rev-parse '@{u}') if [ "$LOCAL" != "$REMOTE" ]; then echo "❌ Local branch is not fully pushed to origin" echo "" @@ -72,6 +72,11 @@ else echo "✓ Up to date with remote" fi +./scripts/run-release-preflight.sh \ + --sha "$LOCAL" \ + --profile rehearsal \ + --if-configured + python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/release-dry-run.yml \ --branch "$CURRENT_BRANCH" \ diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index e917146a8..14797cb19 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -68,7 +68,7 @@ echo "✓ On required branch (${REQUIRED_BRANCH})" # Check 4: Up to date with remote git fetch origin --quiet LOCAL=$(git rev-parse @) -REMOTE=$(git rev-parse @{u}) +REMOTE=$(git rev-parse '@{u}') if [ "$LOCAL" != "$REMOTE" ]; then echo "❌ Local branch is not fully pushed to origin" @@ -84,6 +84,11 @@ else echo "✓ Up to date with remote" fi +./scripts/run-release-preflight.sh \ + --sha "$LOCAL" \ + --profile release \ + --if-configured + python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \