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

420
tests/derive.test.ts Normal file
View File

@@ -0,0 +1,420 @@
/**
* Unit tests for the derive module, src/derive.ts
*/
import { parseSemver, renderTemplate, deriveTags } from '../src/derive.js'
describe('parseSemver', () => {
it('parses simple version without prefix', () => {
const result = parseSemver('1.2.3')
expect(result).toEqual({
prefix: '',
major: 1,
minor: 2,
patch: 3,
prerelease: '',
build: '',
version: '1.2.3'
})
})
it('parses version with v prefix', () => {
const result = parseSemver('v1.2.3')
expect(result).toEqual({
prefix: 'v',
major: 1,
minor: 2,
patch: 3,
prerelease: '',
build: '',
version: '1.2.3'
})
})
it('parses version with uppercase V prefix', () => {
const result = parseSemver('V1.2.3')
expect(result).toEqual({
prefix: 'V',
major: 1,
minor: 2,
patch: 3,
prerelease: '',
build: '',
version: '1.2.3'
})
})
it('parses version with prerelease', () => {
const result = parseSemver('v1.0.0-beta.1')
expect(result).toEqual({
prefix: 'v',
major: 1,
minor: 0,
patch: 0,
prerelease: 'beta.1',
build: '',
version: '1.0.0-beta.1'
})
})
it('parses version with build metadata', () => {
const result = parseSemver('v1.0.0+build.456')
expect(result).toEqual({
prefix: 'v',
major: 1,
minor: 0,
patch: 0,
prerelease: '',
build: 'build.456',
version: '1.0.0'
})
})
it('parses version with both prerelease and build metadata', () => {
const result = parseSemver('v1.2.3-alpha.1+build.789')
expect(result).toEqual({
prefix: 'v',
major: 1,
minor: 2,
patch: 3,
prerelease: 'alpha.1',
build: 'build.789',
version: '1.2.3-alpha.1'
})
})
it('handles whitespace around version string', () => {
const result = parseSemver(' v1.2.3 ')
expect(result).toEqual({
prefix: 'v',
major: 1,
minor: 2,
patch: 3,
prerelease: '',
build: '',
version: '1.2.3'
})
})
it('throws on empty string', () => {
expect(() => parseSemver('')).toThrow('Invalid semver: empty string')
})
it('throws on whitespace only', () => {
expect(() => parseSemver(' ')).toThrow('Invalid semver: empty string')
})
it('throws on invalid semver', () => {
expect(() => parseSemver('not-a-version')).toThrow(
"Invalid semver: 'not-a-version'"
)
})
it('throws on incomplete version', () => {
expect(() => parseSemver('v1.2')).toThrow("Invalid semver: 'v1.2'")
})
})
describe('renderTemplate', () => {
const ctx = {
prefix: 'v',
major: 1,
minor: 2,
patch: 3,
prerelease: 'beta.1',
build: 'build.456',
version: '1.2.3-beta.1'
}
it('renders prefix placeholder', () => {
expect(renderTemplate('{{prefix}}', ctx)).toBe('v')
})
it('renders major placeholder', () => {
expect(renderTemplate('{{major}}', ctx)).toBe('1')
})
it('renders minor placeholder', () => {
expect(renderTemplate('{{minor}}', ctx)).toBe('2')
})
it('renders patch placeholder', () => {
expect(renderTemplate('{{patch}}', ctx)).toBe('3')
})
it('renders prerelease placeholder', () => {
expect(renderTemplate('{{prerelease}}', ctx)).toBe('beta.1')
})
it('renders build placeholder', () => {
expect(renderTemplate('{{build}}', ctx)).toBe('build.456')
})
it('renders version placeholder', () => {
expect(renderTemplate('{{version}}', ctx)).toBe('1.2.3-beta.1')
})
it('renders multiple placeholders', () => {
expect(renderTemplate('{{prefix}}{{major}}.{{minor}}', ctx)).toBe('v1.2')
})
it('renders same placeholder multiple times', () => {
expect(renderTemplate('{{major}}-{{major}}', ctx)).toBe('1-1')
})
it('preserves text without placeholders', () => {
expect(renderTemplate('latest', ctx)).toBe('latest')
})
it('handles empty prefix', () => {
const noPrefix = { ...ctx, prefix: '' }
expect(renderTemplate('{{prefix}}{{major}}', noPrefix)).toBe('1')
})
it('handles empty prerelease', () => {
const noPrerelease = { ...ctx, prerelease: '' }
expect(renderTemplate('{{major}}-{{prerelease}}', noPrerelease)).toBe('1-')
})
describe('Handlebars conditionals', () => {
it('renders {{#if}} section when variable has value', () => {
expect(
renderTemplate('{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}', ctx)
).toBe('1-beta.1')
})
it('omits {{#if}} section when variable is empty', () => {
const noPrerelease = { ...ctx, prerelease: '' }
expect(
renderTemplate(
'{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}',
noPrerelease
)
).toBe('1')
})
it('handles {{#if}} with zero-valued numbers (converted to strings)', () => {
const zeroMinor = { ...ctx, minor: 0 }
expect(
renderTemplate('{{major}}{{#if minor}}.{{minor}}{{/if}}', zeroMinor)
).toBe('1.0')
})
it('handles nested conditionals', () => {
expect(
renderTemplate(
'{{major}}{{#if prerelease}}-{{prerelease}}{{#if build}}+{{build}}{{/if}}{{/if}}',
ctx
)
).toBe('1-beta.1+build.456')
})
it('handles nested conditionals with inner empty', () => {
const noBuild = { ...ctx, build: '' }
expect(
renderTemplate(
'{{major}}{{#if prerelease}}-{{prerelease}}{{#if build}}+{{build}}{{/if}}{{/if}}',
noBuild
)
).toBe('1-beta.1')
})
it('handles {{#unless}} for inverse logic', () => {
const noPrerelease = { ...ctx, prerelease: '' }
expect(
renderTemplate('{{major}}{{#unless prerelease}}-stable{{/unless}}', ctx)
).toBe('1')
expect(
renderTemplate(
'{{major}}{{#unless prerelease}}-stable{{/unless}}',
noPrerelease
)
).toBe('1-stable')
})
it('handles multiple {{#if}} sections', () => {
const noBuild = { ...ctx, build: '' }
expect(
renderTemplate(
'{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}{{#if build}}+{{build}}{{/if}}',
noBuild
)
).toBe('1-beta.1')
})
})
})
describe('deriveTags', () => {
it('generates tags with default-style template', () => {
const result = deriveTags(
'v1.2.3',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
)
expect(result).toEqual(['v1', 'v1.2'])
})
it('generates single tag from simple template', () => {
const result = deriveTags('v1.2.3', '{{prefix}}{{major}}')
expect(result).toEqual(['v1'])
})
it('handles version without prefix', () => {
const result = deriveTags(
'1.2.3',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
)
expect(result).toEqual(['1', '1.2'])
})
it('handles prerelease in template', () => {
const result = deriveTags(
'v1.0.0-rc.1',
'{{prefix}}{{major}}-{{prerelease}}'
)
expect(result).toEqual(['v1-rc.1'])
})
it('filters empty tags and prefix-only tags from result', () => {
const result = deriveTags('v1.2.3', '{{prefix}}{{major}},,{{prefix}}')
expect(result).toEqual(['v1'])
})
it('trims whitespace around template parts', () => {
const result = deriveTags('v1.2.3', ' {{prefix}}{{major}} , latest ')
expect(result).toEqual(['v1', 'latest'])
})
it('generates full version tag', () => {
const result = deriveTags('v1.2.3', '{{prefix}}{{version}}')
expect(result).toEqual(['v1.2.3'])
})
it('throws on invalid semver', () => {
expect(() => deriveTags('invalid', '{{major}}')).toThrow(
"Invalid semver: 'invalid'"
)
})
it('handles newline-delimited template', () => {
const result = deriveTags(
'v1.2.3',
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}'
)
expect(result).toEqual(['v1', 'v1.2'])
})
it('handles mixed newlines and commas in template', () => {
const result = deriveTags(
'v1.2.3',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}\nlatest'
)
expect(result).toEqual(['v1', 'v1.2', 'latest'])
})
describe('with Handlebars conditionals', () => {
it('generates tag with optional prerelease when present', () => {
const result = deriveTags(
'v1.0.0-beta.1',
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}'
)
expect(result).toEqual(['v1-beta.1'])
})
it('generates tag without prerelease section when absent', () => {
const result = deriveTags(
'v1.0.0',
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}'
)
expect(result).toEqual(['v1'])
})
it('generates multiple tags with conditionals', () => {
const result = deriveTags(
'v1.2.3-rc.1',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#if prerelease}}-{{prerelease}}{{/if}}'
)
expect(result).toEqual(['v1', 'v1.2-rc.1'])
})
it('handles build metadata conditional', () => {
const result = deriveTags(
'v1.0.0+build.123',
'{{prefix}}{{major}}{{#if build}}+{{build}}{{/if}}'
)
expect(result).toEqual(['v1+build.123'])
})
it('handles both prerelease and build conditionals', () => {
const result = deriveTags(
'v1.0.0-alpha.1+build.789',
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}{{#if build}}+{{build}}{{/if}}'
)
expect(result).toEqual(['v1-alpha.1+build.789'])
})
})
describe('conditional tag inclusion', () => {
it('includes latest tag only for stable releases', () => {
// Stable release gets latest tag
const stable = deriveTags(
'v1.2.3',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#unless prerelease}},latest{{/unless}}'
)
expect(stable).toEqual(['v1', 'v1.2', 'latest'])
// Prerelease does not get latest tag
const prerelease = deriveTags(
'v1.2.3-beta.1',
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#unless prerelease}},latest{{/unless}}'
)
expect(prerelease).toEqual(['v1', 'v1.2'])
})
it('conditionally includes entire tag group with {{#if}}', () => {
// With prerelease: include prerelease-specific tags
const withPre = deriveTags(
'v2.0.0-rc.1',
'{{prefix}}{{major}}{{#if prerelease}},{{prefix}}{{major}}-{{prerelease}}{{/if}}'
)
expect(withPre).toEqual(['v2', 'v2-rc.1'])
// Without prerelease: only major tag
const withoutPre = deriveTags(
'v2.0.0',
'{{prefix}}{{major}}{{#if prerelease}},{{prefix}}{{major}}-{{prerelease}}{{/if}}'
)
expect(withoutPre).toEqual(['v2'])
})
it('conditionally includes newline-separated tags', () => {
// Stable release includes all tags
const stable = deriveTags(
'v1.0.0',
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}{{#unless prerelease}}\nstable{{/unless}}'
)
expect(stable).toEqual(['v1', 'v1.0', 'stable'])
// Prerelease excludes stable tag
const prerelease = deriveTags(
'v1.0.0-alpha.1',
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}{{#unless prerelease}}\nstable{{/unless}}'
)
expect(prerelease).toEqual(['v1', 'v1.0'])
})
it('excludes multiple tags conditionally', () => {
const result = deriveTags(
'v1.0.0-beta.1',
'{{prefix}}{{major}}{{#unless prerelease}},latest,stable,production{{/unless}}'
)
expect(result).toEqual(['v1'])
})
it('includes multiple tags conditionally for stable', () => {
const result = deriveTags(
'v1.0.0',
'{{prefix}}{{major}}{{#unless prerelease}},latest,stable,production{{/unless}}'
)
expect(result).toEqual(['v1', 'latest', 'stable', 'production'])
})
})
})

View File

@@ -1445,4 +1445,226 @@ describe('run', () => {
})
})
})
describe('derive_from input', () => {
it('derives tags from semver using default template', async () => {
setupInputs({
derive_from: 'v1.2.3',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.2',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['v1', 'v1.2'],
updated: [],
skipped: [],
tags: ['v1', 'v1.2']
})
})
it('derives tags using custom template', async () => {
setupInputs({
derive_from: 'v2.5.0',
derive_from_template: '{{prefix}}{{major}}',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v2',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['v2'],
updated: [],
skipped: [],
tags: ['v2']
})
})
it('combines derive_from with explicit tags', async () => {
setupInputs({
tags: 'latest',
derive_from: 'v1.0.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(3)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/latest',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.0',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['latest', 'v1', 'v1.0'],
updated: [],
skipped: [],
tags: ['latest', 'v1', 'v1.0']
})
})
it('handles version without v prefix', async () => {
setupInputs({
derive_from: '3.0.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/3',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/3.0',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['3', '3.0'],
updated: [],
skipped: [],
tags: ['3', '3.0']
})
})
it('fails when neither tags nor derive_from is provided', async () => {
setupInputs({
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"No tags specified. Provide 'tags' input, 'derive_from' input, or both."
)
})
it('fails on invalid semver in derive_from', async () => {
setupInputs({
derive_from: 'not-a-version',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"Invalid semver: 'not-a-version'"
)
})
it('deduplicates when tags and derive_from produce overlapping tags', async () => {
setupInputs({
tags: 'v1,v1.2',
derive_from: 'v1.2.3', // Derives v1 and v1.2, overlapping with explicit
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should only create 2 tags (deduplicated), not 4
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.2',
sha: 'sha-abc123'
})
expect(getOutputs()).toEqual({
created: ['v1', 'v1.2'],
updated: [],
skipped: [],
tags: ['v1', 'v1.2']
})
})
it('fails when tags and derive_from produce same tag with different refs', async () => {
setupInputs({
tags: 'v1:main', // Explicit v1 pointing to main
derive_from: 'v1.2.3', // Derives v1 (using default ref)
ref: 'develop', // Different default ref
github_token: 'test-token',
when_exists: 'update'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"Duplicate tag 'v1' with different refs: 'main' and 'develop'"
)
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
})
})