14 Commits

Author SHA1 Message Date
jimehbot[bot]
f44ca97378 chore(main): release 2.1.0 (#18)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-10-28 01:59:41 +00:00
1041193aa6 chore(readme): attempt 4 at example version bumps (#25) 2025-10-28 01:58:18 +00:00
4db4cf0ae7 ci: remove unnecessary dependency and add more checks for release-tags (#24) 2025-10-28 01:56:49 +00:00
78da350f05 chore(readme): attempt 3 at example version bumping (#23) 2025-10-28 01:54:45 +00:00
c0954ab20f chore(readme): another attempt at example version bumping (#22) 2025-10-28 01:51:46 +00:00
f690607ffe chore(readme): attempt to improve auto version bumping (#21) 2025-10-28 01:48:12 +00:00
20206210a0 chore(release-please): fix package naming config (#20) 2025-10-28 01:37:35 +00:00
c1b031da4b chore(package.json): bump version to 2.0.0 (#19) 2025-10-28 01:33:31 +00:00
40c0c24c34 feat(tag): add support for annotated tags and improved tag handling (#15) 2025-10-28 01:29:46 +00:00
2cb31b2a0a chore(release): update release-please configuration (#17) 2025-10-28 01:24:52 +00:00
5a403d344e chore(ci): bump jimeh/update-tags-action to v2.0.0 (#16) 2025-10-28 00:48:59 +00:00
99a7d3ae1f docs(changelog): update changelog formatting and whitespace (#14) 2025-10-27 22:31:26 +00:00
bdda9e7570 ci(github/workflows): add copilot setup steps workflow (#12) 2025-10-27 22:29:43 +00:00
ba01e004ad ci(licensed): fix typo in comment (#13) 2025-10-27 22:29:07 +00:00
18 changed files with 890 additions and 529 deletions

View File

@@ -1,3 +1,3 @@
{
".": "2.0.0"
".": "2.1.0"
}

View File

@@ -1,14 +1,15 @@
{
"always-update": true,
"packages": {
".": {
"release-type": "simple",
"release-type": "node",
"changelog-path": "CHANGELOG.md",
"extra-files": ["README.md"],
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": false,
"prerelease": false,
"initial-version": "0.0.1"
"include-component-in-tag": false
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"

View File

@@ -77,7 +77,6 @@ jobs:
release-please:
runs-on: ubuntu-latest
if: ${{ github.ref == 'refs/heads/main' }}
needs: [check-dist]
outputs:
release_created: ${{ steps.release-please.outputs.release_created }}
major: ${{ steps.release-please.outputs.major }}
@@ -91,12 +90,12 @@ jobs:
release-tags:
runs-on: ubuntu-latest
needs: release-please
needs: [release-please, check-dist, lint, test]
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write
steps:
- uses: jimeh/update-tags-action@e58fa0f2f874a12bf0eb90ef8ab4256808c0f373 # v1.0.1
- uses: jimeh/update-tags-action@bf34cb3d0919fe9e601539e11a89b250e00e9cc3 # v2.0.0
with:
tags: |
v${{ needs.release-please.outputs.major }}

View File

@@ -0,0 +1,27 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0
with:
ruby-version: ruby
bundler-cache: true

View File

@@ -26,7 +26,7 @@ jobs:
node-version-file: .node-version
cache: npm
- run: npm ci
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 # v1.266.0
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0
with:
ruby-version: ruby
bundler-cache: true

View File

@@ -3,3 +3,4 @@
dist/
node_modules/
coverage/
CHANGELOG.md

View File

@@ -1,5 +1,17 @@
# Changelog
## [2.1.0](https://github.com/jimeh/update-tags-action/compare/v2.0.0...v2.1.0) (2025-10-28)
### Features
* **tag:** add support for annotated tags and improved tag handling ([40c0c24](https://github.com/jimeh/update-tags-action/commit/40c0c24c3478fe96765282b3f82b7f72696f0e92)), closes [#7](https://github.com/jimeh/update-tags-action/issues/7)
### Bug Fixes
* **when_exists:** fail-fast if tags exist if `when_exists` is `fail` ([40c0c24](https://github.com/jimeh/update-tags-action/commit/40c0c24c3478fe96765282b3f82b7f72696f0e92))
## [2.0.0](https://github.com/jimeh/update-tags-action/compare/v1.0.1...v2.0.0) (2025-10-27)
@@ -9,22 +21,25 @@
## [1.0.1](https://github.com/jimeh/update-tags-action/compare/v1.0.0...v1.0.1) (2023-05-18)
### Bug Fixes
- **action:** tweak metadata for GitHub Marketplace
* **action:** tweak metadata for GitHub Marketplace
([#4](https://github.com/jimeh/update-tags-action/issues/4))
([b74b3c7](https://github.com/jimeh/update-tags-action/commit/b74b3c77fc20bdfd61e29dbf680a9f84612e5fda))
## [1.0.0](https://github.com/jimeh/update-tags-action/compare/v0.0.1...v1.0.0) (2023-05-18)
### Miscellaneous Chores
- **release:** bump version to 1.0.0
* **release:** bump version to 1.0.0
([d4f686e](https://github.com/jimeh/update-tags-action/commit/d4f686ef9ff51ff4426907f89983bd286903c23e))
## 0.0.1 (2023-05-18)
### Features
- initial implementation
* initial implementation
([0185b10](https://github.com/jimeh/update-tags-action/commit/0185b100ff1752ce06ade4b147b6befb8c37e525))

View File

@@ -22,43 +22,24 @@ to move its own major and minor tags.
### Basic
<!-- x-release-please-start-major -->
<!-- x-release-please-start-minor -->
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: v1,v1.2
tags: v2,v2.1
```
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1
v1.2
v2
v2.1
```
<!-- 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
@@ -123,13 +104,12 @@ 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 |
| 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 }} |
| 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 }} |
<!-- action-docs-inputs -->

View File

@@ -47,13 +47,17 @@ const setupTagDoesNotExist = (): void => {
})
}
const setupTagExists = (tagName: string, sha: string): void => {
const setupTagExists = (
tagName: string,
sha: string,
type: 'commit' | 'tag' = 'commit'
): void => {
github.mockOctokit.rest.git.getRef.mockImplementation(
async (args: unknown) => {
const { ref } = args as { ref: string }
if (ref === `tags/${tagName}`) {
return {
data: { ref: `refs/tags/${tagName}`, object: { sha } }
data: { ref: `refs/tags/${tagName}`, object: { sha, type } }
}
}
throw { status: 404 }
@@ -61,9 +65,12 @@ const setupTagExists = (tagName: string, sha: string): void => {
)
}
const setupTagExistsForAll = (sha: string): void => {
const setupTagExistsForAll = (
sha: string,
type: 'commit' | 'tag' = 'commit'
): void => {
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: { ref: 'refs/tags/v1', object: { sha } }
data: { ref: 'refs/tags/v1', object: { sha, type } }
})
}
@@ -102,14 +109,14 @@ describe('run', () => {
})
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' does not exist, creating with SHA sha-abc123."
"Tag 'v1' does not exist, creating with commit SHA sha-abc123."
)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v1.0'])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
expect(core.setOutput).toHaveBeenCalledWith('tags', ['v1', 'v1.0'])
})
it('updates existing tags when SHA differs', async () => {
it('updates existing tags when commit SHA differs', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
@@ -131,14 +138,14 @@ describe('run', () => {
})
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' exists, updating to SHA sha-def456 (was sha-old123)."
"Tag 'v1' exists, updating to commit SHA sha-def456 (was sha-old123)."
)
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(core.setOutput).toHaveBeenCalledWith('tags', ['v1'])
})
it('skips updating when tag exists with same SHA', async () => {
it('skips updating when tag exists with same commit SHA', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
@@ -153,7 +160,7 @@ describe('run', () => {
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' already exists with desired SHA sha-abc123."
"Tag 'v1' already exists with desired commit SHA sha-abc123."
)
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
@@ -293,6 +300,40 @@ describe('run', () => {
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
it('fails fast when when_exists is fail and one of multiple tags exists', async () => {
setupInputs({
tags: 'v1,v2,v3',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'fail'
})
setupCommitResolver('sha-abc123')
// Only v2 exists
github.mockOctokit.rest.git.getRef.mockImplementation(
async (args: unknown) => {
const { ref } = args as { ref: string }
if (ref === 'tags/v2') {
return {
data: {
ref: 'refs/tags/v2',
object: { sha: 'sha-old123', type: 'commit' }
}
}
}
throw { status: 404 }
}
)
await run()
// Should fail during desired tags resolution (resolveDesiredTags() in
// tags.ts), before any tags are created
expect(core.setFailed).toHaveBeenCalledWith("Tag 'v2' already exists.")
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
it('fails when when_exists has invalid value', async () => {
setupInputs({
tags: 'v1',
@@ -326,7 +367,7 @@ describe('run', () => {
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Action failed with error')
expect.stringContaining("Failed to check if tag 'v1' exists")
)
})
@@ -655,241 +696,407 @@ describe('run', () => {
])
})
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()
it('creates annotated tags when annotation is provided', async () => {
setupInputs({
tags: 'v1,v1.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Release v1.0'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'tag-object-sha' }
})
github.mockOctokit.rest.git.createTag.mockImplementation(
async (args: unknown) => {
const { tag } = args as { tag: string }
return { data: { sha: `sha-tag-object-${tag}` } }
}
)
await run()
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'])
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Release v1.0',
object: 'sha-abc123',
type: 'commit'
})
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1.0',
message: 'Release v1.0',
object: 'sha-abc123',
type: 'commit'
})
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'])
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-tag-object-v1'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.0',
sha: 'sha-tag-object-v1.0'
})
it('creates lightweight tags when annotation is only whitespace', async () => {
setupInputs({
tags: 'v1.0.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: ' '
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v1.0'])
})
await run()
it('updates existing tags with annotation', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Updated release'
})
setupCommitResolver('sha-def456')
setupTagExistsForAll('sha-old123')
// Should NOT create tag object for whitespace-only annotation
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'])
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-456' }
})
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')
await run()
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'])
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Updated release',
object: 'sha-def456',
type: 'commit'
})
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', [])
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-tag-object-456',
force: true
})
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')
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
// 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'])
it('creates lightweight tags when annotation is empty', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: ''
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1'])
})
it('updates lightweight tags when annotation is empty', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
annotation: ''
})
setupCommitResolver('sha-def456')
setupTagExistsForAll('sha-old123')
await run()
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-def456',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('detects and dereferences existing annotated tags', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-def456')
// Tag exists as annotated tag (type: 'tag', sha is tag object SHA)
setupTagExistsForAll('sha-tag-object-old', 'tag')
// Mock getTag to return the underlying commit SHA
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object-old',
object: { sha: 'sha-old-commit', type: 'commit' }
}
})
await run()
// Should have called getTag to dereference the annotated tag
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag_sha: 'sha-tag-object-old'
})
// Should update because commit SHAs differ
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('updates existing annotated tags with new annotated tags', async () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Updated release'
})
setupCommitResolver('sha-def456')
// Tag exists as annotated tag
setupTagExistsForAll('sha-tag-object-old', 'tag')
// Mock getTag to return the underlying commit SHA
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object-old',
object: { sha: 'sha-old-commit', type: 'commit' }
}
})
// Mock createTag for the new annotated tag
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-new' }
})
await run()
// Should have called getTag to dereference the existing annotated tag
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag_sha: 'sha-tag-object-old'
})
// Should create new annotated tag
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Updated release',
object: 'sha-def456',
type: 'commit'
})
// Should update to new tag object SHA
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-tag-object-new',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('updates annotated tag to lightweight when annotation removed', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: '' // No annotation = lightweight tag
})
setupCommitResolver('sha-abc123')
// Tag exists as annotated tag pointing to same commit
setupTagExistsForAll('sha-tag-object', 'tag')
// Mock getTag to return the same commit SHA as target
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object',
message: 'Old annotation',
object: { sha: 'sha-abc123', type: 'commit' }
}
})
await run()
// Should have called getTag to dereference
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledTimes(1)
// Should update to remove annotation
expect(github.mockOctokit.rest.git.createTag).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-abc123',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('skips when annotated tag has same commit and same annotation', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Release v1'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-tag-object', 'tag')
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object',
message: 'Release v1', // Same annotation
object: { sha: 'sha-abc123', type: 'commit' }
}
})
await run()
// Should have called getTag to dereference
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledTimes(1)
// Should NOT update because both commit and annotation match
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(core.setOutput).toHaveBeenCalledWith('tags', [])
})
it('updates when annotated tag has same commit but different annotation', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'Updated release message'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-tag-object-old', 'tag')
github.mockOctokit.rest.git.getTag.mockResolvedValue({
data: {
sha: 'sha-tag-object-old',
message: 'Old release message', // Different annotation
object: { sha: 'sha-abc123', type: 'commit' }
}
})
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-new' }
})
await run()
// Should have called getTag to check existing annotation
expect(github.mockOctokit.rest.git.getTag).toHaveBeenCalledTimes(1)
// Should create new annotated tag with new message
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'Updated release message',
object: 'sha-abc123',
type: 'commit'
})
// Should update to new tag object
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-tag-object-new',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('updates lightweight tag to annotated when annotation added', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update',
annotation: 'New annotation'
})
setupCommitResolver('sha-abc123')
// Tag exists as lightweight tag pointing to same commit
setupTagExistsForAll('sha-abc123', 'commit')
github.mockOctokit.rest.git.createTag.mockResolvedValue({
data: { sha: 'sha-tag-object-new' }
})
await run()
// Should NOT call getTag since existing tag is lightweight
expect(github.mockOctokit.rest.git.getTag).not.toHaveBeenCalled()
// Should create annotated tag
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createTag).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
tag: 'v1',
message: 'New annotation',
object: 'sha-abc123',
type: 'commit'
})
// Should update to tag object
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'tags/v1',
sha: 'sha-tag-object-new',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
})

View File

@@ -22,8 +22,8 @@ inputs:
default: "update"
annotation:
description: >-
Optional annotation message for the tag. If provided, creates an annotated
tag instead of a lightweight tag.
Optional annotation message for tags. If provided, creates annotated tags.
If empty, creates lightweight tags.
required: false
default: ""
github_token:

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="116" height="20" role="img" aria-label="Coverage: 94.44%"><title>Coverage: 94.44%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="116" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="53" height="20" fill="#4c1"/><rect width="116" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="885" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">94.44%</text><text x="885" y="140" transform="scale(.1)" fill="#fff" textLength="430">94.44%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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

22
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "update-tags-action",
"version": "1.0.1",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "update-tags-action",
"version": "1.0.1",
"version": "2.1.0",
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
@@ -125,6 +125,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1566,6 +1567,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz",
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -2373,6 +2375,7 @@
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.2",
@@ -2403,6 +2406,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2884,6 +2888,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3351,6 +3356,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -4150,6 +4156,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4210,6 +4217,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4336,6 +4344,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6056,6 +6065,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -7889,6 +7899,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8167,6 +8178,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -8788,6 +8800,7 @@
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10075,7 +10088,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tunnel": {
"version": "0.0.6",
@@ -10206,6 +10220,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10279,6 +10294,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},

View File

@@ -1,6 +1,6 @@
{
"name": "update-tags-action",
"version": "1.0.1",
"version": "2.1.0",
"author": "jimeh",
"type": "module",
"private": true,

View File

@@ -1,17 +1,18 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parseTagsInput, type Tag } from './tags.js'
import { parse } from 'csv-parse/sync'
const WHEN_EXISTS_MODES = ['update', 'skip', 'fail'] as const
export type WhenExistsMode = (typeof WHEN_EXISTS_MODES)[number]
export interface Inputs {
tags: Tag[]
tags: string[]
defaultRef: string
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
octokit: ReturnType<typeof github.getOctokit>
token: string
}
/**
@@ -36,27 +37,34 @@ function validateWhenExists(input: string): WhenExistsMode {
*
* @returns Parsed and validated inputs
*/
export async function getInputs(): Promise<Inputs> {
export function getInputs(): Inputs {
const tagsInput: string = core.getInput('tags', { required: true })
const defaultRef: string = core.getInput('ref')
const whenExistsInput = core.getInput('when_exists') || 'update'
const whenExists = validateWhenExists(whenExistsInput)
const annotation: string = core.getInput('annotation') || ''
const annotation: string = core.getInput('annotation')
const token: string = core.getInput('github_token', {
required: true
})
const octokit = github.getOctokit(token)
const { owner, repo } = github.context.repo
const tags = await parseTagsInput(octokit, tagsInput, defaultRef, owner, repo)
// Parse tags as CSV/newline delimited strings
const tags = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
return {
tags,
defaultRef,
whenExists,
annotation,
owner,
repo,
octokit
token
}
}

View File

@@ -1,6 +1,7 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { getInputs } from './inputs.js'
import { processTag } from './tags.js'
import { resolveDesiredTags, processTag } from './tags.js'
/**
* The main function for the action.
@@ -11,7 +12,7 @@ export async function run(): Promise<void> {
try {
let inputs
try {
inputs = await getInputs()
inputs = getInputs()
} catch (error) {
// For parsing/validation errors, pass message directly.
const message = error instanceof Error ? error.message : String(error)
@@ -19,21 +20,26 @@ export async function run(): Promise<void> {
return
}
const { tags, whenExists, annotation, owner, repo, octokit } = inputs
// Create GitHub API client
const octokit = github.getOctokit(inputs.token)
let tags
try {
tags = await resolveDesiredTags(inputs, octokit)
} catch (error) {
// For tag resolution errors (ref resolution, tag existence checks), pass
// message directly.
const message = error instanceof Error ? error.message : String(error)
core.setFailed(message)
return
}
const created: string[] = []
const updated: string[] = []
// Create or update all tags.
for (const tag of tags) {
const result = await processTag(
tag,
whenExists,
annotation,
owner,
repo,
octokit
)
const result = await processTag(tag, octokit)
if (result === 'failed') {
return

View File

@@ -1,39 +1,86 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parse } from 'csv-parse/sync'
import type { Inputs, WhenExistsMode } from './inputs.js'
export interface Tag {
export interface ExistingTagInfo {
commitSHA: string
isAnnotated: boolean
annotation?: string
}
export interface DesiredTag {
name: string
ref: string
sha: string
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
existing?: ExistingTagInfo
}
export type TagResult = 'created' | 'updated' | 'skipped' | 'failed'
/**
* Parse tags input string and resolve refs to SHAs.
*
* @param octokit - The GitHub API client
* @param tagsInput - The raw tags input string
* @param defaultRef - The default ref to use if not specified per-tag
* @param owner - The repository owner
* @param repo - The repository name
* @returns Array of desired tags with resolved SHAs
*/
export async function parseTagsInput(
octokit: ReturnType<typeof github.getOctokit>,
tagsInput: string,
defaultRef: string,
owner: string,
interface TagOperationContext {
owner: string
repo: string
): Promise<Tag[]> {
const parsedTags: string[] = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
octokit: ReturnType<typeof github.getOctokit>
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
*
* @param ctx - Operation context
* @param existing - The existing tag reference data
* @returns Information about the existing tag
*/
async function fetchExistingTagInfo(
ctx: TagOperationContext,
existing: { data: { object: { sha: string; type: string } } }
): Promise<ExistingTagInfo> {
const existingObject = existing.data.object
const isAnnotated = existingObject.type === 'tag'
if (!isAnnotated) {
return {
commitSHA: existingObject.sha,
isAnnotated: false
}
}
// Dereference annotated tag to get underlying commit
const tagObject = await ctx.octokit.rest.git.getTag({
owner: ctx.owner,
repo: ctx.repo,
tag_sha: existingObject.sha
})
return {
commitSHA: tagObject.data.object.sha,
isAnnotated: true,
annotation: tagObject.data.message
}
}
/**
* Resolve desired tag objects from inputs.
*
* @param inputs - The validated inputs containing tags, refs, and configuration
* @param octokit - The GitHub API client
* @returns Array of desired tags with resolved SHAs and configuration
*/
export async function resolveDesiredTags(
inputs: Inputs,
octokit: ReturnType<typeof github.getOctokit>
): Promise<DesiredTag[]> {
const {
tags: parsedTags,
defaultRef,
whenExists,
annotation,
owner,
repo
} = inputs
const uniqueRefs = new Set<string>()
const tags: Record<string, string> = {}
@@ -60,7 +107,7 @@ export async function parseTagsInput(
throw new Error("Missing ref: provide 'ref' input or specify per-tag ref")
}
// Check for duplicate tag with different ref.
// Check for duplicate tag with different ref
if (tags[tagName] && tags[tagName] !== ref) {
throw new Error(
`Duplicate tag '${tagName}' with different refs: ` +
@@ -73,228 +120,84 @@ export async function parseTagsInput(
}
// Pre-resolve all unique refs in parallel.
const ctx: TagOperationContext = { owner, repo, octokit }
const refToSha: Record<string, string> = {}
await Promise.all(
Array.from(uniqueRefs).map(async (ref) => {
refToSha[ref] = await resolveRefToSha(octokit, owner, repo, ref)
refToSha[ref] = await resolveRefToSha(ctx, ref)
})
)
// Build result array with resolved SHAs.
const result: Tag[] = []
for (const tagName in tags) {
const tagRef = tags[tagName]
result.push({
name: tagName,
ref: tagRef,
sha: refToSha[tagRef]
// Build result array with resolved SHAs and check for existing tags.
const tagNames = Object.keys(tags)
const result: DesiredTag[] = await Promise.all(
tagNames.map(async (tagName) => {
const tagRef = tags[tagName]
const sha = refToSha[tagRef]
// Check if tag already exists
let existing: ExistingTagInfo | undefined
try {
const existingRef = await ctx.octokit.rest.git.getRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${tagName}`
})
existing = await fetchExistingTagInfo(ctx, existingRef)
// Fail early if when_exists is 'fail'
if (whenExists === 'fail') {
throw new Error(`Tag '${tagName}' already exists.`)
}
} catch (error: unknown) {
// Check if it's a GitHub API error with a status property
if (typeof error === 'object' && error !== null && 'status' in error) {
const apiError = error as { status: number; message?: string }
if (apiError.status === 404) {
// Tag doesn't exist, existing remains undefined
} else {
// Some other API error
throw new Error(
`Failed to check if tag '${tagName}' exists: ${apiError.message || String(error)}`
)
}
} else if (error instanceof Error) {
// Already an Error (e.g., from when_exists === 'fail')
throw error
} else {
// Unknown error type
throw new Error(
`Failed to check if tag '${tagName}' exists: ${String(error)}`
)
}
}
return {
name: tagName,
ref: tagRef,
sha,
whenExists,
annotation,
owner,
repo,
existing
}
})
}
)
return result
}
/**
* Process a single desired tag: create or update it based on configuration.
*
* @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
* @returns The result of the tag operation
*/
export async function processTag(
tag: Tag,
whenExists: 'update' | 'skip' | 'fail',
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const { name: tagName, sha } = tag
try {
// Check if the tag exists.
const existing = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${tagName}`
})
// If the tag exists, decide action based on 'when_exists'.
if (whenExists === 'update') {
const existingSHA = existing.data.object.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 ${existingCommitSHA}).`
)
await updateTag(tagName, sha, annotation, owner, repo, octokit)
return 'updated'
} else if (whenExists === 'skip') {
core.info(`Tag '${tagName}' exists, skipping.`)
return 'skipped'
} else {
// whenExists === 'fail'
core.setFailed(`Tag '${tagName}' already exists.`)
return 'failed'
}
} catch (error: unknown) {
const err = error as { status?: number }
if (err?.status !== 404) {
throw error
}
// If the tag doesn't exist (404), create it.
core.info(`Tag '${tagName}' does not exist, creating with SHA ${sha}.`)
await createTag(tagName, sha, annotation, owner, repo, octokit)
return 'created'
}
}
/**
* Create a tag object for an annotated tag.
*
* @param tagName - Name of the tag
* @param sha - Commit SHA to tag
* @param annotation - Annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
* @returns SHA of the created tag object
*/
async function createTagObject(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<string> {
const tagObject = await octokit.rest.git.createTag({
owner,
repo,
tag: tagName,
message: annotation,
object: sha,
type: 'commit'
})
return tagObject.data.sha
}
/**
* 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 and non-empty, create an annotated tag object first
if (annotation && annotation.trim()) {
refSha = await createTagObject(
tagName,
sha,
annotation,
owner,
repo,
octokit
)
}
// 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 and non-empty, create an annotated tag object first
if (annotation && annotation.trim()) {
refSha = await createTagObject(
tagName,
sha,
annotation,
owner,
repo,
octokit
)
}
// 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,
repo: string,
ctx: TagOperationContext,
ref: string
): Promise<string> {
try {
const {
data: { sha }
} = await octokit.rest.repos.getCommit({
owner,
repo,
} = await ctx.octokit.rest.repos.getCommit({
owner: ctx.owner,
repo: ctx.repo,
ref
})
@@ -303,3 +206,201 @@ async function resolveRefToSha(
throw new Error(`Failed to resolve ref '${ref}' to a SHA: ${String(error)}`)
}
}
/**
* Process a single desired tag: create or update it based on configuration.
*
* @param tag - The desired tag to process (with existing info if applicable)
* @param octokit - GitHub API client
* @returns The result of the tag operation
*/
export async function processTag(
tag: DesiredTag,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const ctx: TagOperationContext = { owner: tag.owner, repo: tag.repo, octokit }
// Tag doesn't exist, create it
if (!tag.existing) {
return await createTag(ctx, tag)
}
// Tag exists - handle based on when_exists strategy
if (tag.whenExists === 'skip') {
core.info(`Tag '${tag.name}' exists, skipping.`)
return 'skipped'
}
if (tag.whenExists === 'fail') {
// This should not happen as we fail early in resolveDesiredTags
core.setFailed(`Tag '${tag.name}' already exists.`)
return 'failed'
}
// whenExists === 'update' - check if update is needed
if (tagMatchesTarget(tag)) {
core.info(
`Tag '${tag.name}' already exists with desired commit SHA ${tag.sha}` +
(tag.existing.isAnnotated ? ' (annotated).' : '.')
)
return 'skipped'
}
return await updateExistingTag(ctx, tag)
}
/**
* Update an existing tag to point to a new commit and/or annotation.
*/
async function updateExistingTag(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<TagResult> {
if (!tag.existing) {
throw new Error(`Cannot update non-existent tag '${tag.name}'`)
}
const reasons = getUpdateReasons(tag)
const commitMatches = tag.existing.commitSHA === tag.sha
if (commitMatches) {
core.info(
`Tag '${tag.name}' exists with same commit but ${reasons.join(', ')}.`
)
} else {
core.info(
`Tag '${tag.name}' exists` +
`${tag.existing.isAnnotated ? ' (annotated)' : ''}` +
`, updating to ${reasons.join(', ')}.`
)
}
const targetSha = await resolveTargetSHA(ctx, tag)
await ctx.octokit.rest.git.updateRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${tag.name}`,
sha: targetSha,
force: true
})
return 'updated'
}
/**
* Create a tag (doesn't exist yet).
*/
async function createTag(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<TagResult> {
core.info(
`Tag '${tag.name}' does not exist, creating with commit SHA ${tag.sha}.`
)
const targetSha = await resolveTargetSHA(ctx, tag)
await ctx.octokit.rest.git.createRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `refs/tags/${tag.name}`,
sha: targetSha
})
return 'created'
}
/**
* Resolve the target SHA for a tag (creates annotated tag object if needed).
*
* @param ctx - Operation context
* @param tag - The tag to create
* @returns The SHA to use (tag object SHA if annotated, commit SHA otherwise)
*/
async function resolveTargetSHA(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<string> {
if (!tag.annotation) {
return tag.sha
}
const tagObject = await ctx.octokit.rest.git.createTag({
owner: ctx.owner,
repo: ctx.repo,
tag: tag.name,
message: tag.annotation,
object: tag.sha,
type: 'commit'
})
return tagObject.data.sha
}
/**
* Compare existing tag state with desired target state.
*
* @param tag - The desired tag with existing info
* @returns Object indicating whether commit and annotation match
*/
function compareTagState(tag: DesiredTag): {
commitMatches: boolean
annotationMatches: boolean
} {
if (!tag.existing) {
return { commitMatches: false, annotationMatches: false }
}
const commitMatches = tag.existing.commitSHA === tag.sha
const annotationMatches =
tag.existing.isAnnotated && tag.annotation
? tag.existing.annotation === tag.annotation
: !tag.existing.isAnnotated && !tag.annotation
return { commitMatches, annotationMatches }
}
/**
* Check if a tag needs to be updated based on commit and annotation.
*
* @param tag - The desired tag with existing info
* @returns True if the tag matches the target state
*/
function tagMatchesTarget(tag: DesiredTag): boolean {
const { commitMatches, annotationMatches } = compareTagState(tag)
return commitMatches && annotationMatches
}
/**
* Get update reason messages based on what changed.
*
* @param tag - The desired tag with existing info
* @returns Array of reason strings
*/
function getUpdateReasons(tag: DesiredTag): string[] {
if (!tag.existing) return []
const { commitMatches, annotationMatches } = compareTagState(tag)
const reasons: string[] = []
if (!commitMatches) {
reasons.push(`commit SHA ${tag.sha} (was ${tag.existing.commitSHA})`)
}
if (!annotationMatches && tag.annotation) {
if (tag.existing.isAnnotated) {
reasons.push('annotation message changed')
} else {
reasons.push('adding annotation')
}
} else if (
!annotationMatches &&
!tag.annotation &&
tag.existing.isAnnotated
) {
reasons.push('removing annotation')
}
return reasons
}