ci: Add reusable retry script for CI commands (#26815)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-03-10 10:52:57 +00:00 committed by GitHub
parent 56176bf6b5
commit 60f4569fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 100 additions and 23 deletions

View File

@ -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'

60
.github/scripts/retry.mjs vendored Normal file
View File

@ -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] '<command>'
*
* 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] '<command>'");
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);

View File

@ -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

View File

@ -24,24 +24,42 @@ interface PullResult {
pulled: number;
}
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 10_000;
async function sleep(ms: number): Promise<void> {
return await new Promise((resolve) => setTimeout(resolve, ms));
}
async function pullImage(image: string): Promise<PullResult> {
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 {