diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dbcfc93cc8..298f91bddd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -984,6 +984,19 @@ jobs: - run: pnpm playwright:install working-directory: site + # Cache the Coder release binaries downloaded by the outdatedCLI / + # outdatedAgent e2e tests so most runs skip the flaky GitHub release + # download entirely. The cache key is keyed off the test files that pin + # the downloaded versions, so it invalidates when those versions change. + - name: Restore e2e Coder release binary cache + id: coder-e2e-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /tmp/coder-e2e-cache + key: coder-e2e-cache-${{ runner.os }}-${{ hashFiles('site/e2e/tests/outdatedCLI.spec.ts', 'site/e2e/tests/outdatedAgent.spec.ts') }} + restore-keys: | + coder-e2e-cache-${{ runner.os }}- + # Run tests that don't require a premium license without a premium license - run: pnpm playwright:test --forbid-only --workers 1 if: ${{ !matrix.variant.premium }} @@ -1000,6 +1013,16 @@ jobs: CODER_E2E_REQUIRE_PREMIUM_TESTS: "1" working-directory: site + - name: Save e2e Coder release binary cache + # Only the default branch is trusted to write the cache, so PR runs + # cannot poison the cache that subsequent runs restore from. Skip when + # the cache already had an exact key hit (no new content). + if: always() && github.ref == 'refs/heads/main' && steps.coder-e2e-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /tmp/coder-e2e-cache + key: ${{ steps.coder-e2e-cache.outputs.cache-primary-key }} + - name: Upload Playwright failure artifacts if: failure() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 637951887f..9d58d98aff 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -487,38 +487,60 @@ export const downloadCoderVersion = async ( return binaryPath; } - // Run our official install script to install the binary - await new Promise((resolve, reject) => { - const cp = spawn( - path.join(__dirname, "../../install.sh"), - [ - "--version", - versionNumber, - "--method", - "standalone", - "--prefix", - tempDir, - "--binary-name", - binaryName, - ], - { - env: { - ...process.env, - XDG_CACHE_HOME: "/tmp/coder-e2e-cache", - TRACE: "1", // tells install.sh to `set -x`, helpful if something goes wrong + // runInstallScript runs our official install script to install the binary, + // resolving with the script's exit code. + const runInstallScript = (): Promise => + new Promise((resolve, reject) => { + const cp = spawn( + path.join(__dirname, "../../install.sh"), + [ + "--version", + versionNumber, + "--method", + "standalone", + "--prefix", + tempDir, + "--binary-name", + binaryName, + ], + { + env: { + ...process.env, + XDG_CACHE_HOME: "/tmp/coder-e2e-cache", + TRACE: "1", // tells install.sh to `set -x`, helpful if something goes wrong + }, }, - }, - ); - cp.stderr.on("data", (data) => console.error(data.toString())); - cp.stdout.on("data", (data) => console.info(data.toString())); - cp.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`install.sh failed with code ${code}`)); - } + ); + cp.stderr.on("data", (data) => console.error(data.toString())); + cp.stdout.on("data", (data) => console.info(data.toString())); + cp.on("error", (err) => reject(err)); + cp.on("close", (code) => resolve(code ?? 1)); }); - }); + + // The install script downloads the release asset from GitHub, which + // occasionally returns a transient error (e.g. HTTP 403/503, surfacing as a + // nonzero curl exit code). Retry with exponential backoff so a single hiccup + // does not fail the test. Partial downloads are resumed and completed + // binaries are reused across attempts by install.sh. + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const code = await runInstallScript(); + if (code === 0) { + return binaryPath; + } + if (attempt === maxAttempts) { + throw new Error( + `install.sh failed with code ${code} after ${maxAttempts} attempts`, + ); + } + // Exponential backoff with jitter: ~1s, 2s, 4s, 8s between attempts. + const backoffMs = + 2 ** (attempt - 1) * 1000 + Math.floor(Math.random() * 1000); + console.error( + `install.sh attempt ${attempt}/${maxAttempts} failed with code ${code}; retrying in ${backoffMs}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } return binaryPath; };