mirror of
https://github.com/jimeh/update-tags-action.git
synced 2026-02-19 01:26:40 +00:00
feat(tags): support per-tag annotation overrides (#81)
This commit is contained in:
@@ -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'"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user