From 5d68eb843164a12fec59c14851f26bdcae632dbf Mon Sep 17 00:00:00 2001 From: kellertk Date: Thu, 11 Sep 2025 13:27:44 -0700 Subject: [PATCH] feat: Add global timeout support --- README.md | 1 + action.yml | 3 ++ src/index.ts | 15 ++++++++- test/index.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d0d4c11e..f33ce24f8 100644 --- a/README.md +++ b/README.md @@ -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 | #### Adjust the retry mechanism diff --git a/action.yml b/action.yml index ee1ee8650..8680a47e7 100644 --- a/action.yml +++ b/action.yml @@ -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: diff --git a/src/index.ts b/src/index.ts index 20d511e1d..ff56db7a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; } diff --git a/test/index.test.ts b/test/index.test.ts index 557ed006c..6df229d96 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -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));