feat(tags/derive): add semver tag derivation functionality (#79)

This commit is contained in:
2025-12-23 23:33:54 +00:00
committed by GitHub
parent 6adab3a060
commit 1d171e9f3c
18 changed files with 12699 additions and 292 deletions

105
src/derive.ts Normal file
View File

@@ -0,0 +1,105 @@
import { parse } from 'csv-parse/sync'
import Handlebars from 'handlebars'
import * as semver from 'semver'
/**
* Context containing parsed semver components for template rendering.
*/
export interface SemverContext {
/** "v" or "V" if input had a prefix, empty string otherwise */
prefix: string
/** Major version number */
major: number
/** Minor version number */
minor: number
/** Patch version number */
patch: number
/** Prerelease identifier (e.g., "beta.1"), empty if none */
prerelease: string
/** Build metadata (e.g., "build.123"), empty if none */
build: string
/** Full version string without prefix */
version: string
}
/**
* Parse a version string into semver components.
*
* @param input - Version string (e.g., "v1.2.3", "1.2.3-beta.1+build.456")
* @returns Parsed semver context
* @throws Error if the version string is not valid semver
*/
export function parseSemver(input: string): SemverContext {
const trimmed = input.trim()
if (!trimmed) {
throw new Error('Invalid semver: empty string')
}
// Check for v/V prefix and preserve original case
const firstChar = trimmed[0]
const hasPrefix = firstChar === 'v' || firstChar === 'V'
const prefix = hasPrefix ? firstChar : ''
const versionStr = hasPrefix ? trimmed.slice(1) : trimmed
// Parse with semver library
const parsed = semver.parse(versionStr)
if (!parsed) {
throw new Error(`Invalid semver: '${input}'`)
}
return {
prefix,
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
prerelease: parsed.prerelease.join('.'),
build: parsed.build.join('.'),
version: parsed.version
}
}
/**
* Render a single template string with semver context using Handlebars.
* Supports {{#if variable}}...{{/if}} conditionals for optional sections.
*
* @param template - Handlebars template string
* @param ctx - Semver context for substitution
* @returns Rendered string
*/
export function renderTemplate(template: string, ctx: SemverContext): string {
const compiled = Handlebars.compile(template, { noEscape: true })
// Convert numbers to strings so 0 is truthy in conditionals
const stringCtx = {
...ctx,
major: String(ctx.major),
minor: String(ctx.minor),
patch: String(ctx.patch)
}
return compiled(stringCtx)
}
/**
* Derive tags from a semver version string using a template.
*
* @param deriveFrom - Semver version string (e.g., "v1.2.3")
* @param template - CSV/newline-delimited Handlebars template string
* @returns Array of derived tag strings
*/
export function deriveTags(deriveFrom: string, template: string): string[] {
const ctx = parseSemver(deriveFrom)
// Render template with Handlebars first, enabling conditional tag inclusion
const rendered = renderTemplate(template, ctx)
// Parse rendered result as CSV/newline delimited
const tags = (
parse(rendered, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
// Exclude empty tags and tags that are just the prefix with no version data
return tags.filter((tag) => tag.length > 0 && tag !== ctx.prefix)
}

View File

@@ -1,6 +1,7 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parse } from 'csv-parse/sync'
import { deriveTags } from './derive.js'
const WHEN_EXISTS_MODES = ['update', 'skip', 'fail'] as const
export type WhenExistsMode = (typeof WHEN_EXISTS_MODES)[number]
@@ -33,13 +34,19 @@ function validateWhenExists(input: string): WhenExistsMode {
return input as WhenExistsMode
}
const DEFAULT_DERIVE_FROM_TEMPLATE =
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
/**
* Get and validate action inputs.
*
* @returns Parsed and validated inputs
*/
export function getInputs(): Inputs {
const tagsInput: string = core.getInput('tags', { required: true })
const tagsInput: string = core.getInput('tags')
const deriveFrom: string = core.getInput('derive_from')
const deriveFromTemplate: string =
core.getInput('derive_from_template') || DEFAULT_DERIVE_FROM_TEMPLATE
const defaultRef: string = core.getInput('ref')
const whenExistsInput = core.getInput('when_exists') || 'update'
const whenExists = validateWhenExists(whenExistsInput)
@@ -51,14 +58,31 @@ export function getInputs(): Inputs {
const { owner, repo } = github.context.repo
// Parse tags as CSV/newline delimited strings
const tags = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
// Parse explicit tags as CSV/newline delimited strings
const explicitTags: string[] = tagsInput
? (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
: []
// Derive tags from semver version string if provided
const derivedTags: string[] = deriveFrom
? deriveTags(deriveFrom, deriveFromTemplate)
: []
// Combine explicit and derived tags
const tags = [...explicitTags, ...derivedTags]
// Validate that at least one tag source is provided
if (tags.length === 0) {
throw new Error(
"No tags specified. Provide 'tags' input, 'derive_from' input, or both."
)
}
return {
tags,