From 9e116fb52a9f6d438d7ba8e7adea4fb2d27e3a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Thu, 20 Aug 2026 10:03:24 +0300 Subject: [PATCH] ci: cache `playwright install-deps` by using custom Docker image (#11009) --- .github/actions/setup-playwright/action.yml | 10 +- .github/docker/Dockerfile | 16 ++ .github/workflows/ci.yml | 146 +++++++++++++++--- knip.jsonc | 1 - package.json | 2 +- scripts/override-vite7.ts | 11 ++ .../globalSetup/another-vite-instance.ts | 1 + .../global-setup/test/global-setup.test.ts | 2 +- .../test/mocker-redirect-traversal.test.ts | 3 +- 9 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 .github/docker/Dockerfile create mode 100644 scripts/override-vite7.ts diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml index 279e4bb16..76432205b 100644 --- a/.github/actions/setup-playwright/action.yml +++ b/.github/actions/setup-playwright/action.yml @@ -39,16 +39,10 @@ runs: restore-keys: | ${{ runner.os }}-playwright-${{ inputs.browsers }}- - # webkit/firefox link against apt system libraries (e.g. webkit's libwoff2dec) - # that are NOT stored in the browser cache, so install them on Linux whenever - # the full set is requested, even on a cache hit. chromium needs none, and + # System libraries are never installed here: Linux jobs that need them run + # inside the playwright-deps container image (see .github/docker/Dockerfile), and # Windows/macOS need no system deps (--with-deps on Windows is a slow ~3min # Media Foundation DISM). - - name: Install Playwright system deps (Linux) - shell: bash - if: runner.os == 'Linux' && inputs.browsers == 'all' - run: pnpm exec playwright install-deps - - name: Install Playwright chromium (cache miss) shell: bash if: steps.playwright-cache.outputs.cache-hit != 'true' && inputs.browsers != 'all' diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile new file mode 100644 index 000000000..75b09c0e9 --- /dev/null +++ b/.github/docker/Dockerfile @@ -0,0 +1,16 @@ +# CI image with Playwright system dependencies pre-installed, so Linux browser +# jobs skip `playwright install-deps` on every run. Browser binaries are NOT +# baked in; jobs install them through the setup-playwright cache. +# git and ca-certificates are required by actions/checkout inside container +# jobs; zstd keeps actions/cache archives compatible with the runner VMs. +FROM node:24-bookworm-slim + +ARG PLAYWRIGHT_VERSION +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates zstd \ + && npx -y "playwright@${PLAYWRIGHT_VERSION}" install-deps \ + && rm -rf /var/lib/apt/lists/* + +# CI runs the container with --user 1001 (the GitHub runner user, which owns +# the mounted volumes); create it so passwd lookups resolve +RUN useradd -m -u 1001 runner diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42ab8312c..079bfacfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,8 +86,61 @@ jobs: docs/** .github/** !.github/workflows/ci.yml + !.github/docker/** **.md + # Ensures the playwright-deps image exists in GHCR: a no-op manifest check + # when the tag for the locked Playwright version + Dockerfile hash already + # exists, otherwise builds and pushes it. Fork PRs get a read-only token and + # cannot push a missing tag; changes to the tag inputs must come from a + # branch in this repository. + playwright-deps-image: + runs-on: ubuntu-latest + name: 'Image: playwright-deps, ubuntu-latest' + timeout-minutes: 15 + permissions: + packages: write # push the playwright-deps image to GHCR + outputs: + image: ${{ steps.image.outputs.image }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Resolve image tag + id: image + run: | + version="$(node -e " + const fs = require('fs'); + const lockfile = fs.readFileSync('./pnpm-lock.yaml', 'utf8'); + const match = lockfile.match(/playwright:\s+specifier: [\s\w.^]+version: (\d+\.\d+\.\d+)/); + if (!match) throw new Error('Failed to resolve playwright version'); + console.log(match[1]); + ")" + hash="$(sha256sum .github/docker/Dockerfile | cut -c1-12)" + echo "image=ghcr.io/${GITHUB_REPOSITORY@L}/playwright-deps:${version}-${hash}" >> "$GITHUB_OUTPUT" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + + - name: Build and push if missing + env: + IMAGE: ${{ steps.image.outputs.image }} + PLAYWRIGHT_VERSION: ${{ steps.image.outputs.version }} + run: | + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "$IMAGE already exists, skipping build" + else + docker build --build-arg "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" -t "$IMAGE" .github/docker + if ! docker push "$IMAGE"; then + echo "::error::Failed to publish $IMAGE. Fork PRs cannot push to GHCR - a Vitest maintainer needs to run CI from a vitest-dev/vitest branch to publish the playwright-deps:$PLAYWRIGHT_VERSION image." + exit 1 + fi + fi + test: needs: changed name: 'Test: ${{ matrix.suite }}, node-${{ matrix.node_version }}, ${{ matrix.os }}' @@ -172,33 +225,77 @@ jobs: retention-days: 1 include-hidden-files: true - test-browser: + # runner.test.ts is the heaviest spec (~28% of the suite, ~70% of whichever + # shard it lands in). Windows isolates it in its own job and shards the other + # 32 specs; Ubuntu shards the whole suite 2 ways (runner rides in one shard). + # Every spec, runner included, runs on both OSes. Ubuntu runs inside the + # playwright-deps container so `playwright install-deps` is never needed; + # Windows needs no system deps. + test-browser-windows: needs: changed - name: 'Browsers: ${{ matrix.name }}, node-24, ${{ matrix.os }}' + name: 'Browsers: ${{ matrix.name }}, node-24, windows-latest' if: needs.changed.outputs.should_skip != 'true' - runs-on: ${{ matrix.os }} + runs-on: windows-latest strategy: - # runner.test.ts is the heaviest spec (~28% of the suite, ~70% of whichever - # shard it lands in). Windows isolates it in its own job and shards the other - # 32 specs; Ubuntu shards the whole suite 2 ways (runner rides in one shard). - # Every spec, runner included, runs on both OSes. matrix: include: - - os: windows-latest - name: runner + - name: runner command: pnpm run test:browser:playwright runner.test.ts - - os: windows-latest - name: rest 1/2 + - name: rest 1/2 command: "pnpm run test:browser:playwright --exclude 'specs/runner.test.ts' --shard=1/2" - - os: windows-latest - name: rest 2/2 + - name: rest 2/2 command: "pnpm run test:browser:playwright --exclude 'specs/runner.test.ts' --shard=2/2" - - os: ubuntu-latest - name: shard 1/2 + fail-fast: false + + timeout-minutes: 30 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-and-cache + with: + node-version: 24 + + - name: Install + run: pnpm i --filter '!docs' + + - uses: ./.github/actions/setup-playwright + with: + browsers: all + + - name: Build + run: pnpm run build + + - name: Test Browser (playwright) + # matrix.command is a fixed, workflow-defined string + run: ${{ matrix.command }} # zizmor: ignore[template-injection] + + test-browser-linux: + needs: + - changed + - playwright-deps-image + name: 'Browsers: ${{ matrix.name }}, node-24, ubuntu-latest' + if: needs.changed.outputs.should_skip != 'true' + + runs-on: ubuntu-latest + permissions: + packages: read # pull the playwright-deps image from GHCR + container: + # the tag is version-keyed, built by playwright-deps-image in this workflow + image: ${{ needs.playwright-deps-image.outputs.image }} # zizmor: ignore[unpinned-images] + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --user 1001 + strategy: + matrix: + include: + - name: shard 1/2 command: pnpm run test:browser:playwright --shard=1/2 - - os: ubuntu-latest - name: shard 2/2 + - name: shard 2/2 command: pnpm run test:browser:playwright --shard=2/2 fail-fast: false @@ -228,11 +325,22 @@ jobs: run: ${{ matrix.command }} # zizmor: ignore[template-injection] test-vite7: - needs: changed + needs: + - changed + - playwright-deps-image # Runs on ubuntu to keep macOS under its ~5 concurrent-runner limit. name: 'Test: vite@7, ${{ matrix.suite }}, node-24, ubuntu-latest' if: needs.changed.outputs.should_skip != 'true' runs-on: ubuntu-latest + permissions: + packages: read # pull the playwright-deps image from GHCR + container: + # the tag is version-keyed, built by playwright-deps-image in this workflow + image: ${{ needs.playwright-deps-image.outputs.image }} # zizmor: ignore[unpinned-images] + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + options: --user 1001 timeout-minutes: 30 @@ -256,6 +364,8 @@ jobs: - name: Install run: | pnpm override-vite7 + # the container user does not own the workspace volume, and checkout's safe.directory entry does not survive into later steps + git config --global --add safe.directory "$GITHUB_WORKSPACE" # ubuntu runners have no default git identity; set one for this commit git add . && git -c user.email=ci@vitest.dev -c user.name=vitest-ci commit -m "ci" && pnpm i --prefer-offline --no-frozen-lockfile --filter '!docs' diff --git a/knip.jsonc b/knip.jsonc index d92c0f485..1961065d2 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -5,7 +5,6 @@ "test/e2e/dts/*", "test/e2e/fixtures/conditions-pkg" ], - "ignoreBinaries": ["yq"], "ignoreIssues": { "docs/.vitepress/blog.data.ts": ["files"], "docs/.vitepress/components/*.vue": ["files"], diff --git a/package.json b/package.json index e344ca92f..f5b749843 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "ui:build": "vite build packages/ui", "ui:dev": "npm -C packages/ui run dev:client", "ui:test": "npm -C packages/ui run test:run", - "override-vite7": "yq -i '.overrides.vite = \"npm:vite@7\"' pnpm-workspace.yaml", + "override-vite7": "node scripts/override-vite7.ts", "test:browser:playwright": "pnpm -C test/browser run test:playwright" }, "devDependencies": { diff --git a/scripts/override-vite7.ts b/scripts/override-vite7.ts new file mode 100644 index 000000000..5affb986e --- /dev/null +++ b/scripts/override-vite7.ts @@ -0,0 +1,11 @@ +import fs from 'node:fs' + +const file = 'pnpm-workspace.yaml' +const content = fs.readFileSync(file, 'utf8') +const updated = content.replace(`vite: 'catalog:'`, 'vite: npm:vite@7') + +if (updated === content) { + throw new Error(`Expected to find the "vite: 'catalog:'" override in ${file}`) +} + +fs.writeFileSync(file, updated) diff --git a/test/e2e/fixtures/global-setup/globalSetup/another-vite-instance.ts b/test/e2e/fixtures/global-setup/globalSetup/another-vite-instance.ts index 2a74c65b3..c32ae24b0 100644 --- a/test/e2e/fixtures/global-setup/globalSetup/another-vite-instance.ts +++ b/test/e2e/fixtures/global-setup/globalSetup/another-vite-instance.ts @@ -7,6 +7,7 @@ export async function setup() { const server = await createServer({ root: resolve(import.meta.dirname, '..'), server: { + host: '127.0.0.1', port: 9988, }, }) diff --git a/test/e2e/fixtures/global-setup/test/global-setup.test.ts b/test/e2e/fixtures/global-setup/test/global-setup.test.ts index 288fce604..3b1b0532d 100644 --- a/test/e2e/fixtures/global-setup/test/global-setup.test.ts +++ b/test/e2e/fixtures/global-setup/test/global-setup.test.ts @@ -22,7 +22,7 @@ test('server running', async () => { }) test('vite instance running', async () => { - const res = await (await fetch('http://localhost:9988')).text() + const res = await (await fetch('http://127.0.0.1:9988')).text() expect(res).toContain('