feat: Add global timeout support (#1487)

This commit is contained in:
Tom Keller
2025-09-11 13:32:59 -07:00
committed by GitHub
parent b845e0f4f4
commit 1584b8b0e2
4 changed files with 98 additions and 1 deletions

View File

@@ -152,6 +152,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 |
| 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 |
| 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>
#### Adjust the retry mechanism

View File

@@ -89,6 +89,9 @@ inputs:
force-skip-oidc:
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.
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:
aws-account-id:

View File

@@ -57,6 +57,16 @@ export async function run() {
.map((s) => s.trim());
const forceSkipOidc = getBooleanInput('force-skip-oidc', { 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) {
throw new Error(
@@ -122,6 +132,7 @@ export async function run() {
const validCredentials = await areCredentialsValid(credentialsClient);
if (validCredentials) {
core.notice('Pre-existing credentials are valid. No need to generate new ones.');
if (timeoutId) clearTimeout(timeoutId);
return;
}
core.notice('No valid credentials exist. Running as normal.');
@@ -207,11 +218,13 @@ export async function run() {
} else {
core.info('Proceeding with IAM user credentials');
}
// Clear timeout on successful completion
if (timeoutId) clearTimeout(timeoutId);
} catch (error) {
core.setFailed(errorMessage(error));
const showStackTrace = process.env.SHOW_STACK_TRACE;
if (showStackTrace === 'true') {
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', {}, () => {
beforeEach(() => {
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));