From 22b9abf2310892d4d74940eb1d79fc6829c7a2df Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:32:40 +0100 Subject: [PATCH] Fix the release preflight worker and Helm publish, and map v6.4.3 to release/v6.4 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, but de41ea1883 made 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 commit 56e51e622e and 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. --- .github/workflows/publish-helm-chart.yml | 14 ++++---- docs/release-control/control_plane.json | 5 +++ .../v6/internal/RELEASE_PROMOTION_POLICY.md | 8 +++++ .../subsystems/deployment-installability.md | 14 ++++++++ .../installtests/build_release_assets_test.go | 8 +++++ scripts/release-preflight-worker.sh | 36 +++++++++++++++++-- .../internal/release_preflight_test.py | 16 +++++++++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml index 83763fa26..b73ffdc35 100644 --- a/.github/workflows/publish-helm-chart.yml +++ b/.github/workflows/publish-helm-chart.yml @@ -55,6 +55,13 @@ jobs: id-token: write attestations: write steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + - name: Determine chart version id: versions env: @@ -93,13 +100,6 @@ jobs: python3 scripts/write_github_output.py release_tag "$RELEASE_TAG" echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 0 - fetch-tags: true - - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0 with: diff --git a/docs/release-control/control_plane.json b/docs/release-control/control_plane.json index 3d0cd4abf..ebef138a8 100644 --- a/docs/release-control/control_plane.json +++ b/docs/release-control/control_plane.json @@ -18,6 +18,11 @@ "prerelease_branch": "release/v6.3.2", "stable_branch": "release/v6.3.2" }, + { + "version_prefix": "6.4.3", + "prerelease_branch": "release/v6.4", + "stable_branch": "release/v6.4" + }, { "version_prefix": "6.5.", "prerelease_branch": "release/v6.5", diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 594ed63b3..689d1d3e3 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -279,6 +279,14 @@ without the other lanes changing the candidate underneath it. v6.2.0, v6.3.0, and v6.4.0. They remain recorded and bounded; the train does not continue the practice. An exception requires active customer harm and is recorded in the release notes. +7. The `v6.4.3` patch line predates the first train and is the first line + released under the train's branch rule. `release/v6.4` was created from + `main` at the exact-SHA-qualified commit `56e51e622e` on 2026-09-02 and is + declared in `control_plane.json` with the version prefix `6.4.3`, so the + release workflow refuses a `v6.4.3` dispatch from any other branch and a + moving `main` can no longer invalidate the compiler's exact-SHA binding + between dispatch and compilation, which is what failed run 33579042375. + Earlier `6.4.x` versions keep their historical `main` mapping. ## Paid Pro Artifact Lineage diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f62068c0e..2286a532c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -941,6 +941,15 @@ artifact-selection behaviour. Worker startup must compare the complete `go`-prefixed toolchain identity from `go.mod` with `go env GOVERSION` so a formatting mismatch cannot reject an otherwise exact toolchain or conceal a real version drift. + The worker must activate an installed `mise` toolchain before its tool + checks, because it runs over a non-login ssh shell where profile hooks do + not load; it must leave `GOTMPDIR` unset by default so unix-socket test + fixtures resolve under `/tmp` exactly as on GitHub runners; and it must + publish the smoke stacks on a host port pair it has verified free + (honouring `PULSE_RELEASE_PREFLIGHT_E2E_PORT`), probe health and update + status on that port, and hand Playwright the same base URL, because a + worker may also host long-running Pulse instances on `7655` and `17655` + and a port collision fails the smoke only after every other stage passed. Race-instrumented Go builds must place `GOTMPDIR` under the worker's persistent run directory and remove that bounded scratch directory on exit; a small WSL `/tmp` tmpfs must not turn release qualification into a false @@ -1940,6 +1949,11 @@ artifact-selection behaviour. `release` and `workflow_dispatch` triggers, and its chart-version resolver must prefer inputs over the release-event tag when inputs are present so all three entry paths converge on the same identity. + Because that resolver writes its outputs through + `scripts/write_github_output.py`, the workflow must check out the + repository before the resolver step runs; de41ea1883 introduced the + script call ahead of the checkout and every chart publish failed until + the v6.4.3-rc.1 run exposed it. `helm-pages.yml` must not treat chart-releaser's "no chart changes detected" no-op as a successful Pages publication for a newly published release version. A successful Pages workflow must create or update the diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 23bc2e22c..210d31076 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3185,6 +3185,14 @@ func TestPublishHelmChartReachableViaWorkflowCall(t *testing.T) { if !(attestationLogin < chartPush && chartPush < attestation) { t.Fatal("publish-helm-chart.yml must authenticate the OCI attestation client before pushing and attesting the chart") } + // The chart-version resolver writes its outputs through + // scripts/write_github_output.py, so the repository must already be + // checked out when it runs (de41ea1883 broke every chart publish this way). + checkout := strings.Index(workflow, "- name: Checkout repository") + chartVersion := strings.Index(workflow, "- name: Determine chart version") + if !(checkout >= 0 && chartVersion > checkout) { + t.Fatal("publish-helm-chart.yml must check out the repository before the chart-version resolver runs scripts/write_github_output.py") + } for _, forbidden := range []string{ `versions/latest/restore`, `-f visibility=public`, diff --git a/scripts/release-preflight-worker.sh b/scripts/release-preflight-worker.sh index 36e523560..ed9166e4f 100755 --- a/scripts/release-preflight-worker.sh +++ b/scripts/release-preflight-worker.sh @@ -44,6 +44,35 @@ RUN_DIR="${WORKER_ROOT}/tmp/${RUN_ID}" 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 @@ -236,6 +265,7 @@ run_playwright() { --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" \ @@ -250,10 +280,10 @@ run_rehearsal_smoke() { 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' + 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}' http://localhost:7655/api/updates/status || true)" + 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) ;; *) @@ -275,7 +305,7 @@ run_release_smoke() { 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' + 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 } diff --git a/scripts/release_control/internal/release_preflight_test.py b/scripts/release_control/internal/release_preflight_test.py index 54efc80ac..0c2cc4950 100755 --- a/scripts/release_control/internal/release_preflight_test.py +++ b/scripts/release_control/internal/release_preflight_test.py @@ -248,6 +248,22 @@ class ReleasePreflightTest(unittest.TestCase): ) self.assertIn("is not reachable from a fetched origin branch", runner) + def test_worker_matches_ci_toolchain_and_publishes_smoke_on_a_free_port(self) -> None: + worker = (ROOT / "scripts/release-preflight-worker.sh").read_text() + # Non-login ssh shells never source profile hooks, so an installed mise + # toolchain must be activated explicitly or a stale system Node wins. + self.assertIn('"$HOME/.local/bin/mise" activate bash --shims', worker) + # GitHub runners leave GOTMPDIR unset; unix-socket fixtures sit at the + # sun_path limit under /tmp, so the worker must not nest test temp dirs. + self.assertIn('GO_TMP_DIR="${PULSE_RELEASE_PREFLIGHT_GO_TMP_DIR:-}"', worker) + # Smoke stacks publish on a verified-free host port pair and every probe + # and the browser use that port; a worker may host Pulse on 7655. + self.assertIn("smoke_port_pair_is_free", worker) + self.assertIn("PULSE_RELEASE_PREFLIGHT_E2E_PORT", worker) + self.assertIn('export PULSE_E2E_BASE_URL="http://localhost:${PULSE_E2E_PORT}"', worker) + self.assertIn('--env "PLAYWRIGHT_BASE_URL=${PULSE_E2E_BASE_URL}"', worker) + self.assertNotIn("localhost:7655", worker) + def test_worker_serializes_resource_intensive_test_suites(self) -> None: worker = (ROOT / "scripts/release-preflight-worker.sh").read_text() scheduling_block = re.search(