Skip to content

Commit e4bf8b6

Browse files
author
Mor Weinberger
committed
feat: Add retry mechanism with configurable attempts, wait time, and timeout
New input parameters: - max-attempts (default: 1) - Maximum number of build attempts - retry-wait-seconds (default: 0) - Delay between retry attempts - timeout-minutes (default: 0) - Timeout per attempt (0 = no timeout) Implementation: - Wraps build execution in retry loop with comprehensive logging - Adds timeout support per attempt using Promise.race() - Fully backward compatible (default values maintain current behavior) - Adds 2 test cases Signed-off-by: Mor Weinberger <test@example.com>
1 parent 9e436ba commit e4bf8b6

File tree

7 files changed

+201
-26
lines changed

7 files changed

+201
-26
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,28 @@ jobs:
153153
tags: user/app:latest
154154
```
155155

156+
### Retry on failure
157+
158+
Build can be configured to retry on failure with configurable attempts, wait time, and timeout:
159+
160+
```yaml
161+
-
162+
name: Build and push
163+
uses: docker/build-push-action@v6
164+
with:
165+
context: .
166+
push: true
167+
tags: user/app:latest
168+
max-attempts: 3
169+
retry-wait-seconds: 30
170+
timeout-minutes: 10
171+
```
172+
173+
This configuration will:
174+
- Make up to 3 attempts to build (initial attempt + 2 retries)
175+
- Wait 30 seconds between each retry attempt
176+
- Timeout each attempt after 10 minutes
177+
156178
## Examples
157179

158180
* [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:
258280
| `target` | String | Sets the target stage to build |
259281
| `ulimit` | List | [Ulimit](https://docs.docker.com/engine/reference/commandline/buildx_build/#ulimit) options (e.g., `nofile=1024:1024`) |
260282
| `github-token` | String | GitHub Token used to authenticate against a repository for [Git context](#git-context) (default `${{ github.token }}`) |
283+
| `max-attempts` | Number | Maximum number of build attempts (including initial attempt) (default `1`) |
284+
| `retry-wait-seconds` | Number | Number of seconds to wait between retry attempts (default `5`) |
285+
| `timeout-minutes` | Number | Timeout for each build attempt in minutes, `0` means no timeout (default `0`) |
261286

262287
### outputs
263288

__tests__/context.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,77 @@ ANOTHER_SECRET=ANOTHER_SECRET_ENV`]
879879
);
880880
});
881881

882+
describe('getInputs', () => {
883+
beforeEach(() => {
884+
process.env = Object.keys(process.env).reduce((object, key) => {
885+
if (!key.startsWith('INPUT_')) {
886+
object[key] = process.env[key];
887+
}
888+
return object;
889+
}, {});
890+
});
891+
892+
test('should parse retry inputs with default values', async () => {
893+
setInput('context', '.');
894+
setInput('load', 'false');
895+
setInput('no-cache', 'false');
896+
setInput('push', 'false');
897+
setInput('pull', 'false');
898+
setInput('max-attempts', '1');
899+
setInput('retry-wait-seconds', '5');
900+
setInput('timeout-minutes', '0');
901+
902+
const inputs = await context.getInputs();
903+
expect(inputs['max-attempts']).toBe(1);
904+
expect(inputs['retry-wait-seconds']).toBe(5);
905+
expect(inputs['timeout-minutes']).toBe(0);
906+
});
907+
908+
test('should parse retry inputs with custom values', async () => {
909+
setInput('context', '.');
910+
setInput('max-attempts', '3');
911+
setInput('retry-wait-seconds', '30');
912+
setInput('timeout-minutes', '10');
913+
setInput('load', 'false');
914+
setInput('no-cache', 'false');
915+
setInput('push', 'false');
916+
setInput('pull', 'false');
917+
918+
const inputs = await context.getInputs();
919+
expect(inputs['max-attempts']).toBe(3);
920+
expect(inputs['retry-wait-seconds']).toBe(30);
921+
expect(inputs['timeout-minutes']).toBe(10);
922+
});
923+
924+
test('should parse invalid retry inputs as NaN', async () => {
925+
setInput('context', '.');
926+
setInput('max-attempts', 'invalid');
927+
setInput('retry-wait-seconds', 'abc');
928+
setInput('load', 'false');
929+
setInput('no-cache', 'false');
930+
setInput('push', 'false');
931+
setInput('pull', 'false');
932+
933+
const inputs = await context.getInputs();
934+
expect(isNaN(inputs['max-attempts'])).toBe(true);
935+
expect(isNaN(inputs['retry-wait-seconds'])).toBe(true);
936+
});
937+
938+
test('should parse negative and zero values', async () => {
939+
setInput('context', '.');
940+
setInput('max-attempts', '0');
941+
setInput('retry-wait-seconds', '-10');
942+
setInput('load', 'false');
943+
setInput('no-cache', 'false');
944+
setInput('push', 'false');
945+
setInput('pull', 'false');
946+
947+
const inputs = await context.getInputs();
948+
expect(inputs['max-attempts']).toBe(0);
949+
expect(inputs['retry-wait-seconds']).toBe(-10);
950+
});
951+
});
952+
882953
// See: https://github.com/actions/toolkit/blob/a1b068ec31a042ff1e10a522d8fdf0b8869d53ca/packages/core/src/core.ts#L89
883954
function getInputName(name: string): string {
884955
return `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;

action.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ inputs:
111111
description: "GitHub Token used to authenticate against a repository for Git context"
112112
default: ${{ github.token }}
113113
required: false
114+
max-attempts:
115+
description: "Maximum number of build attempts (including initial attempt)"
116+
required: false
117+
default: '1'
118+
retry-wait-seconds:
119+
description: "Number of seconds to wait between retry attempts"
120+
required: false
121+
default: '5'
122+
timeout-minutes:
123+
description: "Timeout for each build attempt in minutes (0 means no timeout)"
124+
required: false
125+
default: '0'
114126

115127
outputs:
116128
imageid:

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/context.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export interface Inputs {
4141
target: string;
4242
ulimit: string[];
4343
'github-token': string;
44+
'max-attempts': number;
45+
'retry-wait-seconds': number;
46+
'timeout-minutes': number;
4447
}
4548

4649
export async function getInputs(): Promise<Inputs> {
@@ -77,7 +80,10 @@ export async function getInputs(): Promise<Inputs> {
7780
tags: Util.getInputList('tags'),
7881
target: core.getInput('target'),
7982
ulimit: Util.getInputList('ulimit', {ignoreComma: true}),
80-
'github-token': core.getInput('github-token')
83+
'github-token': core.getInput('github-token'),
84+
'max-attempts': Util.getInputNumber('max-attempts') ?? 1,
85+
'retry-wait-seconds': Util.getInputNumber('retry-wait-seconds') ?? 5,
86+
'timeout-minutes': Util.getInputNumber('timeout-minutes') ?? 0
8187
};
8288
}
8389

src/main.ts

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -97,25 +97,7 @@ actionsToolkit.run(
9797
core.debug(`buildCmd.command: ${buildCmd.command}`);
9898
core.debug(`buildCmd.args: ${JSON.stringify(buildCmd.args)}`);
9999

100-
let err: Error | undefined;
101-
await Exec.getExecOutput(buildCmd.command, buildCmd.args, {
102-
ignoreReturnCode: true,
103-
env: Object.assign({}, process.env, {
104-
BUILDX_METADATA_WARNINGS: 'true'
105-
}) as {
106-
[key: string]: string;
107-
}
108-
}).then(res => {
109-
if (res.exitCode != 0) {
110-
if (inputs.call && inputs.call === 'check' && res.stdout.length > 0) {
111-
// checks warnings are printed to stdout: https://github.com/docker/buildx/pull/2647
112-
// take the first line with the message summaryzing the warnings
113-
err = new Error(res.stdout.split('\n')[0]?.trim());
114-
} else if (res.stderr.length > 0) {
115-
err = new Error(`buildx failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
116-
}
117-
}
118-
});
100+
await executeBuildWithRetry(buildCmd, inputs);
119101

120102
const imageID = toolkit.buildxBuild.resolveImageID();
121103
const metadata = toolkit.buildxBuild.resolveMetadata();
@@ -182,10 +164,6 @@ actionsToolkit.run(
182164
stateHelper.setSummarySupported();
183165
}
184166
});
185-
186-
if (err) {
187-
throw err;
188-
}
189167
},
190168
// post
191169
async () => {
@@ -238,6 +216,89 @@ actionsToolkit.run(
238216
}
239217
);
240218

219+
async function executeBuildWithRetry(buildCmd: {command: string; args: string[]}, inputs: context.Inputs): Promise<void> {
220+
// Validate and sanitize retry inputs
221+
let maxAttempts = inputs['max-attempts'];
222+
if (isNaN(maxAttempts) || maxAttempts < 1) {
223+
core.warning(`Invalid max-attempts value '${inputs['max-attempts']}'. Using default: 1`);
224+
maxAttempts = 1;
225+
}
226+
227+
let retryWaitSeconds = inputs['retry-wait-seconds'];
228+
if (isNaN(retryWaitSeconds) || retryWaitSeconds < 0) {
229+
core.warning(`Invalid retry-wait-seconds value '${inputs['retry-wait-seconds']}'. Using default: 5`);
230+
retryWaitSeconds = 5;
231+
}
232+
233+
let timeoutMinutes = inputs['timeout-minutes'];
234+
if (isNaN(timeoutMinutes) || timeoutMinutes < 0) {
235+
core.warning(`Invalid timeout-minutes value '${inputs['timeout-minutes']}'. Using default: 0`);
236+
timeoutMinutes = 0;
237+
}
238+
239+
let lastError: Error | undefined;
240+
241+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
242+
try {
243+
if (maxAttempts > 1) {
244+
core.info(`Build attempt ${attempt} of ${maxAttempts}`);
245+
}
246+
247+
await executeBuildWithTimeout(buildCmd, inputs, timeoutMinutes);
248+
return;
249+
} catch (error) {
250+
lastError = error as Error;
251+
core.warning(`Build failed on attempt ${attempt}: ${lastError.message}`);
252+
253+
if (attempt < maxAttempts) {
254+
if (retryWaitSeconds > 0) {
255+
core.info(`Retrying in ${retryWaitSeconds} seconds...`);
256+
await new Promise(resolve => setTimeout(resolve, retryWaitSeconds * 1000));
257+
} else {
258+
core.info('Retrying immediately...');
259+
}
260+
}
261+
}
262+
}
263+
264+
if (lastError) {
265+
core.error(`All ${maxAttempts} attempts failed`);
266+
throw lastError;
267+
}
268+
}
269+
270+
async function executeBuildWithTimeout(buildCmd: {command: string; args: string[]}, inputs: context.Inputs, timeoutMinutes: number): Promise<void> {
271+
const buildPromise = Exec.getExecOutput(buildCmd.command, buildCmd.args, {
272+
ignoreReturnCode: true,
273+
env: Object.assign({}, process.env, {
274+
BUILDX_METADATA_WARNINGS: 'true'
275+
}) as {
276+
[key: string]: string;
277+
}
278+
}).then(res => {
279+
if (res.exitCode != 0) {
280+
if (inputs.call && inputs.call === 'check' && res.stdout.length > 0) {
281+
throw new Error(res.stdout.split('\n')[0]?.trim());
282+
} else if (res.stderr.length > 0) {
283+
throw new Error(`buildx failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
284+
} else {
285+
throw new Error('buildx failed with unknown error');
286+
}
287+
}
288+
});
289+
290+
if (timeoutMinutes <= 0) {
291+
return buildPromise;
292+
}
293+
294+
let timeoutHandle: NodeJS.Timeout;
295+
const timeoutPromise = new Promise<void>((_, reject) => {
296+
timeoutHandle = setTimeout(() => reject(new Error(`Build attempt timed out after ${timeoutMinutes} minutes`)), timeoutMinutes * 60 * 1000);
297+
});
298+
299+
return Promise.race([buildPromise, timeoutPromise]).finally(() => clearTimeout(timeoutHandle));
300+
}
301+
241302
async function buildRef(toolkit: Toolkit, since: Date, builder?: string): Promise<string> {
242303
// get ref from metadata file
243304
const ref = toolkit.buildxBuild.resolveRef();

0 commit comments

Comments
 (0)