feat(inputs): add dry run support (#76)

This commit is contained in:
2025-12-23 21:31:53 +00:00
committed by GitHub
parent bb3e44be2f
commit ef41989077
11 changed files with 642 additions and 67 deletions

View File

@@ -110,6 +110,7 @@ jobs:
| `ref` | <p>The SHA or ref to tag. Defaults to SHA of current commit.</p> | `false` | `${{ github.sha }}` |
| `when_exists` | <p>What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'.</p> | `false` | `update` |
| `annotation` | <p>Optional annotation message for tags. If provided, creates annotated tags. If empty, creates lightweight tags.</p> | `false` | `""` |
| `dry_run` | <p>If true, logs planned operations without executing them.</p> | `false` | `false` |
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
<!-- action-docs-inputs source="action.yml" -->

View File

@@ -26,6 +26,11 @@ inputs:
If empty, creates lightweight tags.
required: false
default: ""
dry_run:
description: >-
If true, logs planned operations without executing them.
required: false
default: "false"
github_token:
description: "The GitHub token to use for authentication."
required: false

114
dist/index.js generated vendored
View File

@@ -29483,7 +29483,7 @@ var noop = () => {
};
var consoleWarn = console.warn.bind(console);
var consoleError = console.error.bind(console);
function createLogger(logger = {}) {
function createLogger$1(logger = {}) {
if (typeof logger.debug !== "function") {
logger.debug = noop;
}
@@ -29571,7 +29571,7 @@ var Octokit = class {
}
this.request = request.defaults(requestDefaults);
this.graphql = withCustomRequest(this.request).defaults(requestDefaults);
this.log = createLogger(options.log);
this.log = createLogger$1(options.log);
this.hook = hook;
if (!options.authStrategy) {
if (!options.auth) {
@@ -34055,6 +34055,7 @@ function getInputs() {
const whenExistsInput = coreExports.getInput('when_exists') || 'update';
const whenExists = validateWhenExists(whenExistsInput);
const annotation = coreExports.getInput('annotation');
const dryRun = coreExports.getBooleanInput('dry_run');
const token = coreExports.getInput('github_token', {
required: true
});
@@ -34070,12 +34071,43 @@ function getInputs() {
defaultRef,
whenExists,
annotation,
dryRun,
owner,
repo,
token
};
}
/**
* Create a logger that optionally prefixes all messages.
*
* @param prefix - Optional prefix to prepend to all log messages
* @returns Logger instance with prefixed messages
*/
function createLogger(prefix = '') {
const prefixMessage = (msg) => {
if (!prefix) {
return msg;
}
if (typeof msg === 'string') {
return `${prefix}${msg}`;
}
// Wrap Error with prefixed message, preserving the original as cause
const wrapped = new Error(`${prefix}${msg.message}`, { cause: msg });
if (msg.stack) {
wrapped.stack = msg.stack;
}
return wrapped;
};
return {
debug: (message) => coreExports.debug(`${prefix}${message}`),
info: (message) => coreExports.info(`${prefix}${message}`),
notice: (message, properties) => coreExports.notice(prefixMessage(message), properties),
warning: (message, properties) => coreExports.warning(prefixMessage(message), properties),
error: (message, properties) => coreExports.error(prefixMessage(message), properties)
};
}
/**
* Plan tag operations based on inputs.
*
@@ -34198,21 +34230,19 @@ async function planTagOperations(inputs, octokit) {
*
* @param operation - The planned tag operation to execute
* @param octokit - GitHub API client
* @param options - Execution options (e.g., dryRun)
*/
async function executeTagOperation(operation, octokit) {
async function executeTagOperation(operation, octokit, options = {}) {
const dryRun = options.dryRun ?? false;
const ctx = {
owner: operation.owner,
repo: operation.repo,
octokit
octokit,
dryRun,
log: createLogger(dryRun ? '[dry-run] ' : '')
};
if (operation.operation === 'skip') {
if (operation.reason === 'when_exists_skip') {
coreExports.info(`Tag '${operation.name}' exists, skipping.`);
}
else {
coreExports.info(`Tag '${operation.name}' already exists with desired commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.'));
}
logSkipOperation(ctx, operation);
return;
}
if (operation.operation === 'create') {
@@ -34225,10 +34255,23 @@ async function executeTagOperation(operation, octokit) {
}
throw new Error(`Unknown operation type: ${operation.operation}`);
}
/**
* Log a skip operation.
*/
function logSkipOperation(ctx, operation) {
if (operation.reason === 'when_exists_skip') {
ctx.log.info(`Tag '${operation.name}' exists, skipping.`);
}
else {
ctx.log.info(`Tag '${operation.name}' already exists with desired ` +
`commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.'));
}
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param tagName - The name of the tag to fetch
* @returns Information about the existing tag
*/
@@ -34261,7 +34304,7 @@ async function fetchTagInfo(ctx, tagName) {
/**
* Resolve a ref to a SHA.
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param ref - The ref to resolve
* @returns The SHA
*/
@@ -34283,13 +34326,17 @@ async function resolveRefToSha(ctx, ref) {
*/
async function updateExistingTag(ctx, operation) {
const commitMatches = operation.existingSHA === operation.sha;
const verb = ctx.dryRun ? 'Would update' : 'Updating';
if (commitMatches) {
coreExports.info(`Tag '${operation.name}' exists with same commit but ${operation.reasons.join(', ')}.`);
ctx.log.info(`${verb} tag '${operation.name}', ${operation.reasons.join(', ')}.`);
}
else {
coreExports.info(`Tag '${operation.name}' exists` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''}` +
`, updating to ${operation.reasons.join(', ')}.`);
ctx.log.info(`${verb} tag '${operation.name}'` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''} ` +
`to ${operation.reasons.join(', ')}.`);
}
if (ctx.dryRun) {
return;
}
const targetSha = await resolveTargetSHA(ctx, operation.name, operation.sha, operation.annotation);
await ctx.octokit.rest.git.updateRef({
@@ -34304,7 +34351,12 @@ async function updateExistingTag(ctx, operation) {
* Create a tag (doesn't exist yet).
*/
async function createTag(ctx, operation) {
coreExports.info(`Tag '${operation.name}' does not exist, creating with commit SHA ${operation.sha}.`);
const verb = ctx.dryRun ? 'Would create' : 'Creating';
ctx.log.info(`${verb} tag '${operation.name}' at commit SHA ${operation.sha}` +
(operation.annotation ? ' (annotated).' : '.'));
if (ctx.dryRun) {
return;
}
const targetSha = await resolveTargetSHA(ctx, operation.name, operation.sha, operation.annotation);
await ctx.octokit.rest.git.createRef({
owner: ctx.owner,
@@ -34316,7 +34368,7 @@ async function createTag(ctx, operation) {
/**
* Resolve the target SHA for a tag (creates annotated tag object if needed).
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param tagName - The tag name
* @param commitSha - The commit SHA
* @param annotation - The annotation message (if any)
@@ -34392,20 +34444,26 @@ async function run() {
const inputs = getInputs();
const octokit = githubExports.getOctokit(inputs.token);
const operations = await planTagOperations(inputs, octokit);
if (inputs.dryRun) {
coreExports.info('[dry-run] Dry-run mode enabled, no changes will be made.');
}
const created = [];
const updated = [];
const skipped = [];
// Execute all planned operations.
for (const operation of operations) {
await executeTagOperation(operation, octokit);
if (operation.operation === 'create') {
created.push(operation.name);
}
else if (operation.operation === 'update') {
updated.push(operation.name);
}
else if (operation.operation === 'skip') {
skipped.push(operation.name);
await executeTagOperation(operation, octokit, { dryRun: inputs.dryRun });
// Only track actual changes when not in dry-run mode
if (!inputs.dryRun) {
if (operation.operation === 'create') {
created.push(operation.name);
}
else if (operation.operation === 'update') {
updated.push(operation.name);
}
else if (operation.operation === 'skip') {
skipped.push(operation.name);
}
}
}
coreExports.setOutput('created', created);

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,7 @@ export interface Inputs {
defaultRef: string
whenExists: WhenExistsMode
annotation: string
dryRun: boolean
owner: string
repo: string
token: string
@@ -43,6 +44,7 @@ export function getInputs(): Inputs {
const whenExistsInput = core.getInput('when_exists') || 'update'
const whenExists = validateWhenExists(whenExistsInput)
const annotation: string = core.getInput('annotation')
const dryRun: boolean = core.getBooleanInput('dry_run')
const token: string = core.getInput('github_token', {
required: true
})
@@ -63,6 +65,7 @@ export function getInputs(): Inputs {
defaultRef,
whenExists,
annotation,
dryRun,
owner,
repo,
token

47
src/logger.ts Normal file
View File

@@ -0,0 +1,47 @@
import * as core from '@actions/core'
import type { AnnotationProperties } from '@actions/core'
/**
* Logger interface that mirrors @actions/core logging functions.
*/
export interface Logger {
debug: (message: string) => void
info: (message: string) => void
notice: (message: string | Error, properties?: AnnotationProperties) => void
warning: (message: string | Error, properties?: AnnotationProperties) => void
error: (message: string | Error, properties?: AnnotationProperties) => void
}
/**
* Create a logger that optionally prefixes all messages.
*
* @param prefix - Optional prefix to prepend to all log messages
* @returns Logger instance with prefixed messages
*/
export function createLogger(prefix: string = ''): Logger {
const prefixMessage = (msg: string | Error): string | Error => {
if (!prefix) {
return msg
}
if (typeof msg === 'string') {
return `${prefix}${msg}`
}
// Wrap Error with prefixed message, preserving the original as cause
const wrapped = new Error(`${prefix}${msg.message}`, { cause: msg })
if (msg.stack) {
wrapped.stack = msg.stack
}
return wrapped
}
return {
debug: (message: string) => core.debug(`${prefix}${message}`),
info: (message: string) => core.info(`${prefix}${message}`),
notice: (message: string | Error, properties?: AnnotationProperties) =>
core.notice(prefixMessage(message), properties),
warning: (message: string | Error, properties?: AnnotationProperties) =>
core.warning(prefixMessage(message), properties),
error: (message: string | Error, properties?: AnnotationProperties) =>
core.error(prefixMessage(message), properties)
}
}

View File

@@ -14,20 +14,27 @@ export async function run(): Promise<void> {
const octokit = github.getOctokit(inputs.token)
const operations = await planTagOperations(inputs, octokit)
if (inputs.dryRun) {
core.info('[dry-run] Dry-run mode enabled, no changes will be made.')
}
const created: string[] = []
const updated: string[] = []
const skipped: string[] = []
// Execute all planned operations.
for (const operation of operations) {
await executeTagOperation(operation, octokit)
await executeTagOperation(operation, octokit, { dryRun: inputs.dryRun })
if (operation.operation === 'create') {
created.push(operation.name)
} else if (operation.operation === 'update') {
updated.push(operation.name)
} else if (operation.operation === 'skip') {
skipped.push(operation.name)
// Only track actual changes when not in dry-run mode
if (!inputs.dryRun) {
if (operation.operation === 'create') {
created.push(operation.name)
} else if (operation.operation === 'update') {
updated.push(operation.name)
} else if (operation.operation === 'skip') {
skipped.push(operation.name)
}
}
}

View File

@@ -1,6 +1,6 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import type { Inputs } from './inputs.js'
import { createLogger, type Logger } from './logger.js'
export interface ExistingTagInfo {
commitSHA: string
@@ -37,12 +37,21 @@ export interface SkipOperation extends BaseOperation {
export type TagOperation = CreateOperation | UpdateOperation | SkipOperation
interface Context {
export interface ExecuteOptions {
dryRun?: boolean
}
interface ReadContext {
owner: string
repo: string
octokit: ReturnType<typeof github.getOctokit>
}
interface Context extends ReadContext {
dryRun: boolean
log: Logger
}
/**
* Plan tag operations based on inputs.
*
@@ -92,7 +101,7 @@ export async function planTagOperations(
}
// Pre-resolve all unique refs in parallel.
const ctx: Context = { owner: inputs.owner, repo: inputs.repo, octokit }
const ctx: ReadContext = { owner: inputs.owner, repo: inputs.repo, octokit }
const refSHAs: Record<string, string> = {}
await Promise.all(
Array.from(uniqueRefs).map(async (ref) => {
@@ -197,26 +206,24 @@ export async function planTagOperations(
*
* @param operation - The planned tag operation to execute
* @param octokit - GitHub API client
* @param options - Execution options (e.g., dryRun)
*/
export async function executeTagOperation(
operation: TagOperation,
octokit: ReturnType<typeof github.getOctokit>
octokit: ReturnType<typeof github.getOctokit>,
options: ExecuteOptions = {}
): Promise<void> {
const dryRun = options.dryRun ?? false
const ctx: Context = {
owner: operation.owner,
repo: operation.repo,
octokit
octokit,
dryRun,
log: createLogger(dryRun ? '[dry-run] ' : '')
}
if (operation.operation === 'skip') {
if (operation.reason === 'when_exists_skip') {
core.info(`Tag '${operation.name}' exists, skipping.`)
} else {
core.info(
`Tag '${operation.name}' already exists with desired commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.')
)
}
logSkipOperation(ctx, operation)
return
}
@@ -235,15 +242,30 @@ export async function executeTagOperation(
)
}
/**
* Log a skip operation.
*/
function logSkipOperation(ctx: Context, operation: SkipOperation): void {
if (operation.reason === 'when_exists_skip') {
ctx.log.info(`Tag '${operation.name}' exists, skipping.`)
} else {
ctx.log.info(
`Tag '${operation.name}' already exists with desired ` +
`commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.')
)
}
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param tagName - The name of the tag to fetch
* @returns Information about the existing tag
*/
async function fetchTagInfo(
ctx: Context,
ctx: ReadContext,
tagName: string
): Promise<ExistingTagInfo> {
const ref = await ctx.octokit.rest.git.getRef({
@@ -278,11 +300,11 @@ async function fetchTagInfo(
/**
* Resolve a ref to a SHA.
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param ref - The ref to resolve
* @returns The SHA
*/
async function resolveRefToSha(ctx: Context, ref: string): Promise<string> {
async function resolveRefToSha(ctx: ReadContext, ref: string): Promise<string> {
try {
const {
data: { sha }
@@ -306,19 +328,24 @@ async function updateExistingTag(
operation: UpdateOperation
): Promise<void> {
const commitMatches = operation.existingSHA === operation.sha
const verb = ctx.dryRun ? 'Would update' : 'Updating'
if (commitMatches) {
core.info(
`Tag '${operation.name}' exists with same commit but ${operation.reasons.join(', ')}.`
ctx.log.info(
`${verb} tag '${operation.name}', ${operation.reasons.join(', ')}.`
)
} else {
core.info(
`Tag '${operation.name}' exists` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''}` +
`, updating to ${operation.reasons.join(', ')}.`
ctx.log.info(
`${verb} tag '${operation.name}'` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''} ` +
`to ${operation.reasons.join(', ')}.`
)
}
if (ctx.dryRun) {
return
}
const targetSha = await resolveTargetSHA(
ctx,
operation.name,
@@ -342,10 +369,16 @@ async function createTag(
ctx: Context,
operation: CreateOperation
): Promise<void> {
core.info(
`Tag '${operation.name}' does not exist, creating with commit SHA ${operation.sha}.`
const verb = ctx.dryRun ? 'Would create' : 'Creating'
ctx.log.info(
`${verb} tag '${operation.name}' at commit SHA ${operation.sha}` +
(operation.annotation ? ' (annotated).' : '.')
)
if (ctx.dryRun) {
return
}
const targetSha = await resolveTargetSHA(
ctx,
operation.name,
@@ -364,14 +397,14 @@ async function createTag(
/**
* Resolve the target SHA for a tag (creates annotated tag object if needed).
*
* @param ctx - Operation context
* @param ctx - Read-only operation context
* @param tagName - The tag name
* @param commitSha - The commit SHA
* @param annotation - The annotation message (if any)
* @returns The SHA to use (tag object SHA if annotated, commit SHA otherwise)
*/
async function resolveTargetSHA(
ctx: Context,
ctx: ReadContext,
tagName: string,
commitSha: string,
annotation: string

View File

@@ -4,7 +4,9 @@ import { jest } from '@jest/globals'
export const debug = jest.fn<typeof core.debug>()
export const error = jest.fn<typeof core.error>()
export const info = jest.fn<typeof core.info>()
export const notice = jest.fn<typeof core.notice>()
export const getInput = jest.fn<typeof core.getInput>()
export const getBooleanInput = jest.fn<typeof core.getBooleanInput>()
export const setOutput = jest.fn<typeof core.setOutput>()
export const setFailed = jest.fn<typeof core.setFailed>()
export const warning = jest.fn<typeof core.warning>()

193
tests/logger.test.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Unit tests for the logger module, src/logger.ts
*/
import { jest } from '@jest/globals'
import * as core from './fixtures/core.js'
jest.unstable_mockModule('@actions/core', () => core)
const { createLogger } = await import('../src/logger.js')
describe('createLogger', () => {
beforeEach(() => {
jest.resetAllMocks()
})
describe('without prefix', () => {
it('calls core.debug with the message', () => {
const log = createLogger()
log.debug('test debug message')
expect(core.debug).toHaveBeenCalledWith('test debug message')
})
it('calls core.info with the message', () => {
const log = createLogger()
log.info('test info message')
expect(core.info).toHaveBeenCalledWith('test info message')
})
it('calls core.notice with string message', () => {
const log = createLogger()
log.notice('test notice message')
expect(core.notice).toHaveBeenCalledWith('test notice message', undefined)
})
it('calls core.notice with Error unchanged', () => {
const log = createLogger()
const error = new Error('test error')
log.notice(error)
expect(core.notice).toHaveBeenCalledWith(error, undefined)
})
it('calls core.notice with properties', () => {
const log = createLogger()
const props = { title: 'Notice Title' }
log.notice('test notice', props)
expect(core.notice).toHaveBeenCalledWith('test notice', props)
})
it('calls core.warning with string message', () => {
const log = createLogger()
log.warning('test warning message')
expect(core.warning).toHaveBeenCalledWith(
'test warning message',
undefined
)
})
it('calls core.warning with Error unchanged', () => {
const log = createLogger()
const error = new Error('warning error')
log.warning(error)
expect(core.warning).toHaveBeenCalledWith(error, undefined)
})
it('calls core.warning with properties', () => {
const log = createLogger()
const props = { title: 'Warning Title' }
log.warning('test warning', props)
expect(core.warning).toHaveBeenCalledWith('test warning', props)
})
it('calls core.error with string message', () => {
const log = createLogger()
log.error('test error message')
expect(core.error).toHaveBeenCalledWith('test error message', undefined)
})
it('calls core.error with Error unchanged', () => {
const log = createLogger()
const error = new Error('error error')
log.error(error)
expect(core.error).toHaveBeenCalledWith(error, undefined)
})
it('calls core.error with properties', () => {
const log = createLogger()
const props = { title: 'Error Title' }
log.error('test error', props)
expect(core.error).toHaveBeenCalledWith('test error', props)
})
})
describe('with prefix', () => {
it('prefixes debug messages', () => {
const log = createLogger('[dry-run] ')
log.debug('test debug message')
expect(core.debug).toHaveBeenCalledWith('[dry-run] test debug message')
})
it('prefixes info messages', () => {
const log = createLogger('[dry-run] ')
log.info('test info message')
expect(core.info).toHaveBeenCalledWith('[dry-run] test info message')
})
it('prefixes notice string messages', () => {
const log = createLogger('[dry-run] ')
log.notice('test notice message')
expect(core.notice).toHaveBeenCalledWith(
'[dry-run] test notice message',
undefined
)
})
it('wraps notice Error with prefixed message and cause', () => {
const log = createLogger('[dry-run] ')
const original = new Error('notice error')
log.notice(original)
expect(core.notice).toHaveBeenCalledTimes(1)
const [wrapped, props] = core.notice.mock.calls[0]
expect(wrapped).toBeInstanceOf(Error)
expect((wrapped as Error).message).toBe('[dry-run] notice error')
expect((wrapped as Error).cause).toBe(original)
expect((wrapped as Error).stack).toBe(original.stack)
expect(props).toBeUndefined()
})
it('prefixes warning string messages', () => {
const log = createLogger('[dry-run] ')
log.warning('test warning message')
expect(core.warning).toHaveBeenCalledWith(
'[dry-run] test warning message',
undefined
)
})
it('wraps warning Error with prefixed message and cause', () => {
const log = createLogger('[dry-run] ')
const original = new Error('warning error')
log.warning(original)
expect(core.warning).toHaveBeenCalledTimes(1)
const [wrapped, props] = core.warning.mock.calls[0]
expect(wrapped).toBeInstanceOf(Error)
expect((wrapped as Error).message).toBe('[dry-run] warning error')
expect((wrapped as Error).cause).toBe(original)
expect((wrapped as Error).stack).toBe(original.stack)
expect(props).toBeUndefined()
})
it('prefixes error string messages', () => {
const log = createLogger('[dry-run] ')
log.error('test error message')
expect(core.error).toHaveBeenCalledWith(
'[dry-run] test error message',
undefined
)
})
it('wraps error Error with prefixed message and cause', () => {
const log = createLogger('[dry-run] ')
const original = new Error('error error')
log.error(original)
expect(core.error).toHaveBeenCalledTimes(1)
const [wrapped, props] = core.error.mock.calls[0]
expect(wrapped).toBeInstanceOf(Error)
expect((wrapped as Error).message).toBe('[dry-run] error error')
expect((wrapped as Error).cause).toBe(original)
expect((wrapped as Error).stack).toBe(original.stack)
expect(props).toBeUndefined()
})
it('preserves properties when prefixing', () => {
const log = createLogger('[test] ')
const props = { title: 'Test Title', file: 'test.ts', startLine: 10 }
log.warning('message with props', props)
expect(core.warning).toHaveBeenCalledWith(
'[test] message with props',
props
)
})
})
describe('with empty prefix', () => {
it('behaves the same as no prefix', () => {
const log = createLogger('')
log.info('test message')
expect(core.info).toHaveBeenCalledWith('test message')
})
})
})

View File

@@ -16,9 +16,14 @@ jest.unstable_mockModule('csv-parse/sync', () => csvParse)
const { run } = await import('../src/main.js')
// Helper functions for cleaner test setup
const setupInputs = (inputs: Record<string, string>): void => {
const setupInputs = (inputs: Record<string, string | boolean>): void => {
core.getInput.mockImplementation((name: string) => {
return inputs[name] || ''
const value = inputs[name]
return typeof value === 'string' ? value : ''
})
core.getBooleanInput.mockImplementation((name: string) => {
const value = inputs[name]
return typeof value === 'boolean' ? value : false
})
}
@@ -123,7 +128,7 @@ describe('run', () => {
})
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' does not exist, creating with commit SHA sha-abc123."
"Creating tag 'v1' at commit SHA sha-abc123."
)
expect(getOutputs()).toEqual({
created: ['v1', 'v1.0'],
@@ -155,7 +160,7 @@ describe('run', () => {
})
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' exists, updating to commit SHA sha-def456 (was sha-old123)."
"Updating tag 'v1' to commit SHA sha-def456 (was sha-old123)."
)
expect(getOutputs()).toEqual({
created: [],
@@ -1219,4 +1224,225 @@ describe('run', () => {
tags: ['v1']
})
})
describe('dry-run mode', () => {
it('logs planned creates without executing them', async () => {
setupInputs({
tags: 'v1,v1.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
dry_run: true
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should NOT call createRef in dry-run mode
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
// Should log dry-run messages
expect(core.info).toHaveBeenCalledWith(
'[dry-run] Dry-run mode enabled, no changes will be made.'
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would create tag 'v1' at commit SHA sha-abc123."
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would create tag 'v1.0' at commit SHA sha-abc123."
)
// Outputs should be empty in dry-run mode
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: [],
tags: []
})
})
it('logs planned updates without executing them', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
dry_run: true
})
setupCommitResolver('sha-def456')
setupTagExistsForAll('sha-old123')
await run()
// Should NOT call updateRef in dry-run mode
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
// Should log dry-run messages
expect(core.info).toHaveBeenCalledWith(
'[dry-run] Dry-run mode enabled, no changes will be made.'
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would update tag 'v1' " +
'to commit SHA sha-def456 (was sha-old123).'
)
// Outputs should be empty in dry-run mode
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: [],
tags: []
})
})
it('logs skipped tags with dry-run prefix', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
dry_run: true
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-abc123')
await run()
expect(core.info).toHaveBeenCalledWith(
'[dry-run] Dry-run mode enabled, no changes will be made.'
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Tag 'v1' already exists with desired commit SHA sha-abc123."
)
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: [],
tags: []
})
})
it('handles mixed operations in dry-run mode', async () => {
setupInputs({
tags: 'v1,v2,v3',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
dry_run: true
})
setupCommitResolver('sha-abc123')
// v1 exists with different SHA, v2 matches, v3 doesn't exist
github.mockOctokit.rest.git.getRef.mockImplementation(
async (args: unknown) => {
const { ref } = args as { ref: string }
if (ref === 'tags/v1') {
return {
data: {
ref: 'refs/tags/v1',
object: { sha: 'sha-old', type: 'commit' }
}
}
}
if (ref === 'tags/v2') {
return {
data: {
ref: 'refs/tags/v2',
object: { sha: 'sha-abc123', type: 'commit' }
}
}
}
throw { status: 404 }
}
)
await run()
// No actual operations should happen
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
// Should log all planned operations
expect(core.info).toHaveBeenCalledWith(
'[dry-run] Dry-run mode enabled, no changes will be made.'
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would update tag 'v1' to commit SHA sha-abc123 (was sha-old)."
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Tag 'v2' already exists with desired commit SHA sha-abc123."
)
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would create tag 'v3' at commit SHA sha-abc123."
)
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: [],
tags: []
})
})
it('logs annotated tag creation in dry-run mode', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Release v1',
dry_run: true
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith(
"[dry-run] Would create tag 'v1' at commit SHA sha-abc123 (annotated)."
)
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: [],
tags: []
})
})
it('executes normally when dry_run is false', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
dry_run: false
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should actually create the tag
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['v1'],
updated: [],
skipped: [],
tags: ['v1']
})
})
})
})