feat(tags): support per-tag annotation overrides (#81)

This commit is contained in:
2025-12-24 00:12:13 +00:00
committed by GitHub
parent 1d171e9f3c
commit 32e66b04be
8 changed files with 619 additions and 59 deletions

View File

@@ -538,22 +538,42 @@ describe('run', () => {
})
})
it('fails when tag specification has multiple colons', async () => {
it('creates annotated tag with per-tag annotation syntax', async () => {
setupInputs({
tags: 'stable:refs/heads/main:latest',
tags: 'stable:refs/heads/main:Release annotation',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({ 'refs/heads/main': 'sha-main' })
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-stable' }
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Invalid tag specification')
)
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('too many colons')
)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'stable',
message: 'Release annotation',
object: 'sha-main',
type: 'commit'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/stable',
sha: 'sha-tag-object-stable'
})
expect(getOutputs()).toEqual({
created: ['stable'],
updated: [],
skipped: [],
tags: ['stable']
})
})
it('handles mixed scenario with multiple tags', async () => {
@@ -1667,4 +1687,202 @@ describe('run', () => {
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
})
describe('per-tag annotations', () => {
it('creates tag with per-tag annotation using empty ref fallback', async () => {
setupInputs({
tags: 'v1::Per-tag annotation',
ref: 'main',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-v1' }
})
await run()
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Per-tag annotation',
object: 'sha-main',
type: 'commit'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-tag-object-v1'
})
expect(getOutputs()).toEqual({
created: ['v1'],
updated: [],
skipped: [],
tags: ['v1']
})
})
it('handles per-tag annotation containing colons', async () => {
setupInputs({
tags: 'v1:main:Release: version 1.0.0: stable',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({ main: 'sha-main' })
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-v1' }
})
await run()
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Release: version 1.0.0: stable',
object: 'sha-main',
type: 'commit'
})
})
it('mixes per-tag annotations with global annotation', async () => {
setupInputs({
tags: 'v1:main:Custom message,v2',
ref: 'main',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Global annotation'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockImplementation(
async (args: unknown) => {
const { tag } = args as { tag: string }
return { data: { sha: `sha-tag-object-${tag}` } }
}
)
await run()
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Custom message',
object: 'sha-main',
type: 'commit'
})
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v2',
message: 'Global annotation',
object: 'sha-main',
type: 'commit'
})
})
it('per-tag annotation overrides global annotation', async () => {
setupInputs({
tags: 'v1:main:Per-tag wins',
ref: 'main',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Global annotation should be ignored'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-v1' }
})
await run()
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Per-tag wins',
object: 'sha-main',
type: 'commit'
})
})
it('updates tag when per-tag annotation differs from existing', async () => {
setupInputs({
tags: 'v1:main:New annotation',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({ main: 'sha-main' })
// Tag exists as annotated with different message but same commit
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: {
ref: 'refs/tags/v1',
object: { sha: 'sha-tag-object-old', type: 'tag' }
}
})
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object-old',
message: 'Old annotation',
object: { sha: 'sha-main', type: 'commit' }
}
})
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-new' }
})
await run()
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'New annotation',
object: 'sha-main',
type: 'commit'
})
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-tag-object-new',
force: true
})
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('fails when empty tag name with per-tag annotation', async () => {
setupInputs({
tags: '::Some annotation',
ref: 'main',
github_token: 'test-token'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"Invalid tag: '::Some annotation'"
)
})
})
})

View File

@@ -4,14 +4,262 @@
import { jest } from '@jest/globals'
import * as core from './fixtures/core.js'
import * as github from './fixtures/github.js'
import type { Inputs } from '../src/inputs.js'
// Mocks should be declared before the module being tested is imported.
jest.unstable_mockModule('@actions/core', () => core)
jest.unstable_mockModule('@actions/github', () => github)
// The module being tested should be imported dynamically.
const { executeTagOperation } = await import('../src/tags.js')
import type { TagOperation } from '../src/tags.js'
const { executeTagOperation, planTagOperations } =
await import('../src/tags.js')
import type {
TagOperation,
CreateOperation,
UpdateOperation
} from '../src/tags.js'
// Helper to create a minimal Inputs object for testing
const createInputs = (overrides: Partial<Inputs> = {}): Inputs => ({
tags: [],
defaultRef: 'main',
whenExists: 'update',
annotation: '',
dryRun: false,
owner: 'test-owner',
repo: 'test-repo',
token: 'test-token',
...overrides
})
describe('planTagOperations', () => {
beforeEach(() => {
jest.resetAllMocks()
github.getOctokit.mockReturnValue(github.mockOctokit)
})
const setupCommitResolver = (
refToSha: Record<string, string> | string
): void => {
if (typeof refToSha === 'string') {
github.mockOctokit.rest.repos.getCommit.mockResolvedValue({
data: { sha: refToSha }
})
} else {
github.mockOctokit.rest.repos.getCommit.mockImplementation(
async (args: unknown) => {
const { ref } = args as { ref: string }
const sha = refToSha[ref]
if (sha) return { data: { sha } }
throw new Error(`Unknown ref: ${ref}`)
}
)
}
}
const setupTagDoesNotExist = (): void => {
github.mockOctokit.rest.git.getRef.mockRejectedValue({
status: 404
})
}
describe('per-tag annotations', () => {
it('parses per-tag annotation with explicit ref', async () => {
const inputs = createInputs({
tags: ['v1:main:Custom annotation'],
annotation: 'Global annotation'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(1)
expect(operations[0].operation).toBe('create')
expect((operations[0] as CreateOperation).annotation).toBe(
'Custom annotation'
)
})
it('parses per-tag annotation with empty ref (fallback to default)', async () => {
const inputs = createInputs({
tags: ['v1::Custom annotation'],
defaultRef: 'develop',
annotation: 'Global annotation'
})
setupCommitResolver('sha-develop')
setupTagDoesNotExist()
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(1)
expect(operations[0].operation).toBe('create')
expect(operations[0].ref).toBe('develop')
expect((operations[0] as CreateOperation).annotation).toBe(
'Custom annotation'
)
})
it('handles annotation containing colons', async () => {
const inputs = createInputs({
tags: ['v1:main:Release: v1.0.0'],
annotation: ''
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(1)
expect((operations[0] as CreateOperation).annotation).toBe(
'Release: v1.0.0'
)
})
it('falls back to global annotation when per-tag not specified', async () => {
const inputs = createInputs({
tags: ['v1', 'v2:main'],
annotation: 'Global annotation'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(2)
expect((operations[0] as CreateOperation).annotation).toBe(
'Global annotation'
)
expect((operations[1] as CreateOperation).annotation).toBe(
'Global annotation'
)
})
it('mixes per-tag and global annotations', async () => {
const inputs = createInputs({
tags: ['v1:main:Per-tag message', 'v2'],
annotation: 'Global annotation'
})
setupCommitResolver('sha-main')
setupTagDoesNotExist()
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(2)
expect((operations[0] as CreateOperation).annotation).toBe(
'Per-tag message'
)
expect((operations[1] as CreateOperation).annotation).toBe(
'Global annotation'
)
})
it('uses per-tag annotation for update comparison', async () => {
const inputs = createInputs({
tags: ['v1:main:New annotation'],
annotation: 'Global annotation',
whenExists: 'update'
})
setupCommitResolver('sha-main')
// Tag exists with same commit but different annotation
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: {
ref: 'refs/tags/v1',
object: { sha: 'sha-tag-object', type: 'tag' }
}
})
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object',
message: 'Old annotation',
object: { sha: 'sha-main', type: 'commit' }
}
})
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(1)
expect(operations[0].operation).toBe('update')
expect((operations[0] as UpdateOperation).annotation).toBe(
'New annotation'
)
expect((operations[0] as UpdateOperation).reasons).toContain(
'annotation message changed'
)
})
it('skips tag when per-tag annotation matches existing', async () => {
const inputs = createInputs({
tags: ['v1:main:Same annotation'],
annotation: 'Global annotation',
whenExists: 'update'
})
setupCommitResolver('sha-main')
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: {
ref: 'refs/tags/v1',
object: { sha: 'sha-tag-object', type: 'tag' }
}
})
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object',
message: 'Same annotation',
object: { sha: 'sha-main', type: 'commit' }
}
})
const operations = await planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
expect(operations).toHaveLength(1)
expect(operations[0].operation).toBe('skip')
})
it('rejects empty tag name with annotation', async () => {
const inputs = createInputs({
tags: ['::Some annotation']
})
await expect(
planTagOperations(
inputs,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
github.mockOctokit as any
)
).rejects.toThrow("Invalid tag: '::Some annotation'")
})
})
})
describe('executeTagOperation', () => {
beforeEach(() => {