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

@@ -85,7 +85,16 @@ npm run licensed:cache # Re-cache licenses if needed
### Tag Input Parsing
Uses `csv-parse/sync` to handle both CSV and newline-delimited formats. Supports
per-tag ref overrides: `v1:main` tags `v1` to `main` branch.
per-tag ref and annotation overrides using the format `tag:ref:annotation`:
- `tag` — Use default `ref` and `annotation` inputs
- `tag:ref` — Override ref for this tag (e.g., `v1:main` tags `v1` to `main`)
- `tag:ref:annotation` — Override both ref and annotation
- `tag::annotation` — Override annotation only (empty ref uses default)
Annotations can contain colons; everything after the second colon is treated as
the annotation text. Per-tag values override the global `ref` and `annotation`
inputs.
### Tag Derivation
@@ -105,7 +114,7 @@ conditionals like `{{#if prerelease}}...{{/if}}`.
1. Parse and validate inputs ([inputs.ts](src/inputs.ts))
2. Plan all tag operations ([tags.ts](src/tags.ts):planTagOperations):
- Parse `tag:ref` syntax and extract per-tag refs
- Parse `tag:ref:annotation` syntax and extract per-tag refs/annotations
- Pre-resolve all unique refs to SHAs in parallel (optimization)
- For each tag, check existence and determine operation:
- If exists + fail mode: Fail action immediately
@@ -215,13 +224,14 @@ chore(deps): bump @actions/core to v1.10.0
**Inputs:**
- `tags`: CSV/newline list, supports `tag:ref` syntax
- `tags`: CSV/newline list, supports `tag:ref:annotation` syntax for per-tag
overrides
- `derive_from`: Semver version string to derive tags from (e.g., "v1.2.3")
- `derive_from_template`: Handlebars template for tag derivation (default:
`{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}`)
- `ref`: SHA/ref to tag (default: current commit)
- `ref`: Default SHA/ref to tag (default: current commit)
- `when_exists`: update|skip|fail (default: update)
- `annotation`: Optional message for annotated tags (default: lightweight)
- `annotation`: Default annotation message for tags (default: lightweight/none)
- `dry_run`: Log planned operations without executing (default: false)
- `github_token`: Auth token (default: github.token)

View File

@@ -133,19 +133,91 @@ jobs:
## Inputs
| name | description | required | default |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------- |
| `tags` | <p>List/CSV of tags to create/update.</p> | `false` | `""` |
| `derive_from` | <p>Semver version string to derive tags from (e.g., 'v1.2.3'). When provided, generates tags using <code>derive_from_template</code> input. Default template will produce major and minor tags. (e.g., 'v1', 'v1.2')</p> | `false` | `""` |
| `derive_from_template` | <p>Handlebars template for deriving tags from the <code>derive_from</code> input. CSV/newline-delimited list with placeholders: {{prefix}}, {{major}}, {{minor}}, {{patch}}, {{prerelease}}, {{build}}, {{version}}.</p> | `false` | `{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}` |
| `ref` | <p>The SHA or ref to tag. Defaults to SHA of current commit.</p> | `false` | `${{ github.sha }}` |
| `when_exists` | <p>What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'.</p> | `false` | `update` |
| `annotation` | <p>Optional annotation message for tags. If provided, creates annotated tags. If empty, creates lightweight tags.</p> | `false` | `""` |
| `dry_run` | <p>If true, logs planned operations without executing them.</p> | `false` | `false` |
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
| name | description | required | default |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------- |
| `tags` | <p>List/CSV of tags to create/update. Supports per-tag ref and annotation overrides using the format 'tag:ref:annotation'. Use 'tag::annotation' to specify an annotation with the default ref.</p> | `false` | `""` |
| `derive_from` | <p>Semver version string to derive tags from (e.g., 'v1.2.3'). When provided, generates tags using <code>derive_from_template</code> input. Default template will produce major and minor tags. (e.g., 'v1', 'v1.2')</p> | `false` | `""` |
| `derive_from_template` | <p>Handlebars template for deriving tags from the <code>derive_from</code> input. Uses the same format as the <code>tags</code> input, and supports the following handlebars placeholders: <code>{{prefix}}</code>, <code>{{major}}</code>, <code>{{minor}}</code>, <code>{{patch}}</code>, <code>{{prerelease}}</code>, <code>{{build}}</code>, <code>{{version}}</code>.</p> | `false` | `{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}` |
| `ref` | <p>The SHA or ref to tag. Defaults to SHA of current commit.</p> | `false` | `${{ github.sha }}` |
| `when_exists` | <p>What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'.</p> | `false` | `update` |
| `annotation` | <p>Optional default annotation message for tags. If provided, creates annotated tags. If empty, creates lightweight tags. Can be overridden per-tag using the 'tag:ref:annotation' syntax in the tags input.</p> | `false` | `""` |
| `dry_run` | <p>If true, logs planned operations without executing them.</p> | `false` | `false` |
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
<!-- action-docs-inputs source="action.yml" -->
### Tag Input Syntax
The `tags` input accepts a comma or newline-delimited list of tags. Each tag
specification supports optional per-tag ref and annotation overrides using the
format:
```
tag[:ref[:annotation]]
```
| Format | Description |
| -------------------- | ----------------------------------------------- |
| `tag` | Tag using default `ref` and `annotation` inputs |
| `tag:ref` | Tag using specified ref, default annotation |
| `tag:ref:annotation` | Tag using specified ref and annotation |
| `tag::annotation` | Tag using default ref with specified annotation |
**Per-tag refs** allow different tags to point to different commits:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1:main
v2:develop
```
**Per-tag annotations** allow different annotation messages for each tag:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1:main:Latest v1.x release
v1.2:main:Latest v1.2.x release
```
Use `tag::annotation` to specify an annotation while using the default ref:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1::This is the v1 tag annotation
v1.2::This is the v1.2 tag annotation
```
Per-tag values override the global `ref` and `annotation` inputs:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1:main:Custom annotation for v1
v2
ref: develop
annotation: Default annotation for tags without per-tag override
# v1 -> main with "Custom annotation for v1"
# v2 -> develop with "Default annotation..."
```
Annotations can contain colons (everything after the second colon is the
annotation):
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1:main:Release: version 1.0.0
# Annotation will be "Release: version 1.0.0"
```
### Derive Template Syntax
The `derive_from_template` input uses [Handlebars](https://handlebarsjs.com/)
@@ -172,10 +244,10 @@ value. This is useful for optional components like prerelease or build metadata:
```yaml
- uses: jimeh/update-tags-action@v2
with:
# Creates tag: v1-beta.1
derive_from: v1.2.3-beta.1
derive_from_template: |
{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}
# Creates tag: v1-beta.1
```
For a stable release without prerelease:
@@ -183,10 +255,10 @@ For a stable release without prerelease:
```yaml
- uses: jimeh/update-tags-action@v2
with:
# Creates tag: v1 (prerelease section omitted)
derive_from: v1.2.3
derive_from_template: |
{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}
# Creates tag: v1 (prerelease section omitted)
```
You can also use `{{#unless}}` for inverse logic:
@@ -194,10 +266,10 @@ You can also use `{{#unless}}` for inverse logic:
```yaml
- uses: jimeh/update-tags-action@v2
with:
# Creates tag: v1-stable (only for non-prerelease versions)
derive_from: v1.2.3
derive_from_template: |
{{prefix}}{{major}}{{#unless prerelease}}-stable{{/unless}}
# Creates tag: v1-stable (only for non-prerelease versions)
```
<!-- action-docs-outputs source="action.yml" -->

View File

@@ -8,7 +8,10 @@ branding:
inputs:
tags:
description: "List/CSV of tags to create/update."
description: >-
List/CSV of tags to create/update. Supports per-tag ref and annotation
overrides using the format 'tag:ref:annotation'. Use 'tag::annotation' to
specify an annotation with the default ref.
required: false
derive_from:
description: >-
@@ -18,9 +21,10 @@ inputs:
required: false
derive_from_template:
description: >-
Handlebars template for deriving tags from the `derive_from` input.
CSV/newline-delimited list with placeholders: {{prefix}}, {{major}},
{{minor}}, {{patch}}, {{prerelease}}, {{build}}, {{version}}.
Handlebars template for deriving tags from the `derive_from` input. Uses
the same format as the `tags` input, and supports the following handlebars
placeholders: `{{prefix}}`, `{{major}}`, `{{minor}}`, `{{patch}}`,
`{{prerelease}}`, `{{build}}`, `{{version}}`.
required: false
default: "{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}"
ref:
@@ -35,8 +39,9 @@ inputs:
default: "update"
annotation:
description: >-
Optional annotation message for tags. If provided, creates annotated tags.
If empty, creates lightweight tags.
Optional default annotation message for tags. If provided, creates
annotated tags. If empty, creates lightweight tags. Can be overridden
per-tag using the 'tag:ref:annotation' syntax in the tags input.
required: false
default: ""
dry_run:

26
dist/index.js generated vendored
View File

@@ -45373,16 +45373,16 @@ function createLogger(prefix = '') {
async function planTagOperations(inputs, octokit) {
const uniqueRefs = new Set();
const tagRefs = {};
const tagAnnotations = {};
for (const tag of inputs.tags) {
const parts = tag.split(':').map((s) => s.trim());
if (parts.length > 2) {
throw new Error(`Invalid tag specification '${tag}': too many colons. ` +
`Format should be 'tag' or 'tag:ref'.`);
}
const [tagName, tagRef] = parts;
const parts = tag.split(':');
const tagName = (parts[0] || '').trim();
const tagRef = (parts[1] || '').trim();
// Join remaining parts back with colons to preserve annotation content
const tagAnnotation = parts.slice(2).join(':').trim();
if (!tagName) {
// Skip completely empty tags, but fail on invalid ones like ":main"
if (tagRef) {
if (tagRef || tagAnnotation) {
throw new Error(`Invalid tag: '${tag}'`);
}
continue;
@@ -45397,6 +45397,9 @@ async function planTagOperations(inputs, octokit) {
`'${tagRefs[tagName]}' and '${ref}'`);
}
tagRefs[tagName] = ref;
if (tagAnnotation) {
tagAnnotations[tagName] = tagAnnotation;
}
uniqueRefs.add(ref);
}
// Pre-resolve all unique refs in parallel.
@@ -45410,6 +45413,7 @@ async function planTagOperations(inputs, octokit) {
const result = await Promise.all(tagNames.map(async (tagName) => {
const tagRef = tagRefs[tagName];
const sha = refSHAs[tagRef];
const annotation = tagAnnotations[tagName] || inputs.annotation;
// Check if tag already exists
let existing;
try {
@@ -45445,7 +45449,7 @@ async function planTagOperations(inputs, octokit) {
return {
...baseOp,
operation: 'create',
annotation: inputs.annotation
annotation
};
}
// Tag exists - determine operation based on mode and state
@@ -45458,7 +45462,7 @@ async function planTagOperations(inputs, octokit) {
};
}
// whenExists === 'update' - check if update is needed
const { commitMatches, annotationMatches } = compareTagState(sha, inputs.annotation, existing);
const { commitMatches, annotationMatches } = compareTagState(sha, annotation, existing);
if (commitMatches && annotationMatches) {
return {
...baseOp,
@@ -45468,11 +45472,11 @@ async function planTagOperations(inputs, octokit) {
};
}
// Plan update with reasons
const reasons = getUpdateReasons(sha, inputs.annotation, existing);
const reasons = getUpdateReasons(sha, annotation, existing);
return {
...baseOp,
operation: 'update',
annotation: inputs.annotation,
annotation,
existingSHA: existing.commitSHA,
existingIsAnnotated: existing.isAnnotated,
reasons

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -65,19 +65,18 @@ export async function planTagOperations(
): Promise<TagOperation[]> {
const uniqueRefs = new Set<string>()
const tagRefs: Record<string, string> = {}
const tagAnnotations: Record<string, string> = {}
for (const tag of inputs.tags) {
const parts = tag.split(':').map((s) => s.trim())
if (parts.length > 2) {
throw new Error(
`Invalid tag specification '${tag}': too many colons. ` +
`Format should be 'tag' or 'tag:ref'.`
)
}
const [tagName, tagRef] = parts
const parts = tag.split(':')
const tagName = (parts[0] || '').trim()
const tagRef = (parts[1] || '').trim()
// Join remaining parts back with colons to preserve annotation content
const tagAnnotation = parts.slice(2).join(':').trim()
if (!tagName) {
// Skip completely empty tags, but fail on invalid ones like ":main"
if (tagRef) {
if (tagRef || tagAnnotation) {
throw new Error(`Invalid tag: '${tag}'`)
}
continue
@@ -97,6 +96,9 @@ export async function planTagOperations(
}
tagRefs[tagName] = ref
if (tagAnnotation) {
tagAnnotations[tagName] = tagAnnotation
}
uniqueRefs.add(ref)
}
@@ -115,6 +117,7 @@ export async function planTagOperations(
tagNames.map(async (tagName) => {
const tagRef = tagRefs[tagName]
const sha = refSHAs[tagRef]
const annotation = tagAnnotations[tagName] || inputs.annotation
// Check if tag already exists
let existing: ExistingTagInfo | undefined
@@ -155,7 +158,7 @@ export async function planTagOperations(
return {
...baseOp,
operation: 'create',
annotation: inputs.annotation
annotation
} as CreateOperation
}
@@ -172,7 +175,7 @@ export async function planTagOperations(
// whenExists === 'update' - check if update is needed
const { commitMatches, annotationMatches } = compareTagState(
sha,
inputs.annotation,
annotation,
existing
)
@@ -186,11 +189,11 @@ export async function planTagOperations(
}
// Plan update with reasons
const reasons = getUpdateReasons(sha, inputs.annotation, existing)
const reasons = getUpdateReasons(sha, annotation, existing)
return {
...baseOp,
operation: 'update',
annotation: inputs.annotation,
annotation,
existingSHA: existing.commitSHA,
existingIsAnnotated: existing.isAnnotated,
reasons

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(() => {