mirror of
https://github.com/jimeh/update-tags-action.git
synced 2026-02-18 17:26:38 +00:00
feat(tags): support per-tag annotation overrides (#81)
This commit is contained in:
20
AGENTS.md
20
AGENTS.md
@@ -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)
|
||||
|
||||
|
||||
98
README.md
98
README.md
@@ -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" -->
|
||||
|
||||
17
action.yml
17
action.yml
@@ -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
26
dist/index.js
generated
vendored
@@ -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
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
29
src/tags.ts
29
src/tags.ts
@@ -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
|
||||
|
||||
@@ -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