diff --git a/__tests__/integration/integration.test.ts b/__tests__/integration/integration.test.ts index 48fc1ffd..5cab57c8 100644 --- a/__tests__/integration/integration.test.ts +++ b/__tests__/integration/integration.test.ts @@ -18,6 +18,8 @@ import { RunCondition, RunConditionForAction, ServerTask, + ServerTaskRepository, + SpaceServerTaskRepository, StartTrigger, ServerTaskWaiter } from '@octopusdeploy/api-client' @@ -184,6 +186,60 @@ describe('integration tests', () => { } }) + test('can deploy a release with scheduled start time', async () => { + const output = new CaptureOutput() + + const logger: Logger = { + debug: message => output.debug(message), + info: message => output.info(message), + warn: message => output.warn(message), + error: (message, err) => { + if (err !== undefined) { + output.error(err.message) + } else { + output.error(message) + } + } + } + + const config: ClientConfiguration = { + userAgentApp: 'Test', + instanceURL: apiClientConfig.instanceURL, + apiKey: apiClientConfig.apiKey, + logging: logger + } + + const client = await Client.create(config) + + await createReleaseForTest(client) + const runAt = new Date(Date.now() + 5 * 60 * 1000) + const noRunAfter = new Date(Date.now() + 10 * 60 * 1000) + + const result = await createDeploymentFromInputs(client, { + ...standardInputParameters, + releaseNumber: localReleaseNumber, + environments: ['Dev'], + runAt, + noRunAfter + }) + + expect(result.length).toBe(1) + expect(result[0].serverTaskId).toContain('ServerTasks-') + expect(output.getAllMessages()).toContain(`[INFO] 🎉 1 Deployment queued successfully!`) + + const spaceTaskRepository = new SpaceServerTaskRepository(client, spaceName) + for (const { serverTaskId } of result) { + const task = await spaceTaskRepository.getById(serverTaskId) + expect(new Date(task.QueueTime!)).toStrictEqual(runAt) + expect(new Date(task.QueueTimeExpiry!)).toStrictEqual(noRunAfter) + } + + const taskRepository = new ServerTaskRepository(client) + for (const { serverTaskId } of result) { + await taskRepository.cancel(serverTaskId) + } + }) + test('can deploy a release', async () => { const output = new CaptureOutput() diff --git a/__tests__/unit/input-parsing.test.ts b/__tests__/unit/input-parsing.test.ts index 3026358b..4e784e84 100644 --- a/__tests__/unit/input-parsing.test.ts +++ b/__tests__/unit/input-parsing.test.ts @@ -9,4 +9,42 @@ test('get input parameters', () => { expect(inputParameters.variables).toBeDefined() expect(inputParameters.variables?.['foo']).toBe('quux') expect(inputParameters.variables?.['bar']).toBe('xyzzy') + expect(inputParameters.runAt).toBeUndefined() + expect(inputParameters.noRunAfter).toBeUndefined() +}) + +test('deploy_at and deploy_at_expiry are parsed as dates', () => { + const original = process.env + process.env = Object.assign({}, process.env, { + INPUT_DEPLOY_AT: '2026-04-02T09:00:00+10:00', + INPUT_DEPLOY_AT_EXPIRY: '2026-04-02T17:00:00+10:00' + }) + + const inputParameters = getInputParameters() + expect(inputParameters.runAt).toStrictEqual(new Date('2026-04-02T09:00:00+10:00')) + expect(inputParameters.noRunAfter).toStrictEqual(new Date('2026-04-02T17:00:00+10:00')) + + process.env = original +}) + +test('invalid deploy_at throws error', () => { + const original = process.env + process.env = Object.assign({}, process.env, { + INPUT_DEPLOY_AT: 'notadate' + }) + + expect(() => getInputParameters()).toThrowError("deploy_at 'notadate' is not a valid ISO 8601 date-time string.") + + process.env = original +}) + +test('invalid deploy_at_expiry throws error', () => { + const original = process.env + process.env = Object.assign({}, process.env, { + INPUT_DEPLOY_AT_EXPIRY: 'notadate' + }) + + expect(() => getInputParameters()).toThrowError("deploy_at_expiry 'notadate' is not a valid ISO 8601 date-time string.") + + process.env = original }) diff --git a/action.yml b/action.yml index 574e9f18..c2b0e9f8 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,10 @@ inputs: description: 'Whether to use guided failure mode if errors occur during the deployment.' variables: description: 'A multi-line list of prompted variable values. Format: name:value' + deploy_at: + description: 'Schedule the deployment to run at a specific time. Provide an ISO 8601 date-time string (e.g. 2026-04-01T09:00:00+10:00). Leave blank to deploy immediately.' + deploy_at_expiry: + description: 'Cancel the deployment if it has not started by this time. Provide an ISO 8601 date-time string. Leave blank for no expiry.' server: description: 'The instance URL hosting Octopus Deploy (i.e. "https://octopus.example.com/"). The instance URL is required, but you may also use the OCTOPUS_URL environment variable.' api_key: diff --git a/src/api-wrapper.ts b/src/api-wrapper.ts index 2b0e83a9..9fa3da7f 100644 --- a/src/api-wrapper.ts +++ b/src/api-wrapper.ts @@ -27,7 +27,9 @@ export async function createDeploymentFromInputs( ReleaseVersion: parameters.releaseNumber, EnvironmentNames: parameters.environments, UseGuidedFailure: parameters.useGuidedFailure, - Variables: parameters.variables + Variables: parameters.variables, + RunAt: parameters.runAt, + NoRunAfter: parameters.noRunAfter } const deploymentRepository = new DeploymentRepository(client, parameters.space) diff --git a/src/input-parameters.ts b/src/input-parameters.ts index 87e4f133..514de7e7 100644 --- a/src/input-parameters.ts +++ b/src/input-parameters.ts @@ -25,6 +25,8 @@ export interface InputParameters { // Optional useGuidedFailure?: boolean variables?: PromptedVariableValues + runAt?: Date + noRunAfter?: Date } export function getInputParameters(): InputParameters { @@ -47,7 +49,9 @@ export function getInputParameters(): InputParameters { releaseNumber: getInput('release_number', { required: true }), environments: getMultilineInput('environments', { required: true }).map(p => p.trim()), useGuidedFailure: getBooleanInput('use_guided_failure') || undefined, - variables: variablesMap + variables: variablesMap, + runAt: getInput('deploy_at') ? new Date(getInput('deploy_at')) : undefined, + noRunAfter: getInput('deploy_at_expiry') ? new Date(getInput('deploy_at_expiry')) : undefined } const errors: string[] = [] @@ -69,6 +73,16 @@ export function getInputParameters(): InputParameters { ) } + const deployAt = getInput('deploy_at') + if (deployAt && isNaN(new Date(deployAt).getTime())) { + errors.push(`deploy_at '${deployAt}' is not a valid ISO 8601 date-time string.`) + } + + const deployAtExpiry = getInput('deploy_at_expiry') + if (deployAtExpiry && isNaN(new Date(deployAtExpiry).getTime())) { + errors.push(`deploy_at_expiry '${deployAtExpiry}' is not a valid ISO 8601 date-time string.`) + } + if (errors.length > 0) { throw new Error(errors.join('\n')) }