Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ jobs:
tags: user/app:latest
```

### Retry on failure

Build can be configured to retry on failure with configurable attempts, wait time, and timeout:

```yaml
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: user/app:latest
max-attempts: 3
retry-wait-seconds: 30
timeout-minutes: 10
```

This configuration will:
- Make up to 3 attempts to build (initial attempt + 2 retries)
- Wait 30 seconds between each retry attempt
- Timeout each attempt after 10 minutes

## Examples

* [Multi-platform image](https://docs.docker.com/build/ci/github-actions/multi-platform/)
Expand Down Expand Up @@ -258,6 +280,9 @@ The following inputs can be used as `step.with` keys:
| `target` | String | Sets the target stage to build |
| `ulimit` | List | [Ulimit](https://docs.docker.com/engine/reference/commandline/buildx_build/#ulimit) options (e.g., `nofile=1024:1024`) |
| `github-token` | String | GitHub Token used to authenticate against a repository for [Git context](#git-context) (default `${{ github.token }}`) |
| `max-attempts` | Number | Maximum number of build attempts (including initial attempt) (default `1`) |
| `retry-wait-seconds` | Number | Number of seconds to wait between retry attempts (default `5`) |
| `timeout-minutes` | Number | Timeout for each build attempt in minutes, `0` means no timeout (default `0`) |

### outputs

Expand Down
71 changes: 71 additions & 0 deletions __tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,77 @@ ANOTHER_SECRET=ANOTHER_SECRET_ENV`]
);
});

describe('getInputs', () => {
beforeEach(() => {
process.env = Object.keys(process.env).reduce((object, key) => {
if (!key.startsWith('INPUT_')) {
object[key] = process.env[key];
}
return object;
}, {});
});

test('should parse retry inputs with default values', async () => {
setInput('context', '.');
setInput('load', 'false');
setInput('no-cache', 'false');
setInput('push', 'false');
setInput('pull', 'false');
setInput('max-attempts', '1');
setInput('retry-wait-seconds', '5');
setInput('timeout-minutes', '0');

const inputs = await context.getInputs();
expect(inputs['max-attempts']).toBe(1);
expect(inputs['retry-wait-seconds']).toBe(5);
expect(inputs['timeout-minutes']).toBe(0);
});

test('should parse retry inputs with custom values', async () => {
setInput('context', '.');
setInput('max-attempts', '3');
setInput('retry-wait-seconds', '30');
setInput('timeout-minutes', '10');
setInput('load', 'false');
setInput('no-cache', 'false');
setInput('push', 'false');
setInput('pull', 'false');

const inputs = await context.getInputs();
expect(inputs['max-attempts']).toBe(3);
expect(inputs['retry-wait-seconds']).toBe(30);
expect(inputs['timeout-minutes']).toBe(10);
});

test('should parse invalid retry inputs as NaN', async () => {
setInput('context', '.');
setInput('max-attempts', 'invalid');
setInput('retry-wait-seconds', 'abc');
setInput('load', 'false');
setInput('no-cache', 'false');
setInput('push', 'false');
setInput('pull', 'false');

const inputs = await context.getInputs();
expect(isNaN(inputs['max-attempts'])).toBe(true);
expect(isNaN(inputs['retry-wait-seconds'])).toBe(true);
});

test('should parse negative and zero values', async () => {
setInput('context', '.');
setInput('max-attempts', '0');
setInput('retry-wait-seconds', '-10');
setInput('load', 'false');
setInput('no-cache', 'false');
setInput('push', 'false');
setInput('pull', 'false');

const inputs = await context.getInputs();
expect(inputs['max-attempts']).toBe(0);
expect(inputs['retry-wait-seconds']).toBe(-10);
});
});

// See: https://github.com/actions/toolkit/blob/a1b068ec31a042ff1e10a522d8fdf0b8869d53ca/packages/core/src/core.ts#L89
function getInputName(name: string): string {
return `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;
Expand Down
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ inputs:
description: "GitHub Token used to authenticate against a repository for Git context"
default: ${{ github.token }}
required: false
max-attempts:
description: "Maximum number of build attempts (including initial attempt)"
required: false
default: '1'
retry-wait-seconds:
description: "Number of seconds to wait between retry attempts"
required: false
default: '5'
timeout-minutes:
description: "Timeout for each build attempt in minutes (0 means no timeout)"
required: false
default: '0'

outputs:
imageid:
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export interface Inputs {
target: string;
ulimit: string[];
'github-token': string;
'max-attempts': number;
'retry-wait-seconds': number;
'timeout-minutes': number;
}

export async function getInputs(): Promise<Inputs> {
Expand Down Expand Up @@ -77,7 +80,10 @@ export async function getInputs(): Promise<Inputs> {
tags: Util.getInputList('tags'),
target: core.getInput('target'),
ulimit: Util.getInputList('ulimit', {ignoreComma: true}),
'github-token': core.getInput('github-token')
'github-token': core.getInput('github-token'),
'max-attempts': Util.getInputNumber('max-attempts') ?? 1,
'retry-wait-seconds': Util.getInputNumber('retry-wait-seconds') ?? 5,
'timeout-minutes': Util.getInputNumber('timeout-minutes') ?? 0
};
}

Expand Down
107 changes: 84 additions & 23 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,25 +97,7 @@ actionsToolkit.run(
core.debug(`buildCmd.command: ${buildCmd.command}`);
core.debug(`buildCmd.args: ${JSON.stringify(buildCmd.args)}`);

let err: Error | undefined;
await Exec.getExecOutput(buildCmd.command, buildCmd.args, {
ignoreReturnCode: true,
env: Object.assign({}, process.env, {
BUILDX_METADATA_WARNINGS: 'true'
}) as {
[key: string]: string;
}
}).then(res => {
if (res.exitCode != 0) {
if (inputs.call && inputs.call === 'check' && res.stdout.length > 0) {
// checks warnings are printed to stdout: https://github.com/docker/buildx/pull/2647
// take the first line with the message summaryzing the warnings
err = new Error(res.stdout.split('\n')[0]?.trim());
} else if (res.stderr.length > 0) {
err = new Error(`buildx failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
}
}
});
await executeBuildWithRetry(buildCmd, inputs);

const imageID = toolkit.buildxBuild.resolveImageID();
const metadata = toolkit.buildxBuild.resolveMetadata();
Expand Down Expand Up @@ -182,10 +164,6 @@ actionsToolkit.run(
stateHelper.setSummarySupported();
}
});

if (err) {
throw err;
}
},
// post
async () => {
Expand Down Expand Up @@ -238,6 +216,89 @@ actionsToolkit.run(
}
);

async function executeBuildWithRetry(buildCmd: {command: string; args: string[]}, inputs: context.Inputs): Promise<void> {
// Validate and sanitize retry inputs
let maxAttempts = inputs['max-attempts'];
if (isNaN(maxAttempts) || maxAttempts < 1) {
core.warning(`Invalid max-attempts value '${inputs['max-attempts']}'. Using default: 1`);
maxAttempts = 1;
}

let retryWaitSeconds = inputs['retry-wait-seconds'];
if (isNaN(retryWaitSeconds) || retryWaitSeconds < 0) {
core.warning(`Invalid retry-wait-seconds value '${inputs['retry-wait-seconds']}'. Using default: 5`);
retryWaitSeconds = 5;
}

let timeoutMinutes = inputs['timeout-minutes'];
if (isNaN(timeoutMinutes) || timeoutMinutes < 0) {
core.warning(`Invalid timeout-minutes value '${inputs['timeout-minutes']}'. Using default: 0`);
timeoutMinutes = 0;
}

let lastError: Error | undefined;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (maxAttempts > 1) {
core.info(`Build attempt ${attempt} of ${maxAttempts}`);
}

await executeBuildWithTimeout(buildCmd, inputs, timeoutMinutes);
return;
} catch (error) {
lastError = error as Error;
core.warning(`Build failed on attempt ${attempt}: ${lastError.message}`);

if (attempt < maxAttempts) {
if (retryWaitSeconds > 0) {
core.info(`Retrying in ${retryWaitSeconds} seconds...`);
await new Promise(resolve => setTimeout(resolve, retryWaitSeconds * 1000));
} else {
core.info('Retrying immediately...');
}
}
}
}

if (lastError) {
core.error(`All ${maxAttempts} attempts failed`);
throw lastError;
}
}

async function executeBuildWithTimeout(buildCmd: {command: string; args: string[]}, inputs: context.Inputs, timeoutMinutes: number): Promise<void> {
const buildPromise = Exec.getExecOutput(buildCmd.command, buildCmd.args, {
ignoreReturnCode: true,
env: Object.assign({}, process.env, {
BUILDX_METADATA_WARNINGS: 'true'
}) as {
[key: string]: string;
}
}).then(res => {
if (res.exitCode != 0) {
if (inputs.call && inputs.call === 'check' && res.stdout.length > 0) {
throw new Error(res.stdout.split('\n')[0]?.trim());
} else if (res.stderr.length > 0) {
throw new Error(`buildx failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
} else {
throw new Error('buildx failed with unknown error');
}
}
});

if (timeoutMinutes <= 0) {
return buildPromise;
}

let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(`Build attempt timed out after ${timeoutMinutes} minutes`)), timeoutMinutes * 60 * 1000);
});

return Promise.race([buildPromise, timeoutPromise]).finally(() => clearTimeout(timeoutHandle));
}

async function buildRef(toolkit: Toolkit, since: Date, builder?: string): Promise<string> {
// get ref from metadata file
const ref = toolkit.buildxBuild.resolveRef();
Expand Down