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 9d11c65fb..d7e38a7c4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -948,6 +948,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 @@ -1947,6 +1956,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 1844891f2..ccc452c0f 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3189,6 +3189,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(