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

@@ -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