feat: add support for annotated tags via annotation input

Co-authored-by: jimeh <39563+jimeh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-27 22:14:27 +00:00
parent fed61b3f3c
commit 937c3e6f65
9 changed files with 365 additions and 28 deletions

View File

@@ -40,6 +40,25 @@ to move its own major and minor tags.
<!-- x-release-please-end -->
### Annotated Tags
Create annotated tags with a custom message:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: v1.0.0
annotation: |
Release version 1.0.0
This is a major release with new features and bug fixes.
```
Annotated tags in Git include metadata such as the tagger's name, email, date,
and a message. They are stored as full objects in the Git database and are
recommended for releases. If the `annotation` input is not provided (or is
empty), lightweight tags will be created instead.
### With Release Please
This example uses
@@ -104,12 +123,13 @@ jobs:
## Inputs
| parameter | description | required | default |
| ------------ | --------------------------------------------------------------------------------- | -------- | ------------------- |
| tags | List/CSV of tags to create/update. | `true` | |
| ref | The SHA or ref to tag. Defaults to SHA of current commit. | `false` | ${{ github.sha }} |
| when_exists | What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'. | `false` | update |
| github_token | The GitHub token to use for authentication. | `false` | ${{ github.token }} |
| parameter | description | required | default |
| ------------ | ------------------------------------------------------------------------------------------------------------ | -------- | ------------------- |
| tags | List/CSV of tags to create/update. | `true` | |
| ref | The SHA or ref to tag. Defaults to SHA of current commit. | `false` | ${{ github.sha }} |
| when_exists | What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'. | `false` | update |
| annotation | Optional annotation message for the tag. If provided, creates an annotated tag instead of a lightweight tag. | `false` | |
| github_token | The GitHub token to use for authentication. | `false` | ${{ github.token }} |
<!-- action-docs-inputs -->

View File

@@ -5,7 +5,9 @@ export const mockOctokit = {
git: {
getRef: jest.fn<(args: unknown) => Promise<unknown>>(),
createRef: jest.fn<(args: unknown) => Promise<unknown>>(),
updateRef: jest.fn<(args: unknown) => Promise<unknown>>()
updateRef: jest.fn<(args: unknown) => Promise<unknown>>(),
createTag: jest.fn<(args: unknown) => Promise<unknown>>(),
getTag: jest.fn<(args: unknown) => Promise<unknown>>()
},
repos: {
getCommit: jest.fn<(args: unknown) => Promise<unknown>>()

View File

@@ -654,4 +654,215 @@ describe('run', () => {
'v4'
])
})
describe('annotated tags', () => {
it('creates annotated tags when annotation is provided', async () => {
setupInputs({
tags: 'v1.0.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Release version 1.0.0'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'tag-object-sha' }
})
await run()
// Should create tag object first
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1.0.0',
message: 'Release version 1.0.0',
object: 'sha-abc123',
type: 'commit'
})
// Then create reference pointing to tag object
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.0.0',
sha: 'tag-object-sha'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1.0.0'])
})
it('creates lightweight tags when annotation is empty', async () => {
setupInputs({
tags: 'v1.0.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: ''
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should NOT create tag object
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
// Should create reference pointing directly to commit
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.0.0',
sha: 'sha-abc123'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1.0.0'])
})
it('updates tags with annotation', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Updated version'
})
setupCommitResolver('sha-def456')
setupTagExistsForAll('sha-old123')
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'new-tag-object-sha' }
})
await run()
// Should create new tag object
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Updated version',
object: 'sha-def456',
type: 'commit'
})
// Should update reference to new tag object
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'new-tag-object-sha',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('handles existing annotated tag and compares commit SHA', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Test annotation'
})
setupCommitResolver('sha-abc123')
// Mock existing annotated tag
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: {
ref: 'refs/tags/v1',
object: { sha: 'existing-tag-object-sha', type: 'tag' }
}
})
// Mock getTag to return commit SHA
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'existing-tag-object-sha',
object: { sha: 'sha-abc123', type: 'commit' }
}
})
await run()
// Should fetch tag object to get commit SHA
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag_sha: 'existing-tag-object-sha'
})
// Should skip update since commit SHA matches
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' already exists with desired SHA sha-abc123."
)
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
})
it('updates existing annotated tag when commit SHA differs', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Updated annotation'
})
setupCommitResolver('sha-def456')
// Mock existing annotated tag
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: {
ref: 'refs/tags/v1',
object: { sha: 'existing-tag-object-sha', type: 'tag' }
}
})
// Mock getTag to return different commit SHA
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'existing-tag-object-sha',
object: { sha: 'sha-old123', type: 'commit' }
}
})
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'new-tag-object-sha' }
})
await run()
// Should fetch tag object to get commit SHA
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag_sha: 'existing-tag-object-sha'
})
// Should create new tag object and update reference
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Updated annotation',
object: 'sha-def456',
type: 'commit'
})
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'new-tag-object-sha',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
})
})

View File

@@ -20,6 +20,12 @@ inputs:
'fail'.
required: false
default: "update"
annotation:
description: >-
Optional annotation message for the tag. If provided, creates an annotated
tag instead of a lightweight tag.
required: false
default: ""
github_token:
description: "The GitHub token to use for authentication."
required: false

6
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ export type WhenExistsMode = (typeof WHEN_EXISTS_MODES)[number]
export interface Inputs {
tags: Tag[]
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
octokit: ReturnType<typeof github.getOctokit>
@@ -40,6 +41,7 @@ export async function getInputs(): Promise<Inputs> {
const defaultRef: string = core.getInput('ref')
const whenExistsInput = core.getInput('when_exists') || 'update'
const whenExists = validateWhenExists(whenExistsInput)
const annotation: string = core.getInput('annotation') || ''
const token: string = core.getInput('github_token', {
required: true
})
@@ -52,6 +54,7 @@ export async function getInputs(): Promise<Inputs> {
return {
tags,
whenExists,
annotation,
owner,
repo,
octokit

View File

@@ -19,14 +19,21 @@ export async function run(): Promise<void> {
return
}
const { tags, whenExists, owner, repo, octokit } = inputs
const { tags, whenExists, annotation, owner, repo, octokit } = inputs
const created: string[] = []
const updated: string[] = []
// Create or update all tags.
for (const tag of tags) {
const result = await processTag(tag, whenExists, owner, repo, octokit)
const result = await processTag(
tag,
whenExists,
annotation,
owner,
repo,
octokit
)
if (result === 'failed') {
return

View File

@@ -99,6 +99,7 @@ export async function parseTagsInput(
*
* @param tag - The desired tag to process
* @param whenExists - What to do if the tag already exists
* @param annotation - Optional annotation message for the tag
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
@@ -107,6 +108,7 @@ export async function parseTagsInput(
export async function processTag(
tag: Tag,
whenExists: 'update' | 'skip' | 'fail',
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
@@ -124,22 +126,28 @@ export async function processTag(
// If the tag exists, decide action based on 'when_exists'.
if (whenExists === 'update') {
const existingSHA = existing.data.object.sha
if (existingSHA === sha) {
// For annotated tags, we need to get the commit SHA from the tag object
let existingCommitSHA = existingSHA
if (existing.data.object.type === 'tag') {
const tagObject = await octokit.rest.git.getTag({
owner,
repo,
tag_sha: existingSHA
})
existingCommitSHA = tagObject.data.object.sha
}
if (existingCommitSHA === sha) {
core.info(`Tag '${tagName}' already exists with desired SHA ${sha}.`)
return 'skipped'
}
core.info(
`Tag '${tagName}' exists, updating to SHA ${sha} ` +
`(was ${existingSHA}).`
`(was ${existingCommitSHA}).`
)
await octokit.rest.git.updateRef({
owner,
repo,
ref: `tags/${tagName}`,
sha,
force: true
})
await updateTag(tagName, sha, annotation, owner, repo, octokit)
return 'updated'
} else if (whenExists === 'skip') {
core.info(`Tag '${tagName}' exists, skipping.`)
@@ -157,16 +165,96 @@ export async function processTag(
// If the tag doesn't exist (404), create it.
core.info(`Tag '${tagName}' does not exist, creating with SHA ${sha}.`)
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${tagName}`,
sha
})
await createTag(tagName, sha, annotation, owner, repo, octokit)
return 'created'
}
}
/**
* Create a tag (annotated or lightweight based on annotation parameter).
*
* @param tagName - Name of the tag
* @param sha - Commit SHA to tag
* @param annotation - Optional annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
*/
async function createTag(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<void> {
let refSha = sha
// If annotation is provided, create an annotated tag object first
if (annotation) {
const tagObject = await octokit.rest.git.createTag({
owner,
repo,
tag: tagName,
message: annotation,
object: sha,
type: 'commit'
})
refSha = tagObject.data.sha
}
// Create the reference pointing to the tag object (or commit for lightweight)
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${tagName}`,
sha: refSha
})
}
/**
* Update a tag to point to a new SHA.
*
* @param tagName - Name of the tag
* @param sha - New commit SHA
* @param annotation - Optional annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
*/
async function updateTag(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<void> {
let refSha = sha
// If annotation is provided, create an annotated tag object first
if (annotation) {
const tagObject = await octokit.rest.git.createTag({
owner,
repo,
tag: tagName,
message: annotation,
object: sha,
type: 'commit'
})
refSha = tagObject.data.sha
}
// Update the reference
await octokit.rest.git.updateRef({
owner,
repo,
ref: `tags/${tagName}`,
sha: refSha,
force: true
})
}
async function resolveRefToSha(
octokit: ReturnType<typeof github.getOctokit>,
owner: string,