diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc75a571..3ff79d93d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -403,7 +403,9 @@ jobs: # per-shard names need no fan-in job. web-integration: name: web-integration (${{ matrix.shard }}/2) - runs-on: ubuntu-latest + # Pinned, not ubuntu-latest: Playwright 1.60 refuses ubuntu26.04 (nr-7sn1). Move it together + # with a Playwright that supports the new image. + runs-on: ubuntu-24.04 # The tests take about seven minutes on a shard (nr-y7yu, one worker). The limit was raised to 30 when installing # Playwright's system packages from a slow apt mirror took up to eleven; that step no longer # touches apt unless a library is missing (nr-2e2l). @@ -439,7 +441,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/ms-playwright - key: playwright-chromium-${{ hashFiles('pnpm-lock.yaml') }} + key: playwright-chromium-ubuntu-24.04-${{ hashFiles('pnpm-lock.yaml') }} - name: Install Playwright Chromium if: steps.playwright-cache.outputs.cache-hit != 'true' run: | @@ -488,7 +490,9 @@ jobs: retention-days: 7 web-container: - runs-on: ubuntu-latest + # Pinned, not ubuntu-latest: Playwright 1.60 refuses ubuntu26.04 (nr-7sn1). Move it together + # with a Playwright that supports the new image. + runs-on: ubuntu-24.04 timeout-minutes: 25 needs: [changes, python-tests, rust-test-build, rust-test-run, rust-lint, wasm, web, web-integration] if: >- diff --git a/docs/retrospectives/nr-7sn1.md b/docs/retrospectives/nr-7sn1.md new file mode 100644 index 000000000..ae322efa6 --- /dev/null +++ b/docs/retrospectives/nr-7sn1.md @@ -0,0 +1,11 @@ +# nr-7sn1: ubuntu-latest moved to 26.04 under us, and a cache hid it + +**What happened:** The web-container job failed on PR #3395 with "Playwright does not support chromium on ubuntu26.04-x64". It failed before any test ran, and with nothing in the repository having changed. GitHub had moved `ubuntu-latest` to Ubuntu 26.04, and our lockfile's Playwright (1.60.0) refuses that image. web-integration ran on the same image and stayed green, but only because its Chromium came from an Actions cache keyed on the lockfile alone, so its install step never ran. That was luck, not health. + +**Why:** Jobs that download a host-specific browser followed a floating runner label. A cache keyed without the image skipped the one step that checks host support. + +**Cost:** Every pull request that ran web-container was blocked (PR #3395 failed twice; nr-ri8n and nr-1wbx queued behind it). One P0 bug pass. + +**Prevent by:** `scripts/test_playwright_runner_image.py` requires every job that runs `playwright install` to name `ubuntu-24.04`, and every Playwright cache key to carry that image. Moving the image is therefore a deliberate change, made together with a Playwright upgrade (1.64.1 is the first release seen to list ubuntu26.04). + +**Seen before:** No. diff --git a/scripts/test_playwright_runner_image.py b/scripts/test_playwright_runner_image.py new file mode 100644 index 000000000..5b4cb0cfc --- /dev/null +++ b/scripts/test_playwright_runner_image.py @@ -0,0 +1,58 @@ +"""Every CI job that installs Playwright's Chromium runs on a pinned Ubuntu image (nr-7sn1). + +``ubuntu-latest`` moved to Ubuntu 26.04 in October 2026, and the Playwright the lockfile carries +(1.60) refuses it: ``playwright install chromium`` failed with "Playwright does not support +chromium on ubuntu26.04-x64" before any test ran, and web-container blocked every pull request. +A job that installs the browser therefore names the image it was proven on rather than following +``ubuntu-latest``; moving it is a deliberate change made together with a Playwright that supports +the new image. The workflow is read as text, as ``test_web_integration_ci.py`` does. +""" + +import re +import unittest +from pathlib import Path + +CI_YML = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "ci.yml" +PINNED_IMAGE = "ubuntu-24.04" + + +def jobs() -> dict[str, str]: + """Each job of ci.yml by name, as the text of its body.""" + + ci = CI_YML.read_text(encoding="utf-8") + jobs_text = ci.split("\njobs:\n", 1)[1] + return { + match.group(1): match.group(2) + for match in re.finditer(r"^ ([\w-]+):\n((?:(?: {4}.*)?\n)+)", jobs_text, re.MULTILINE) + } + + +class PlaywrightRunnerImageTest(unittest.TestCase): + def test_jobs_installing_playwright_are_found(self) -> None: + installing = [name for name, body in jobs().items() if "playwright install" in body] + self.assertEqual(sorted(installing), ["web-container", "web-integration"]) + + def test_jobs_installing_playwright_pin_their_image(self) -> None: + for name, body in jobs().items(): + if "playwright install" not in body: + continue + with self.subTest(job=name): + runs_on = re.search(r"^ runs-on: (.+)$", body, re.MULTILINE) + assert runs_on is not None, f"{name} has no runs-on" + self.assertEqual(runs_on.group(1), PINNED_IMAGE) + + def test_cached_browsers_are_keyed_by_the_pinned_image(self) -> None: + """A cached Chromium skips the install, the one step that checks the image (review of #3396). + + The cache once hid this very bug in web-integration, so moving the pin must miss the cache. + """ + + keys = [(name, key) for name, body in jobs().items() for key in re.findall(r"key: (playwright-\S+)", body)] + self.assertTrue(keys, "no Playwright browser cache found in ci.yml") + for name, key in keys: + with self.subTest(job=name): + self.assertIn(PINNED_IMAGE, key) + + +if __name__ == "__main__": + unittest.main()