From 3e9a4250e2d506860b6ad0cecf153301abd70b1b Mon Sep 17 00:00:00 2001 From: Simon Gallitscher Date: Tue, 26 May 2026 14:30:38 +0200 Subject: [PATCH 1/2] feat: add max-attempts and retry-mode input parameters Expose AWS SDK retry configuration as action inputs, allowing users to control retry behavior without relying on environment variables that bleed into other workflow steps. Co-Authored-By: Claude Opus 4.6 --- action.yml | 6 ++++++ dist/index.js | 23 ++++++++++++++++++++++- src/main.ts | 16 ++++++++++++++++ src/utils.ts | 18 ++++++++++++++++++ src/validation.ts | 7 +++++-- 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index 0d91c3d..fcf74a0 100644 --- a/action.yml +++ b/action.yml @@ -72,6 +72,12 @@ inputs: s3-prefix: description: "A prefix to use for the S3 object key when uploading the template. The final key will be '/'. Defaults to no prefix." required: false + max-attempts: + description: "The maximum number of retry attempts for AWS API calls. Defaults to the AWS SDK default (3)." + required: false + retry-mode: + description: "The retry mode for AWS API calls. Supported values: 'standard', 'adaptive'. Defaults to the AWS SDK default ('standard')." + required: false deployment-mode: description: "The deployment mode for the change set. Use 'REVERT_DRIFT' to create a change set that reverts drift. Defaults to standard deployment." required: false diff --git a/dist/index.js b/dist/index.js index 2cb6322..06b6acd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -75609,6 +75609,8 @@ function run() { 'deployment-mode': core.getInput('deployment-mode', { required: false }), 's3-bucket': core.getInput('s3-bucket', { required: false }), 's3-prefix': core.getInput('s3-prefix', { required: false }), + 'max-attempts': core.getInput('max-attempts', { required: false }), + 'retry-mode': core.getInput('retry-mode', { required: false }), 'execute-change-set-id': core.getInput('execute-change-set-id', { required: false }) @@ -75624,6 +75626,12 @@ function run() { }) }); } + if (inputs['max-attempts']) { + clientConfiguration = Object.assign(Object.assign({}, clientConfiguration), { maxAttempts: inputs['max-attempts'] }); + } + if (inputs['retry-mode']) { + clientConfiguration = Object.assign(Object.assign({}, clientConfiguration), { retryMode: inputs['retry-mode'] }); + } const cfn = new client_cloudformation_1.CloudFormationClient(Object.assign({}, clientConfiguration)); // Execute existing change set mode if (inputs.mode === 'execute-only') { @@ -75858,6 +75866,7 @@ exports.parseString = parseString; exports.parseNumber = parseNumber; exports.parseBoolean = parseBoolean; exports.parseParameters = parseParameters; +exports.parseRetryMode = parseRetryMode; exports.parseDeploymentMode = parseDeploymentMode; exports.withRetry = withRetry; exports.configureProxy = configureProxy; @@ -75969,6 +75978,16 @@ function parseParameters(parameterOverrides) { }; }); } +function parseRetryMode(s) { + const parsed = parseString(s); + if (!parsed) { + return undefined; + } + if (parsed === 'standard' || parsed === 'adaptive') { + return parsed; + } + throw new Error(`Invalid retry-mode: ${parsed}. Supported values: 'standard', 'adaptive'.`); +} function parseDeploymentMode(s) { const parsed = parseString(s); if (!parsed) { @@ -76048,7 +76067,9 @@ const baseSchema = zod_1.z.object({ .enum(['create-and-execute', 'create-only', 'execute-only']) .default('create-and-execute'), name: zod_1.z.string().min(1, 'Stack name is required'), - 'http-proxy': zod_1.z.string().optional().transform(emptyToUndefined) + 'http-proxy': zod_1.z.string().optional().transform(emptyToUndefined), + 'max-attempts': zod_1.z.string().optional().transform(utils_1.parseNumber), + 'retry-mode': zod_1.z.string().optional().transform(utils_1.parseRetryMode) }); const createSchema = baseSchema.extend({ mode: zod_1.z.enum(['create-and-execute', 'create-only']), diff --git a/src/main.ts b/src/main.ts index 6510d25..0df807c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -92,6 +92,8 @@ export async function run(): Promise { 'deployment-mode': core.getInput('deployment-mode', { required: false }), 's3-bucket': core.getInput('s3-bucket', { required: false }), 's3-prefix': core.getInput('s3-prefix', { required: false }), + 'max-attempts': core.getInput('max-attempts', { required: false }), + 'retry-mode': core.getInput('retry-mode', { required: false }), 'execute-change-set-id': core.getInput('execute-change-set-id', { required: false }) @@ -113,6 +115,20 @@ export async function run(): Promise { } } + if (inputs['max-attempts']) { + clientConfiguration = { + ...clientConfiguration, + ...{ maxAttempts: inputs['max-attempts'] } + } + } + + if (inputs['retry-mode']) { + clientConfiguration = { + ...clientConfiguration, + ...{ retryMode: inputs['retry-mode'] } + } + } + const cfn = new CloudFormationClient({ ...clientConfiguration }) // Execute existing change set mode diff --git a/src/utils.ts b/src/utils.ts index 5d63d78..1188b41 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -118,6 +118,24 @@ export function parseParameters( }) } +export function parseRetryMode( + s?: string +): 'standard' | 'adaptive' | undefined { + const parsed = parseString(s) + + if (!parsed) { + return undefined + } + + if (parsed === 'standard' || parsed === 'adaptive') { + return parsed + } + + throw new Error( + `Invalid retry-mode: ${parsed}. Supported values: 'standard', 'adaptive'.` + ) +} + export function parseDeploymentMode(s: string): 'REVERT_DRIFT' | undefined { const parsed = parseString(s) diff --git a/src/validation.ts b/src/validation.ts index 8dd95af..b948cef 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -4,7 +4,8 @@ import { parseNumber, parseTags, parseParameters, - parseBoolean + parseBoolean, + parseRetryMode } from './utils' // Helper transformers @@ -16,7 +17,9 @@ const baseSchema = z.object({ .enum(['create-and-execute', 'create-only', 'execute-only']) .default('create-and-execute'), name: z.string().min(1, 'Stack name is required'), - 'http-proxy': z.string().optional().transform(emptyToUndefined) + 'http-proxy': z.string().optional().transform(emptyToUndefined), + 'max-attempts': z.string().optional().transform(parseNumber), + 'retry-mode': z.string().optional().transform(parseRetryMode) }) const createSchema = baseSchema.extend({ From 1737b31a62bc47b6031aa53b678a5fab39c0829b Mon Sep 17 00:00:00 2001 From: Simon Gallitscher Date: Wed, 27 May 2026 09:22:15 +0200 Subject: [PATCH 2/2] test: add unit tests for parseRetryMode and validation schema Address PR review feedback requesting test coverage for the new retry configuration inputs. Co-Authored-By: Claude Opus 4.6 --- __tests__/utils.test.ts | 25 ++++++++++ __tests__/validation.test.ts | 90 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 __tests__/validation.test.ts diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 9373443..129cb05 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -4,6 +4,7 @@ import { isUrl, parseParameters, parseBoolean, + parseRetryMode, withRetry } from '../src/utils' import * as path from 'path' @@ -473,6 +474,30 @@ describe('withRetry', () => { }) }) +describe('parseRetryMode', () => { + test('returns "standard" for valid input', () => { + expect(parseRetryMode('standard')).toBe('standard') + }) + + test('returns "adaptive" for valid input', () => { + expect(parseRetryMode('adaptive')).toBe('adaptive') + }) + + test('throws on invalid input', () => { + expect(() => parseRetryMode('exponential')).toThrow( + "Invalid retry-mode: exponential. Supported values: 'standard', 'adaptive'." + ) + }) + + test('returns undefined for empty string', () => { + expect(parseRetryMode('')).toBeUndefined() + }) + + test('returns undefined for undefined', () => { + expect(parseRetryMode(undefined)).toBeUndefined() + }) +}) + describe('Configure Proxy', () => { beforeEach(() => { jest.clearAllMocks() diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts new file mode 100644 index 0000000..2d74825 --- /dev/null +++ b/__tests__/validation.test.ts @@ -0,0 +1,90 @@ +import { validateAndParseInputs } from '../src/validation' + +const baseInputs = { + mode: 'create-and-execute', + name: 'TestStack', + template: 'template.yaml', + capabilities: 'CAPABILITY_IAM', + 'parameter-overrides': '', + 'fail-on-empty-changeset': '1', + 'no-execute-changeset': '0', + 'no-delete-failed-changeset': '0', + 'disable-rollback': '0', + 'timeout-in-minutes': '', + 'notification-arns': '', + 'role-arn': '', + tags: '', + 'termination-protection': '', + 'http-proxy': '', + 'change-set-name': '', + 'include-nested-stacks-change-set': '0', + 'deployment-mode': '', + 's3-bucket': '', + 's3-prefix': '', + 'execute-change-set-id': '', + 'max-attempts': '', + 'retry-mode': '' +} + +describe('validateAndParseInputs', () => { + describe('max-attempts', () => { + test('parses a valid number string', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'max-attempts': '10' + }) + expect(result['max-attempts']).toBe(10) + }) + + test('returns undefined for empty string', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'max-attempts': '' + }) + expect(result['max-attempts']).toBeUndefined() + }) + + test('returns undefined when not provided', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'max-attempts': undefined + }) + expect(result['max-attempts']).toBeUndefined() + }) + }) + + describe('retry-mode', () => { + test('parses "standard" correctly', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'retry-mode': 'standard' + }) + expect(result['retry-mode']).toBe('standard') + }) + + test('parses "adaptive" correctly', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'retry-mode': 'adaptive' + }) + expect(result['retry-mode']).toBe('adaptive') + }) + + test('returns undefined for empty string', () => { + const result = validateAndParseInputs({ + ...baseInputs, + 'retry-mode': '' + }) + expect(result['retry-mode']).toBeUndefined() + }) + + test('throws on invalid retry-mode', () => { + expect(() => + validateAndParseInputs({ + ...baseInputs, + 'retry-mode': 'exponential' + }) + ).toThrow() + }) + }) +})