feat: Add global timeout support (#1487)

This commit is contained in:
Tom Keller
2025-09-11 13:32:59 -07:00
committed by kellertk
parent 215dbfc2e0
commit a87e741eba
4 changed files with 98 additions and 1 deletions

View File

@@ -153,6 +153,7 @@ See [action.yml](./action.yml) for more detail.
| use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No | | use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No |
| 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 | | 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 |
| force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No | | force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No |
| 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 |
</details> </details>
#### Adjust the retry mechanism #### Adjust the retry mechanism

View File

@@ -92,6 +92,9 @@ inputs:
force-skip-oidc: force-skip-oidc:
required: false required: false
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. 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.
action-timeout-s:
required: false
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.
outputs: outputs:
aws-account-id: aws-account-id:

View File

@@ -58,6 +58,16 @@ export async function run() {
.map((s) => s.trim()); .map((s) => s.trim());
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false }); const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
const noProxy = core.getInput('no-proxy', { required: false }); const noProxy = core.getInput('no-proxy', { required: false });
const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0;
let timeoutId: NodeJS.Timeout | undefined;
if (globalTimeout > 0) {
core.info(`Setting a global timeout of ${globalTimeout} seconds for the action`);
timeoutId = setTimeout(() => {
core.setFailed(`Action timed out after ${globalTimeout} seconds`);
process.exit(1);
}, globalTimeout * 1000);
}
if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) { if (forceSkipOidc && roleToAssume && !AccessKeyId && !webIdentityTokenFile) {
throw new Error( throw new Error(
@@ -123,6 +133,7 @@ export async function run() {
const validCredentials = await areCredentialsValid(credentialsClient); const validCredentials = await areCredentialsValid(credentialsClient);
if (validCredentials) { if (validCredentials) {
core.notice('Pre-existing credentials are valid. No need to generate new ones.'); core.notice('Pre-existing credentials are valid. No need to generate new ones.');
if (timeoutId) clearTimeout(timeoutId);
return; return;
} }
core.notice('No valid credentials exist. Running as normal.'); core.notice('No valid credentials exist. Running as normal.');
@@ -209,11 +220,13 @@ export async function run() {
} else { } else {
core.info('Proceeding with IAM user credentials'); core.info('Proceeding with IAM user credentials');
} }
// Clear timeout on successful completion
if (timeoutId) clearTimeout(timeoutId);
} catch (error) { } catch (error) {
core.setFailed(errorMessage(error)); core.setFailed(errorMessage(error));
const showStackTrace = process.env.SHOW_STACK_TRACE; const showStackTrace = process.env.SHOW_STACK_TRACE;
if (showStackTrace === 'true') { if (showStackTrace === 'true') {
throw error; throw error;
} }

View File

@@ -646,6 +646,86 @@ describe('Configure AWS Credentials', {}, () => {
}); });
}); });
describe('Global Timeout Configuration', {}, () => {
beforeEach(() => {
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValueOnce({
accessKeyId: 'MYAWSACCESSKEYID',
});
});
it('sets timeout when action-timeout-s is provided', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const infoSpy = vi.spyOn(core, 'info');
vi.spyOn(core, 'getInput').mockImplementation(
mocks.getInput({
...mocks.IAM_USER_INPUTS,
'action-timeout-s': '30',
}),
);
await run();
expect(infoSpy).toHaveBeenCalledWith('Setting a global timeout of 30 seconds for the action');
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
expect(clearTimeoutSpy).toHaveBeenCalledWith(expect.any(Object));
expect(core.setFailed).not.toHaveBeenCalled();
});
it('does not set timeout when action-timeout-s is 0', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const infoSpy = vi.spyOn(core, 'info');
vi.spyOn(core, 'getInput').mockImplementation(
mocks.getInput({
...mocks.IAM_USER_INPUTS,
'action-timeout-s': '0',
}),
);
await run();
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
expect(setTimeoutSpy).not.toHaveBeenCalled();
expect(core.setFailed).not.toHaveBeenCalled();
});
it('does not set timeout when action-timeout-s is not provided', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const infoSpy = vi.spyOn(core, 'info');
await run();
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
expect(setTimeoutSpy).not.toHaveBeenCalled();
expect(core.setFailed).not.toHaveBeenCalled();
});
it('timeout callback calls setFailed and exits process', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
vi.spyOn(core, 'getInput').mockImplementation(
mocks.getInput({
...mocks.IAM_USER_INPUTS,
'action-timeout-s': '5',
}),
);
await run();
// Get the timeout callback function
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
// Execute the timeout callback
timeoutCallback();
expect(core.setFailed).toHaveBeenCalledWith('Action timed out after 5 seconds');
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
describe('HTTP Proxy Configuration', {}, () => { describe('HTTP Proxy Configuration', {}, () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS)); vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));