diff --git a/.github/actions/docker-registry-login/action.yml b/.github/actions/docker-registry-login/action.yml index e5089e8a934..2b89669a8e1 100644 --- a/.github/actions/docker-registry-login/action.yml +++ b/.github/actions/docker-registry-login/action.yml @@ -29,11 +29,13 @@ runs: steps: - name: Login to GitHub Container Registry if: inputs.login-ghcr == 'true' - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} + shell: bash + env: + GHCR_TOKEN: ${{ github.token }} + GHCR_USER: ${{ github.actor }} + run: | + node .github/scripts/retry.mjs --attempts 3 --delay 10 \ + 'echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin' - name: Login to DockerHub if: inputs.login-dockerhub == 'true' diff --git a/.github/scripts/retry.mjs b/.github/scripts/retry.mjs new file mode 100644 index 00000000000..c928a79cdcc --- /dev/null +++ b/.github/scripts/retry.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Retry a shell command with configurable attempts and delay. + * + * Usage: node retry.mjs [--attempts N] [--delay N] '' + * + * Options: + * --attempts N Maximum number of attempts (default: 4) + * --delay N Seconds to wait between retries (default: 15) + * + * The command is executed via shell, so pipes and env-var expansion work. + * Exits 0 on first success, 1 if all attempts fail. + */ +import { execSync } from 'node:child_process'; + +const args = process.argv.slice(2); + +function getFlag(name, defaultValue) { + const index = args.indexOf(`--${name}`); + if (index === -1 || !args[index + 1]) return defaultValue; + const value = parseInt(args[index + 1], 10); + if (Number.isNaN(value) || value <= 0) { + console.error(`Error: --${name} must be a positive integer`); + process.exit(1); + } + return value; +} + +const attempts = getFlag('attempts', 4); +const delay = getFlag('delay', 15); + +// Command is the last positional arg (skip flags and their values) +const command = args + .filter((a, i) => { + if (a.startsWith('--')) return false; + if (i > 0 && args[i - 1].startsWith('--')) return false; + return true; + }) + .pop(); + +if (!command) { + console.error("Usage: node retry.mjs [--attempts N] [--delay N] ''"); + process.exit(1); +} + +for (let i = 1; i <= attempts; i++) { + try { + execSync(command, { shell: true, stdio: 'inherit' }); + process.exit(0); + } catch { + if (i < attempts) { + console.error(`Attempt ${i}/${attempts} failed, retrying in ${delay}s...`); + execSync(`sleep ${delay}`); + } else { + console.error(`Attempt ${i}/${attempts} failed, no more retries.`); + } + } +} + +process.exit(1); diff --git a/.github/workflows/security-trivy-scan-callable.yml b/.github/workflows/security-trivy-scan-callable.yml index 2d5082fa6bb..4569cd53bf5 100644 --- a/.github/workflows/security-trivy-scan-callable.yml +++ b/.github/workflows/security-trivy-scan-callable.yml @@ -36,14 +36,11 @@ jobs: security/vex.openvex.json security/trivy.yaml security/trivy-ignore-policy.rego + .github/scripts/retry.mjs sparse-checkout-cone-mode: false - name: Pull Docker image with retry - run: | - for i in {1..4}; do - docker pull "${{ inputs.image_ref }}" && break - [ "$i" -lt 4 ] && echo "Retry $i failed, waiting..." && sleep 15 - done + run: node .github/scripts/retry.mjs --attempts 4 --delay 15 'docker pull "${{ inputs.image_ref }}"' - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1 diff --git a/packages/testing/containers/pull-test-images.ts b/packages/testing/containers/pull-test-images.ts index a6b8ab3aec2..a6668907cf1 100755 --- a/packages/testing/containers/pull-test-images.ts +++ b/packages/testing/containers/pull-test-images.ts @@ -24,24 +24,42 @@ interface PullResult { pulled: number; } +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 10_000; + +async function sleep(ms: number): Promise { + return await new Promise((resolve) => setTimeout(resolve, ms)); +} + async function pullImage(image: string): Promise { const imageStart = Date.now(); - try { - const { stdout, stderr } = await execAsync(`docker pull ${image}`); - const output = stdout + stderr; - // Count cached vs pulled layers - const cached = (output.match(/Already exists/g) ?? []).length; - const pulled = (output.match(/Pull complete/g) ?? []).length; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const { stdout, stderr } = await execAsync(`docker pull ${image}`); + const output = stdout + stderr; - const duration = ((Date.now() - imageStart) / 1000).toFixed(1); - return { image, duration, success: true, cached, pulled }; - } catch (error) { - const duration = ((Date.now() - imageStart) / 1000).toFixed(1); - const message = error instanceof Error ? error.message : String(error); - console.error(` ⚠️ Pull failed for ${image}: ${message}`); - return { image, duration, success: false, cached: 0, pulled: 0 }; + const cached = (output.match(/Already exists/g) ?? []).length; + const pulled = (output.match(/Pull complete/g) ?? []).length; + + const duration = ((Date.now() - imageStart) / 1000).toFixed(1); + return { image, duration, success: true, cached, pulled }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt < MAX_RETRIES) { + console.warn( + ` ⚠️ Pull attempt ${attempt}/${MAX_RETRIES} failed for ${image}: ${message}`, + ); + console.warn(` ⏳ Retrying in ${RETRY_DELAY_MS / 1000}s...`); + await sleep(RETRY_DELAY_MS); + } else { + console.error(` ❌ Pull failed for ${image} after ${MAX_RETRIES} attempts: ${message}`); + } + } } + + const duration = ((Date.now() - imageStart) / 1000).toFixed(1); + return { image, duration, success: false, cached: 0, pulled: 0 }; } function isValidImageKey(key: string): key is ImageKey {