Skip to content

Commit 1584b8b

Browse files
authored
feat: Add global timeout support (#1487)
1 parent b845e0f commit 1584b8b

File tree

4 files changed

+98
-1
lines changed

4 files changed

+98
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ See [action.yml](./action.yml) for more detail.
152152
| use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No |
153153
| allowed-account-ids | A comma-delimited list of expected AWS account IDs. The action will fail if we receive credentials for the wrong account. | No |
154154
| force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No |
155+
| action-timeout-s | Global timeout for the action in seconds. If set to a value greater than 0, the action will fail if it takes longer than this time to complete. | No |
155156
</details>
156157

157158
#### Adjust the retry mechanism

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ inputs:
8989
force-skip-oidc:
9090
required: false
9191
description: When enabled, this option will skip using GitHub OIDC provider even if the id-token permission is set. This is sometimes useful when using IAM instance credentials.
92+
action-timeout-s:
93+
required: false
94+
description: A global timeout in seconds for the action. When the timeout is reached, the action immediately exits. The default is to run without a timeout.
9295

9396
outputs:
9497
aws-account-id:

src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ export async function run() {
5757
.map((s) => s.trim());
5858
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
5959
const noProxy = core.getInput('no-proxy', { required: false });
60+
const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0;
61+
62+
let timeoutId: NodeJS.Timeout | undefined;
63+
if (globalTimeout > 0) {
64+
core.info(`Setting a global timeout of ${globalTimeout} seconds for the action`);
65+
timeoutId = setTimeout(() => {
66+
core.setFailed(`Action timed out after ${globalTimeout} seconds`);
67+
process.exit(1);
68+
}, globalTimeout * 1000);
69+
}
6070

6171
if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) {
6272
throw new Error(
@@ -122,6 +132,7 @@ export async function run() {
122132
const validCredentials = await areCredentialsValid(credentialsClient);
123133
if (validCredentials) {
124134
core.notice('Pre-existing credentials are valid. No need to generate new ones.');
135+
if (timeoutId) clearTimeout(timeoutId);
125136
return;
126137
}
127138
core.notice('No valid credentials exist. Running as normal.');
@@ -207,11 +218,13 @@ export async function run() {
207218
} else {
208219
core.info('Proceeding with IAM user credentials');
209220
}
221+
222+
// Clear timeout on successful completion
223+
if (timeoutId) clearTimeout(timeoutId);
210224
} catch (error) {
211225
core.setFailed(errorMessage(error));
212226

213227
const showStackTrace = process.env.SHOW_STACK_TRACE;
214-
215228
if (showStackTrace === 'true') {
216229
throw error;
217230
}

test/index.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,86 @@ describe('Configure AWS Credentials', {}, () => {
646646
});
647647
});
648648

649+
describe('Global Timeout Configuration', {}, () => {
650+
beforeEach(() => {
651+
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
652+
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
653+
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
654+
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValueOnce({
655+
accessKeyId: 'MYAWSACCESSKEYID',
656+
});
657+
});
658+
659+
it('sets timeout when action-timeout-s is provided', async () => {
660+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
661+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
662+
const infoSpy = vi.spyOn(core, 'info');
663+
vi.spyOn(core, 'getInput').mockImplementation(
664+
mocks.getInput({
665+
...mocks.IAM_USER_INPUTS,
666+
'action-timeout-s': '30',
667+
}),
668+
);
669+
670+
await run();
671+
672+
expect(infoSpy).toHaveBeenCalledWith('Setting a global timeout of 30 seconds for the action');
673+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
674+
expect(clearTimeoutSpy).toHaveBeenCalledWith(expect.any(Object));
675+
expect(core.setFailed).not.toHaveBeenCalled();
676+
});
677+
678+
it('does not set timeout when action-timeout-s is 0', async () => {
679+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
680+
const infoSpy = vi.spyOn(core, 'info');
681+
vi.spyOn(core, 'getInput').mockImplementation(
682+
mocks.getInput({
683+
...mocks.IAM_USER_INPUTS,
684+
'action-timeout-s': '0',
685+
}),
686+
);
687+
688+
await run();
689+
690+
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
691+
expect(setTimeoutSpy).not.toHaveBeenCalled();
692+
expect(core.setFailed).not.toHaveBeenCalled();
693+
});
694+
695+
it('does not set timeout when action-timeout-s is not provided', async () => {
696+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
697+
const infoSpy = vi.spyOn(core, 'info');
698+
699+
await run();
700+
701+
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
702+
expect(setTimeoutSpy).not.toHaveBeenCalled();
703+
expect(core.setFailed).not.toHaveBeenCalled();
704+
});
705+
706+
it('timeout callback calls setFailed and exits process', async () => {
707+
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
708+
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
709+
vi.spyOn(core, 'getInput').mockImplementation(
710+
mocks.getInput({
711+
...mocks.IAM_USER_INPUTS,
712+
'action-timeout-s': '5',
713+
}),
714+
);
715+
716+
await run();
717+
718+
// Get the timeout callback function
719+
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
720+
721+
// Execute the timeout callback
722+
timeoutCallback();
723+
724+
expect(core.setFailed).toHaveBeenCalledWith('Action timed out after 5 seconds');
725+
expect(processExitSpy).toHaveBeenCalledWith(1);
726+
});
727+
});
728+
649729
describe('HTTP Proxy Configuration', {}, () => {
650730
beforeEach(() => {
651731
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));

0 commit comments

Comments
 (0)