feat(tag): add support for annotated tags and improved tag handling (#15)

This commit is contained in:
2025-10-28 01:29:46 +00:00
committed by GitHub
parent 2cb31b2a0a
commit 40c0c24c34
9 changed files with 836 additions and 142 deletions

View File

@@ -1,16 +1,18 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parseTagsInput, type Tag } from './tags.js'
import { parse } from 'csv-parse/sync'
const WHEN_EXISTS_MODES = ['update', 'skip', 'fail'] as const
export type WhenExistsMode = (typeof WHEN_EXISTS_MODES)[number]
export interface Inputs {
tags: Tag[]
tags: string[]
defaultRef: string
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
octokit: ReturnType<typeof github.getOctokit>
token: string
}
/**
@@ -35,25 +37,34 @@ function validateWhenExists(input: string): WhenExistsMode {
*
* @returns Parsed and validated inputs
*/
export async function getInputs(): Promise<Inputs> {
export function getInputs(): Inputs {
const tagsInput: string = core.getInput('tags', { required: true })
const defaultRef: string = core.getInput('ref')
const whenExistsInput = core.getInput('when_exists') || 'update'
const whenExists = validateWhenExists(whenExistsInput)
const annotation: string = core.getInput('annotation')
const token: string = core.getInput('github_token', {
required: true
})
const octokit = github.getOctokit(token)
const { owner, repo } = github.context.repo
const tags = await parseTagsInput(octokit, tagsInput, defaultRef, owner, repo)
// Parse tags as CSV/newline delimited strings
const tags = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
return {
tags,
defaultRef,
whenExists,
annotation,
owner,
repo,
octokit
token
}
}

View File

@@ -1,6 +1,7 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { getInputs } from './inputs.js'
import { processTag } from './tags.js'
import { resolveDesiredTags, processTag } from './tags.js'
/**
* The main function for the action.
@@ -11,7 +12,7 @@ export async function run(): Promise<void> {
try {
let inputs
try {
inputs = await getInputs()
inputs = getInputs()
} catch (error) {
// For parsing/validation errors, pass message directly.
const message = error instanceof Error ? error.message : String(error)
@@ -19,14 +20,26 @@ export async function run(): Promise<void> {
return
}
const { tags, whenExists, owner, repo, octokit } = inputs
// Create GitHub API client
const octokit = github.getOctokit(inputs.token)
let tags
try {
tags = await resolveDesiredTags(inputs, octokit)
} catch (error) {
// For tag resolution errors (ref resolution, tag existence checks), pass
// message directly.
const message = error instanceof Error ? error.message : String(error)
core.setFailed(message)
return
}
const created: string[] = []
const updated: string[] = []
// Create or update all tags.
for (const tag of tags) {
const result = await processTag(tag, whenExists, owner, repo, octokit)
const result = await processTag(tag, octokit)
if (result === 'failed') {
return

View File

@@ -1,39 +1,86 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parse } from 'csv-parse/sync'
import type { Inputs, WhenExistsMode } from './inputs.js'
export interface Tag {
export interface ExistingTagInfo {
commitSHA: string
isAnnotated: boolean
annotation?: string
}
export interface DesiredTag {
name: string
ref: string
sha: string
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
existing?: ExistingTagInfo
}
export type TagResult = 'created' | 'updated' | 'skipped' | 'failed'
/**
* Parse tags input string and resolve refs to SHAs.
*
* @param octokit - The GitHub API client
* @param tagsInput - The raw tags input string
* @param defaultRef - The default ref to use if not specified per-tag
* @param owner - The repository owner
* @param repo - The repository name
* @returns Array of desired tags with resolved SHAs
*/
export async function parseTagsInput(
octokit: ReturnType<typeof github.getOctokit>,
tagsInput: string,
defaultRef: string,
owner: string,
interface TagOperationContext {
owner: string
repo: string
): Promise<Tag[]> {
const parsedTags: string[] = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
octokit: ReturnType<typeof github.getOctokit>
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
*
* @param ctx - Operation context
* @param existing - The existing tag reference data
* @returns Information about the existing tag
*/
async function fetchExistingTagInfo(
ctx: TagOperationContext,
existing: { data: { object: { sha: string; type: string } } }
): Promise<ExistingTagInfo> {
const existingObject = existing.data.object
const isAnnotated = existingObject.type === 'tag'
if (!isAnnotated) {
return {
commitSHA: existingObject.sha,
isAnnotated: false
}
}
// Dereference annotated tag to get underlying commit
const tagObject = await ctx.octokit.rest.git.getTag({
owner: ctx.owner,
repo: ctx.repo,
tag_sha: existingObject.sha
})
return {
commitSHA: tagObject.data.object.sha,
isAnnotated: true,
annotation: tagObject.data.message
}
}
/**
* Resolve desired tag objects from inputs.
*
* @param inputs - The validated inputs containing tags, refs, and configuration
* @param octokit - The GitHub API client
* @returns Array of desired tags with resolved SHAs and configuration
*/
export async function resolveDesiredTags(
inputs: Inputs,
octokit: ReturnType<typeof github.getOctokit>
): Promise<DesiredTag[]> {
const {
tags: parsedTags,
defaultRef,
whenExists,
annotation,
owner,
repo
} = inputs
const uniqueRefs = new Set<string>()
const tags: Record<string, string> = {}
@@ -60,7 +107,7 @@ export async function parseTagsInput(
throw new Error("Missing ref: provide 'ref' input or specify per-tag ref")
}
// Check for duplicate tag with different ref.
// Check for duplicate tag with different ref
if (tags[tagName] && tags[tagName] !== ref) {
throw new Error(
`Duplicate tag '${tagName}' with different refs: ` +
@@ -73,112 +120,84 @@ export async function parseTagsInput(
}
// Pre-resolve all unique refs in parallel.
const ctx: TagOperationContext = { owner, repo, octokit }
const refToSha: Record<string, string> = {}
await Promise.all(
Array.from(uniqueRefs).map(async (ref) => {
refToSha[ref] = await resolveRefToSha(octokit, owner, repo, ref)
refToSha[ref] = await resolveRefToSha(ctx, ref)
})
)
// Build result array with resolved SHAs.
const result: Tag[] = []
for (const tagName in tags) {
const tagRef = tags[tagName]
result.push({
name: tagName,
ref: tagRef,
sha: refToSha[tagRef]
// Build result array with resolved SHAs and check for existing tags.
const tagNames = Object.keys(tags)
const result: DesiredTag[] = await Promise.all(
tagNames.map(async (tagName) => {
const tagRef = tags[tagName]
const sha = refToSha[tagRef]
// Check if tag already exists
let existing: ExistingTagInfo | undefined
try {
const existingRef = await ctx.octokit.rest.git.getRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${tagName}`
})
existing = await fetchExistingTagInfo(ctx, existingRef)
// Fail early if when_exists is 'fail'
if (whenExists === 'fail') {
throw new Error(`Tag '${tagName}' already exists.`)
}
} catch (error: unknown) {
// Check if it's a GitHub API error with a status property
if (typeof error === 'object' && error !== null && 'status' in error) {
const apiError = error as { status: number; message?: string }
if (apiError.status === 404) {
// Tag doesn't exist, existing remains undefined
} else {
// Some other API error
throw new Error(
`Failed to check if tag '${tagName}' exists: ${apiError.message || String(error)}`
)
}
} else if (error instanceof Error) {
// Already an Error (e.g., from when_exists === 'fail')
throw error
} else {
// Unknown error type
throw new Error(
`Failed to check if tag '${tagName}' exists: ${String(error)}`
)
}
}
return {
name: tagName,
ref: tagRef,
sha,
whenExists,
annotation,
owner,
repo,
existing
}
})
}
)
return result
}
/**
* Process a single desired tag: create or update it based on configuration.
*
* @param tag - The desired tag to process
* @param whenExists - What to do if the tag already exists
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
* @returns The result of the tag operation
*/
export async function processTag(
tag: Tag,
whenExists: 'update' | 'skip' | 'fail',
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const { name: tagName, sha } = tag
try {
// Check if the tag exists.
const existing = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${tagName}`
})
// If the tag exists, decide action based on 'when_exists'.
if (whenExists === 'update') {
const existingSHA = existing.data.object.sha
if (existingSHA === sha) {
core.info(`Tag '${tagName}' already exists with desired SHA ${sha}.`)
return 'skipped'
}
core.info(
`Tag '${tagName}' exists, updating to SHA ${sha} ` +
`(was ${existingSHA}).`
)
await octokit.rest.git.updateRef({
owner,
repo,
ref: `tags/${tagName}`,
sha,
force: true
})
return 'updated'
} else if (whenExists === 'skip') {
core.info(`Tag '${tagName}' exists, skipping.`)
return 'skipped'
} else {
// whenExists === 'fail'
core.setFailed(`Tag '${tagName}' already exists.`)
return 'failed'
}
} catch (error: unknown) {
const err = error as { status?: number }
if (err?.status !== 404) {
throw error
}
// If the tag doesn't exist (404), create it.
core.info(`Tag '${tagName}' does not exist, creating with SHA ${sha}.`)
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${tagName}`,
sha
})
return 'created'
}
}
async function resolveRefToSha(
octokit: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
ctx: TagOperationContext,
ref: string
): Promise<string> {
try {
const {
data: { sha }
} = await octokit.rest.repos.getCommit({
owner,
repo,
} = await ctx.octokit.rest.repos.getCommit({
owner: ctx.owner,
repo: ctx.repo,
ref
})
@@ -187,3 +206,201 @@ async function resolveRefToSha(
throw new Error(`Failed to resolve ref '${ref}' to a SHA: ${String(error)}`)
}
}
/**
* Process a single desired tag: create or update it based on configuration.
*
* @param tag - The desired tag to process (with existing info if applicable)
* @param octokit - GitHub API client
* @returns The result of the tag operation
*/
export async function processTag(
tag: DesiredTag,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const ctx: TagOperationContext = { owner: tag.owner, repo: tag.repo, octokit }
// Tag doesn't exist, create it
if (!tag.existing) {
return await createTag(ctx, tag)
}
// Tag exists - handle based on when_exists strategy
if (tag.whenExists === 'skip') {
core.info(`Tag '${tag.name}' exists, skipping.`)
return 'skipped'
}
if (tag.whenExists === 'fail') {
// This should not happen as we fail early in resolveDesiredTags
core.setFailed(`Tag '${tag.name}' already exists.`)
return 'failed'
}
// whenExists === 'update' - check if update is needed
if (tagMatchesTarget(tag)) {
core.info(
`Tag '${tag.name}' already exists with desired commit SHA ${tag.sha}` +
(tag.existing.isAnnotated ? ' (annotated).' : '.')
)
return 'skipped'
}
return await updateExistingTag(ctx, tag)
}
/**
* Update an existing tag to point to a new commit and/or annotation.
*/
async function updateExistingTag(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<TagResult> {
if (!tag.existing) {
throw new Error(`Cannot update non-existent tag '${tag.name}'`)
}
const reasons = getUpdateReasons(tag)
const commitMatches = tag.existing.commitSHA === tag.sha
if (commitMatches) {
core.info(
`Tag '${tag.name}' exists with same commit but ${reasons.join(', ')}.`
)
} else {
core.info(
`Tag '${tag.name}' exists` +
`${tag.existing.isAnnotated ? ' (annotated)' : ''}` +
`, updating to ${reasons.join(', ')}.`
)
}
const targetSha = await resolveTargetSHA(ctx, tag)
await ctx.octokit.rest.git.updateRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${tag.name}`,
sha: targetSha,
force: true
})
return 'updated'
}
/**
* Create a tag (doesn't exist yet).
*/
async function createTag(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<TagResult> {
core.info(
`Tag '${tag.name}' does not exist, creating with commit SHA ${tag.sha}.`
)
const targetSha = await resolveTargetSHA(ctx, tag)
await ctx.octokit.rest.git.createRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `refs/tags/${tag.name}`,
sha: targetSha
})
return 'created'
}
/**
* Resolve the target SHA for a tag (creates annotated tag object if needed).
*
* @param ctx - Operation context
* @param tag - The tag to create
* @returns The SHA to use (tag object SHA if annotated, commit SHA otherwise)
*/
async function resolveTargetSHA(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<string> {
if (!tag.annotation) {
return tag.sha
}
const tagObject = await ctx.octokit.rest.git.createTag({
owner: ctx.owner,
repo: ctx.repo,
tag: tag.name,
message: tag.annotation,
object: tag.sha,
type: 'commit'
})
return tagObject.data.sha
}
/**
* Compare existing tag state with desired target state.
*
* @param tag - The desired tag with existing info
* @returns Object indicating whether commit and annotation match
*/
function compareTagState(tag: DesiredTag): {
commitMatches: boolean
annotationMatches: boolean
} {
if (!tag.existing) {
return { commitMatches: false, annotationMatches: false }
}
const commitMatches = tag.existing.commitSHA === tag.sha
const annotationMatches =
tag.existing.isAnnotated && tag.annotation
? tag.existing.annotation === tag.annotation
: !tag.existing.isAnnotated && !tag.annotation
return { commitMatches, annotationMatches }
}
/**
* Check if a tag needs to be updated based on commit and annotation.
*
* @param tag - The desired tag with existing info
* @returns True if the tag matches the target state
*/
function tagMatchesTarget(tag: DesiredTag): boolean {
const { commitMatches, annotationMatches } = compareTagState(tag)
return commitMatches && annotationMatches
}
/**
* Get update reason messages based on what changed.
*
* @param tag - The desired tag with existing info
* @returns Array of reason strings
*/
function getUpdateReasons(tag: DesiredTag): string[] {
if (!tag.existing) return []
const { commitMatches, annotationMatches } = compareTagState(tag)
const reasons: string[] = []
if (!commitMatches) {
reasons.push(`commit SHA ${tag.sha} (was ${tag.existing.commitSHA})`)
}
if (!annotationMatches && tag.annotation) {
if (tag.existing.isAnnotated) {
reasons.push('annotation message changed')
} else {
reasons.push('adding annotation')
}
} else if (
!annotationMatches &&
!tag.annotation &&
tag.existing.isAnnotated
) {
reasons.push('removing annotation')
}
return reasons
}