mirror of
https://github.com/docker/build-push-action.git
synced 2025-11-07 06:56:55 +00:00
Compare commits
2 commits
fccdd4dba9
...
c4711988fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4711988fe | ||
|
|
edd725a94a |
7 changed files with 201 additions and 26 deletions
25
README.md
25
README.md
|
|
@ -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/)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
|
|
|
|||
12
action.yml
12
action.yml
|
|
@ -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:
|
||||
|
|
|
|||
2
dist/index.js
generated
vendored
2
dist/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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> {
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
107
src/main.ts
107
src/main.ts
|
|
@ -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();
|
||||
|
|
@ -182,10 +164,6 @@ actionsToolkit.run(
|
|||
stateHelper.setSummarySupported();
|
||||
}
|
||||
});
|
||||
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
// post
|
||||
async () => {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue