41 Commits

Author SHA1 Message Date
dependabot[bot]
0b1a9ea25d chore(deps-dev): bump the npm-development group across 1 directory with 3 updates (#56) 2025-11-24 21:16:08 +00:00
dependabot[bot]
88c489d592 chore(deps): bump the actions-minor group with 3 updates (#55) 2025-11-24 21:00:17 +00:00
dependabot[bot]
4e16af70e2 chore(deps-dev): bump js-yaml from 3.14.1 to 3.14.2 (#51) 2025-11-19 19:44:44 +00:00
dependabot[bot]
88edfaa38b chore(deps-dev): bump the npm-development group across 1 directory with 4 updates (#53) 2025-11-19 19:41:27 +00:00
dependabot[bot]
838b7bd8a1 chore(deps-dev): bump @eslint/compat from 1.4.1 to 2.0.0 (#50) 2025-11-19 19:36:23 +00:00
dependabot[bot]
2df44e1d6e chore(deps): bump glob from 10.4.5 to 10.5.0 (#52) 2025-11-19 19:29:53 +00:00
dependabot[bot]
e33b26678f chore(deps): bump jimeh/update-tags-action from 2.0.0 to 2.2.0 in the actions-minor group (#38)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 18:43:20 +00:00
ce60e958b3 ci(dependabot-rebuild): remove unnecessary ref from checkout action (#48) 2025-11-11 18:41:04 +00:00
dependabot[bot]
441aa939fd chore(deps-dev): bump the npm-development group across 1 directory with 5 updates (#47)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-11-11 18:37:15 +00:00
af088380e8 chore(deps): update dependabot configuration to include minor updates (#41) 2025-11-11 18:30:06 +00:00
dependabot[bot]
d2bac049db chore(deps-dev): bump @rollup/plugin-commonjs from 28.0.9 to 29.0.0 (#36)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-11-11 18:28:50 +00:00
b18dece8b1 ci(dependabot-rebuild): fix files path passed to commit signing action (#46) 2025-11-11 18:24:56 +00:00
5f14685d44 chore(build): disable minifaction of dist/index.js to make rebuilt debugging easier (#45) 2025-11-11 18:23:24 +00:00
605c1cfa34 ci(dependabot-rebuild): third attempt at signing commit with GitHub App (#44) 2025-11-11 18:05:07 +00:00
bd3bacec30 ci(dependabot-rebuild): second attempt at signing commits with GitHub App (#43) 2025-11-11 17:15:18 +00:00
389bd20c41 ci(dependabot-rebuild): configure git user dynamically for dependabot rebuild (#42) 2025-11-11 16:37:09 +00:00
38ccbb6879 ci(github/workflows): update bot secret names in dependabot rebuild workflow (#40) 2025-11-11 11:22:04 +00:00
9016fb217e ci(github/workflows): add dependabot rebuild workflow (#39) 2025-11-11 11:15:08 +00:00
dependabot[bot]
ca138ceed8 chore(deps-dev): bump the npm-development group with 3 updates (#35) 2025-11-04 09:04:09 +00:00
jimehbot[bot]
eecd8caae9 chore(main): release 2.2.0 (#34)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-10-29 13:08:22 +00:00
6723e4d4ac feat(action): add skipped tags output and tracking (#33) 2025-10-29 13:06:38 +00:00
92bdad7a4a refactor(tags): improve tag operation planning and execution (#32) 2025-10-29 12:51:33 +00:00
1576a544fe refactor(tags): simplify tag resolution and processing logic (#30) 2025-10-29 12:09:21 +00:00
2b1c01b3ed test: move tests and fixtures to top-level "tests" directory (#29) 2025-10-29 11:09:22 +00:00
73e6309596 chore(vscode): configure prettier as default formatter for multiple file types (#28) 2025-10-29 10:26:12 +00:00
jimehbot[bot]
08138ee77f chore(main): release 2.1.1 (#27)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-10-28 02:14:18 +00:00
c2d45bd3ef docs(readme): fix outdated action inputs/outputs (#26) 2025-10-28 02:12:53 +00:00
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
30 changed files with 35507 additions and 1199 deletions

View File

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

View File

@@ -8,6 +8,7 @@ updates:
groups:
actions-minor:
update-types:
- major
- minor
- patch
- package-ecosystem: npm
@@ -23,4 +24,5 @@ updates:
npm-production:
dependency-type: production
update-types:
- minor
- patch

View File

@@ -1,15 +1,30 @@
{
"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
}
},
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "feature", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "revert", "section": "Reverts" },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": true },
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "ci", "section": "Continuous Integration", "hidden": true }
],
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}

View File

@@ -16,7 +16,7 @@ jobs:
check-dist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
@@ -51,7 +51,7 @@ jobs:
packages: read
statuses: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
@@ -64,7 +64,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
@@ -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@eecd8caae9a536ed536cff9b2b7f0bd187f67c13 # v2.2.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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
with:
ruby-version: ruby
bundler-cache: true

View File

@@ -0,0 +1,42 @@
---
name: Dependabot Rebuild
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
pull-requests: read
jobs:
rebuild-dist:
runs-on: ubuntu-latest
if: |-
${{ github.actor == 'dependabot[bot]' && github.event.sender.login == 'dependabot[bot]' }}
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
token: ${{ steps.app-token.outputs.token }}
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- name: Rebuild dist
run: npm run bundle
- name: Commit and push if changed
uses: ryancyq/github-signed-commit@e9f3b28c80da7be66d24b8f501a5abe82a6b855f # v1.2.0
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
with:
files: |
dist/
commit-message: |-
chore: rebuild dist

View File

@@ -20,13 +20,13 @@ jobs:
check-licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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 # v1.266.0
- uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
with:
ruby-version: ruby
bundler-cache: true

2
.gitignore vendored
View File

@@ -98,7 +98,7 @@ typings/
Thumbs.db
# Ignore built ts files
__tests__/runner/*
tests/runner/*
# IDE files
.idea

View File

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

View File

@@ -1,5 +1,14 @@
{
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@@ -35,7 +35,7 @@ npm run package # Build src/index.ts -> dist/index.js via Rollup
npm run bundle # Alias: format + package
# Run a single test file
NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest __tests__/main.test.ts
NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest tests/main.test.ts
# CI variants (suppress warnings)
npm run ci-test # Run tests in CI mode
@@ -65,7 +65,7 @@ npm run package:watch # Auto-rebuild on changes
- `processTag()`: Creates/updates individual tags based on `when_exists` mode
- `resolveRefToSha()`: Converts git refs to commit SHAs (private helper)
- **[action.yml](action.yml)**: GitHub Action metadata (inputs/outputs)
- **[\_\_fixtures\_\_/](__fixtures__)**: Mock implementations of @actions/core,
- **[tests/fixtures/](tests/fixtures)**: Mock implementations of @actions/core,
@actions/github, and csv-parse for testing
### Tag Input Parsing
@@ -97,12 +97,12 @@ jest.unstable_mockModule('@actions/core', () => core)
const { run } = await import('../src/main.ts')
```
Mock fixtures live in `__fixtures__/` (e.g., `core.ts` mocks @actions/core).
Mock fixtures live in `tests/fixtures/` (e.g., `core.ts` mocks @actions/core).
### Testing Best Practices
- Consider edge cases as well as the main success path
- Tests live in `__tests__/` directory, fixtures in `__fixtures__/`
- Tests live in `tests/` directory, fixtures in `tests/fixtures/`
- Run tests after any refactoring to ensure coverage requirements are met
- Use `@actions/core` package for logging (not `console`) for GitHub Actions
compatibility

View File

@@ -1,5 +1,31 @@
# Changelog
## [2.2.0](https://github.com/jimeh/update-tags-action/compare/v2.1.1...v2.2.0) (2025-10-29)
### Features
* **action:** add skipped tags output and tracking ([#33](https://github.com/jimeh/update-tags-action/issues/33)) ([6723e4d](https://github.com/jimeh/update-tags-action/commit/6723e4d4aceb3ba7314907830d8b1d5186f0a5d9))
## [2.1.1](https://github.com/jimeh/update-tags-action/compare/v2.1.0...v2.1.1) (2025-10-28)
### Documentation
* **readme:** fix outdated action inputs/outputs ([#26](https://github.com/jimeh/update-tags-action/issues/26)) ([c2d45bd](https://github.com/jimeh/update-tags-action/commit/c2d45bd3eff96a93679cc5dbac166c5a14400751))
## [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 +35,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,20 +22,20 @@ 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.2
```
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v1
v1.2
v2
v2.2
```
<!-- x-release-please-end -->
@@ -100,30 +100,38 @@ jobs:
<!-- x-release-please-end -->
<!-- action-docs-inputs -->
<!-- action-docs-inputs source="action.yml" -->
## 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 }} |
| name | description | required | default |
| -------------- | --------------------------------------------------------------------------------------------------------------------- | -------- | --------------------- |
| `tags` | <p>List/CSV of tags to create/update.</p> | `true` | `""` |
| `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` | `""` |
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
<!-- action-docs-inputs -->
<!-- action-docs-outputs -->
<!-- action-docs-inputs source="action.yml" -->
<!-- action-docs-outputs source="action.yml" -->
## Outputs
| parameter | description |
| --------- | --------------------------------------- |
| tags | List of tags that were created/updated. |
| created | List of tags that were created. |
| updated | List of tags that were updated. |
| name | description |
| --------- | ---------------------------------------------- |
| `tags` | <p>List of tags that were created/updated.</p> |
| `created` | <p>List of tags that were created.</p> |
| `updated` | <p>List of tags that were updated.</p> |
| `skipped` | <p>List of tags that were skipped.</p> |
<!-- action-docs-outputs -->
<!-- action-docs-outputs source="action.yml" -->
<!-- action-docs-runs source="action.yml" -->
## Runs
This action is a `node24` action.
<!-- action-docs-runs source="action.yml" -->
## License

View File

@@ -1,657 +0,0 @@
/**
* Unit tests for the action's main functionality, src/main.ts
*/
import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
import * as github from '../__fixtures__/github.js'
import * as csvParse from '../__fixtures__/csv-parse.js'
// Mocks should be declared before the module being tested is imported.
jest.unstable_mockModule('@actions/core', () => core)
jest.unstable_mockModule('@actions/github', () => github)
jest.unstable_mockModule('csv-parse/sync', () => csvParse)
// The module being tested should be imported dynamically. This ensures that
// the mocks are used in place of any actual dependencies.
const { run } = await import('../src/main.js')
// Helper functions for cleaner test setup
const setupInputs = (inputs: Record<string, string>): void => {
core.getInput.mockImplementation((name: string) => {
return inputs[name] || ''
})
}
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
})
}
const setupTagExists = (tagName: string, sha: string): 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 } }
}
}
throw { status: 404 }
}
)
}
const setupTagExistsForAll = (sha: string): void => {
github.mockOctokit.rest.git.getRef.mockResolvedValue({
data: { ref: 'refs/tags/v1', object: { sha } }
})
}
describe('run', () => {
beforeEach(() => {
jest.resetAllMocks()
// Re-setup mocks after reset
github.getOctokit.mockReturnValue(github.mockOctokit)
csvParse.resetToRealImplementation()
})
it('creates new tags when they do not exist', async () => {
setupInputs({
tags: 'v1,v1.0',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
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-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1.0',
sha: 'sha-abc123'
})
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' does not exist, creating with 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 () => {
setupInputs({
tags: 'v1',
ref: 'def456',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-def456')
setupTagExistsForAll('sha-old123')
await run()
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.info).toHaveBeenCalledWith(
"Tag 'v1' exists, updating to 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 () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-abc123')
await 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."
)
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
expect(core.setOutput).toHaveBeenCalledWith('tags', [])
})
it('skips tags when when_exists is skip', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'skip'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-old123')
await run()
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(core.info).toHaveBeenCalledWith("Tag 'v1' exists, skipping.")
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
expect(core.setOutput).toHaveBeenCalledWith('tags', [])
})
it('handles per-tag ref overrides', async () => {
setupInputs({
tags: 'v1:main,v2:develop',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({
main: 'sha-main',
develop: 'sha-develop'
})
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.repos.getCommit).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'main'
})
expect(github.mockOctokit.rest.repos.getCommit).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'develop'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-main'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v2',
sha: 'sha-develop'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
})
it('handles various input formats (newlines and whitespace)', async () => {
setupInputs({
tags: ' v1 \n v1.0 \n v1.0.1 ',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(3)
expect(core.setOutput).toHaveBeenCalledWith('created', [
'v1',
'v1.0',
'v1.0.1'
])
})
it('creates and updates tags in single run', async () => {
setupInputs({
tags: 'v1,v2',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagExists('v1', 'sha-old')
await run()
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(1)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v2'])
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(core.setOutput).toHaveBeenCalledWith('tags', ['v2', 'v1'])
})
it('fails when ref is missing', async () => {
setupInputs({
tags: 'v1',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Missing ref: provide 'ref' input")
)
})
it('fails when when_exists is fail and tag exists', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'fail'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-old123')
await run()
expect(core.setFailed).toHaveBeenCalledWith("Tag 'v1' 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',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'invalid'
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-old123')
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Invalid value for 'when_exists'")
)
})
it('handles non-404 errors when checking if tag exists', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
github.mockOctokit.rest.git.getRef.mockRejectedValue({
status: 500,
message: 'Internal Server Error'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Action failed with error')
)
})
it('handles errors when resolving ref to SHA', async () => {
setupInputs({
tags: 'v1',
ref: 'invalid-ref',
github_token: 'test-token',
when_exists: 'update'
})
github.mockOctokit.rest.repos.getCommit.mockRejectedValue(
new Error('Reference not found')
)
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Failed to resolve ref 'invalid-ref'")
)
})
it('handles non-Error thrown when parsing tags', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
csvParse.parse.mockImplementation(() => {
throw 'Parse error: not an Error instance'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
'Parse error: not an Error instance'
)
})
it('defaults to update mode when when_exists is empty', async () => {
setupInputs({
tags: 'v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: ''
})
setupCommitResolver('sha-abc123')
setupTagExistsForAll('sha-old123')
await run()
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
})
it('handles duplicate tags by using last occurrence', async () => {
setupInputs({
tags: 'v1,v2,v1',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should only create 2 tags (v1 and v2), not 3
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
})
it('optimizes by resolving unique refs only once', async () => {
setupInputs({
tags: 'v1:main,v2:main,v3:develop',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({
main: 'sha-main',
develop: 'sha-develop'
})
setupTagDoesNotExist()
await run()
// Should only call getCommit 2 times (main and develop), not 3
expect(github.mockOctokit.rest.repos.getCommit).toHaveBeenCalledTimes(2)
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(3)
})
it('handles tag with colon but empty ref part', async () => {
setupInputs({
tags: 'v1:,v2',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Both should use default ref
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
})
it('fails when tag specification has multiple colons', async () => {
setupInputs({
tags: 'stable:refs/heads/main:latest',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('Invalid tag specification')
)
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('too many colons')
)
})
it('handles mixed scenario with multiple tags', async () => {
setupInputs({
tags: 'v1,v2,v3',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'skip'
})
setupCommitResolver('sha-abc123')
// v1 exists, v2 and v3 don't
github.mockOctokit.rest.git.getRef.mockImplementation(
async (args: unknown) => {
const { ref } = args as { ref: string }
if (ref === 'tags/v1') {
return {
data: { ref: 'refs/tags/v1', object: { sha: 'sha-old' } }
}
}
throw { status: 404 }
}
)
await run()
// Should skip v1, create v2 and v3
expect(core.info).toHaveBeenCalledWith("Tag 'v1' exists, skipping.")
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v2', 'v3'])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
})
it('fails when tag name is empty (e.g., ":main")', async () => {
setupInputs({
tags: ':main',
ref: '',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-main')
await run()
expect(core.setFailed).toHaveBeenCalledWith("Invalid tag: ':main'")
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
expect(github.mockOctokit.rest.git.updateRef).not.toHaveBeenCalled()
})
it('fails when one of multiple tags has empty name with ref', async () => {
setupInputs({
tags: 'v1,:develop,v2',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver({
abc123: 'sha-abc123',
develop: 'sha-develop'
})
setupTagDoesNotExist()
await run()
// Should fail on invalid tag during parsing, before processing any tags
expect(core.setFailed).toHaveBeenCalledWith("Invalid tag: ':develop'")
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
it('fails when duplicate tag has different refs (explicit)', async () => {
setupInputs({
tags: 'v1:main,v1:develop',
ref: '',
github_token: 'test-token'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"Duplicate tag 'v1' with different refs: 'main' and 'develop'"
)
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
it('fails when duplicate tag has different refs (default vs explicit)', async () => {
setupInputs({
tags: 'v1,v1:develop',
ref: 'main',
github_token: 'test-token'
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
"Duplicate tag 'v1' with different refs: 'main' and 'develop'"
)
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
})
it('skips empty tags from double commas (e.g., "v1,,v2")', async () => {
setupInputs({
tags: 'v1,,v2',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should skip empty tag and process v1 and v2
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v2',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
})
it('skips empty lines in multi-line input (e.g., "v1\\n\\nv2")', async () => {
setupInputs({
tags: 'v1\n\nv2',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should skip empty line and process v1 and v2
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v2',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
})
it('skips empty tags from mix of empty CSV fields and empty lines', async () => {
setupInputs({
tags: 'v1,,v2\n\nv3,v4',
ref: 'abc123',
github_token: 'test-token',
when_exists: 'update'
})
setupCommitResolver('sha-abc123')
setupTagDoesNotExist()
await run()
// Should skip all empty tags and process v1, v2, v3, v4
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v1',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v2',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v3',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
ref: 'refs/tags/v4',
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(4)
expect(core.setOutput).toHaveBeenCalledWith('created', [
'v1',
'v2',
'v3',
'v4'
])
})
})

View File

@@ -20,6 +20,12 @@ inputs:
'fail'.
required: false
default: "update"
annotation:
description: >-
Optional annotation message for tags. If provided, creates annotated tags.
If empty, creates lightweight tags.
required: false
default: ""
github_token:
description: "The GitHub token to use for authentication."
required: false
@@ -32,6 +38,8 @@ outputs:
description: "List of tags that were created."
updated:
description: "List of tags that were updated."
skipped:
description: "List of tags that were skipped."
runs:
using: node24

33425
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

550
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "update-tags-action",
"version": "1.0.1",
"version": "2.2.0",
"author": "jimeh",
"type": "module",
"private": true,
@@ -46,29 +46,28 @@
"csv-parse": "^6.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/compat": "^2.0.0",
"@jest/globals": "^30.2.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.9.1",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.46.2",
"action-docs": "^2.5.1",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jest": "^29.2.1",
"eslint-plugin-prettier": "^5.5.4",
"jest": "^30.2.0",
"make-coverage-badge": "^1.2.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"prettier-eslint": "^16.4.2",
"rollup": "^4.52.5",
"rollup": "^4.53.3",
"ts-jest": "^29.4.5",
"ts-jest-resolver": "^2.0.1",
"typescript": "^5.9.3"

View File

@@ -2,7 +2,6 @@
import commonjs from '@rollup/plugin-commonjs'
import nodeResolve from '@rollup/plugin-node-resolve'
import terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
const config = {
@@ -13,12 +12,7 @@ const config = {
format: 'es',
sourcemap: true
},
plugins: [
typescript(),
nodeResolve({ preferBuiltins: true }),
commonjs(),
terser()
]
plugins: [typescript(), nodeResolve({ preferBuiltins: true }), commonjs()]
}
export default config

View File

@@ -1,16 +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
}
/**
@@ -35,25 +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 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 { planTagOperations, executeTagOperation } from './tags.js'
/**
* The main function for the action.
@@ -9,38 +10,33 @@ import { processTag } from './tags.js'
*/
export async function run(): Promise<void> {
try {
let inputs
try {
inputs = await getInputs()
} catch (error) {
// For parsing/validation errors, pass message directly.
const message = error instanceof Error ? error.message : String(error)
core.setFailed(message)
return
}
const { tags, whenExists, owner, repo, octokit } = inputs
const inputs = getInputs()
const octokit = github.getOctokit(inputs.token)
const operations = await planTagOperations(inputs, octokit)
const created: string[] = []
const updated: string[] = []
const skipped: string[] = []
// Create or update all tags.
for (const tag of tags) {
const result = await processTag(tag, whenExists, owner, repo, octokit)
// Execute all planned operations.
for (const operation of operations) {
await executeTagOperation(operation, octokit)
if (result === 'failed') {
return
} else if (result === 'created') {
created.push(tag.name)
} else if (result === 'updated') {
updated.push(tag.name)
if (operation.operation === 'create') {
created.push(operation.name)
} else if (operation.operation === 'update') {
updated.push(operation.name)
} else if (operation.operation === 'skip') {
skipped.push(operation.name)
}
}
core.setOutput('created', created)
core.setOutput('updated', updated)
core.setOutput('skipped', skipped)
core.setOutput('tags', created.concat(updated))
} catch (error) {
core.setFailed(`Action failed with error: ${String(error)}`)
const message = error instanceof Error ? error.message : String(error)
core.setFailed(message)
}
}

View File

@@ -1,44 +1,63 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { parse } from 'csv-parse/sync'
import type { Inputs } from './inputs.js'
export interface Tag {
export interface ExistingTagInfo {
commitSHA: string
isAnnotated: boolean
annotation?: string
}
interface BaseOperation {
name: string
ref: string
sha: string
owner: string
repo: string
}
export type TagResult = 'created' | 'updated' | 'skipped' | 'failed'
export interface CreateOperation extends BaseOperation {
operation: 'create'
annotation: string
}
export interface UpdateOperation extends BaseOperation {
operation: 'update'
annotation: string
existingSHA: string
existingIsAnnotated: boolean
reasons: string[]
}
export interface SkipOperation extends BaseOperation {
operation: 'skip'
existingIsAnnotated: boolean
reason: 'when_exists_skip' | 'already_matches'
}
export type TagOperation = CreateOperation | UpdateOperation | SkipOperation
interface Context {
owner: string
repo: string
octokit: ReturnType<typeof github.getOctokit>
}
/**
* Parse tags input string and resolve refs to SHAs.
* Plan tag operations based on inputs.
*
* @param inputs - The validated inputs containing tags, refs, and configuration
* @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
* @returns Array of planned tag operations (create, update, or skip)
*/
export async function parseTagsInput(
octokit: ReturnType<typeof github.getOctokit>,
tagsInput: string,
defaultRef: string,
owner: string,
repo: string
): Promise<Tag[]> {
const parsedTags: string[] = (
parse(tagsInput, {
delimiter: ',',
trim: true,
relax_column_count: true
}) as string[][]
).flat()
export async function planTagOperations(
inputs: Inputs,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagOperation[]> {
const uniqueRefs = new Set<string>()
const tags: Record<string, string> = {}
const tagRefs: Record<string, string> = {}
for (const tag of parsedTags) {
for (const tag of inputs.tags) {
const parts = tag.split(':').map((s) => s.trim())
if (parts.length > 2) {
throw new Error(
@@ -55,130 +74,221 @@ export async function parseTagsInput(
continue
}
const ref = tagRef || defaultRef
const ref = tagRef || inputs.defaultRef
if (!ref) {
throw new Error("Missing ref: provide 'ref' input or specify per-tag ref")
}
// Check for duplicate tag with different ref.
if (tags[tagName] && tags[tagName] !== ref) {
// Check for duplicate tag with different ref
if (tagRefs[tagName] && tagRefs[tagName] !== ref) {
throw new Error(
`Duplicate tag '${tagName}' with different refs: ` +
`'${tags[tagName]}' and '${ref}'`
`'${tagRefs[tagName]}' and '${ref}'`
)
}
tags[tagName] = ref
tagRefs[tagName] = ref
uniqueRefs.add(ref)
}
// Pre-resolve all unique refs in parallel.
const refToSha: Record<string, string> = {}
const ctx: Context = { owner: inputs.owner, repo: inputs.repo, octokit }
const refSHAs: Record<string, string> = {}
await Promise.all(
Array.from(uniqueRefs).map(async (ref) => {
refToSha[ref] = await resolveRefToSha(octokit, owner, repo, ref)
refSHAs[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 planned operations
const tagNames = Object.keys(tagRefs)
const result: TagOperation[] = await Promise.all(
tagNames.map(async (tagName) => {
const tagRef = tagRefs[tagName]
const sha = refSHAs[tagRef]
// Check if tag already exists
let existing: ExistingTagInfo | undefined
try {
existing = await fetchTagInfo(ctx, tagName)
// Fail early if when_exists is 'fail'
if (inputs.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 {
throw error
}
}
const baseOp = {
name: tagName,
ref: tagRef,
sha,
owner: inputs.owner,
repo: inputs.repo
}
// Tag doesn't exist - plan creation
if (!existing) {
return {
...baseOp,
operation: 'create',
annotation: inputs.annotation
} as CreateOperation
}
// Tag exists - determine operation based on mode and state
if (inputs.whenExists === 'skip') {
return {
...baseOp,
operation: 'skip',
existingIsAnnotated: existing.isAnnotated,
reason: 'when_exists_skip'
} as SkipOperation
}
// whenExists === 'update' - check if update is needed
const { commitMatches, annotationMatches } = compareTagState(
sha,
inputs.annotation,
existing
)
if (commitMatches && annotationMatches) {
return {
...baseOp,
operation: 'skip',
existingIsAnnotated: existing.isAnnotated,
reason: 'already_matches'
} as SkipOperation
}
// Plan update with reasons
const reasons = getUpdateReasons(sha, inputs.annotation, existing)
return {
...baseOp,
operation: 'update',
annotation: inputs.annotation,
existingSHA: existing.commitSHA,
existingIsAnnotated: existing.isAnnotated,
reasons
} as UpdateOperation
})
}
)
return result
}
/**
* Process a single desired tag: create or update it based on configuration.
* Execute a planned tag operation.
*
* @param tag - The desired tag to process
* @param whenExists - What to do if the tag already exists
* @param owner - Repository owner
* @param repo - Repository name
* @param operation - The planned tag operation to execute
* @param octokit - GitHub API client
* @returns The result of the tag operation
*/
export async function processTag(
tag: Tag,
whenExists: 'update' | 'skip' | 'fail',
owner: string,
repo: string,
export async function executeTagOperation(
operation: TagOperation,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const { name: tagName, sha } = tag
): Promise<void> {
const ctx: Context = {
owner: operation.owner,
repo: operation.repo,
octokit
}
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
if (existingSHA === sha) {
core.info(`Tag '${tagName}' already exists with desired SHA ${sha}.`)
return 'skipped'
}
core.info(
`Tag '${tagName}' exists, updating to SHA ${sha} ` +
`(was ${existingSHA}).`
)
await octokit.rest.git.updateRef({
owner,
repo,
ref: `tags/${tagName}`,
sha,
force: true
})
return 'updated'
} else if (whenExists === 'skip') {
core.info(`Tag '${tagName}' exists, skipping.`)
return 'skipped'
if (operation.operation === 'skip') {
if (operation.reason === 'when_exists_skip') {
core.info(`Tag '${operation.name}' exists, skipping.`)
} 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
core.info(
`Tag '${operation.name}' already exists with desired commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.')
)
}
return
}
// 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
})
return 'created'
if (operation.operation === 'create') {
await createTag(ctx, operation)
return
}
if (operation.operation === 'update') {
await updateExistingTag(ctx, operation)
return
}
throw new Error(
`Unknown operation type: ${(operation as TagOperation).operation}`
)
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
*
* @param ctx - Operation context
* @param tagName - The name of the tag to fetch
* @returns Information about the existing tag
*/
async function fetchTagInfo(
ctx: Context,
tagName: string
): Promise<ExistingTagInfo> {
const ref = await ctx.octokit.rest.git.getRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${tagName}`
})
const object = ref.data.object
const isAnnotated = object.type === 'tag'
if (!isAnnotated) {
return {
commitSHA: object.sha,
isAnnotated: false
}
}
// Dereference annotated tag to get underlying commit
const tagRef = await ctx.octokit.rest.git.getTag({
owner: ctx.owner,
repo: ctx.repo,
tag_sha: object.sha
})
return {
commitSHA: tagRef.data.object.sha,
isAnnotated: true,
annotation: tagRef.data.message
}
}
async function resolveRefToSha(
octokit: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
ref: string
): Promise<string> {
/**
* Resolve a ref to a SHA.
*
* @param ctx - Operation context
* @param ref - The ref to resolve
* @returns The SHA
*/
async function resolveRefToSha(ctx: Context, 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
})
@@ -187,3 +297,165 @@ async function resolveRefToSha(
throw new Error(`Failed to resolve ref '${ref}' to a SHA: ${String(error)}`)
}
}
/**
* Update an existing tag to point to a new commit and/or annotation.
*/
async function updateExistingTag(
ctx: Context,
operation: UpdateOperation
): Promise<void> {
const commitMatches = operation.existingSHA === operation.sha
if (commitMatches) {
core.info(
`Tag '${operation.name}' exists with same commit but ${operation.reasons.join(', ')}.`
)
} else {
core.info(
`Tag '${operation.name}' exists` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''}` +
`, updating to ${operation.reasons.join(', ')}.`
)
}
const targetSha = await resolveTargetSHA(
ctx,
operation.name,
operation.sha,
operation.annotation
)
await ctx.octokit.rest.git.updateRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `tags/${operation.name}`,
sha: targetSha,
force: true
})
}
/**
* Create a tag (doesn't exist yet).
*/
async function createTag(
ctx: Context,
operation: CreateOperation
): Promise<void> {
core.info(
`Tag '${operation.name}' does not exist, creating with commit SHA ${operation.sha}.`
)
const targetSha = await resolveTargetSHA(
ctx,
operation.name,
operation.sha,
operation.annotation
)
await ctx.octokit.rest.git.createRef({
owner: ctx.owner,
repo: ctx.repo,
ref: `refs/tags/${operation.name}`,
sha: targetSha
})
}
/**
* Resolve the target SHA for a tag (creates annotated tag object if needed).
*
* @param ctx - Operation context
* @param tagName - The tag name
* @param commitSha - The commit SHA
* @param annotation - The annotation message (if any)
* @returns The SHA to use (tag object SHA if annotated, commit SHA otherwise)
*/
async function resolveTargetSHA(
ctx: Context,
tagName: string,
commitSha: string,
annotation: string
): Promise<string> {
if (!annotation) {
return commitSha
}
const tagObject = await ctx.octokit.rest.git.createTag({
owner: ctx.owner,
repo: ctx.repo,
tag: tagName,
message: annotation,
object: commitSha,
type: 'commit'
})
return tagObject.data.sha
}
/**
* Compare existing tag state with desired target state.
*
* @param sha - The desired commit SHA
* @param annotation - The desired annotation
* @param existing - Information about the existing tag
* @returns Object indicating whether commit and annotation match
*/
function compareTagState(
sha: string,
annotation: string,
existing: ExistingTagInfo
): {
commitMatches: boolean
annotationMatches: boolean
} {
const isAnnotated = existing.isAnnotated === true
const commitMatches = existing.commitSHA === sha
const annotationMatches =
(isAnnotated && !!annotation && existing.annotation === annotation) ||
(!isAnnotated && !annotation) ||
false
return { commitMatches, annotationMatches }
}
/**
* Get update reason messages based on what changed.
*
* @param sha - The desired commit SHA
* @param annotation - The desired annotation
* @param existing - Information about the existing tag
* @returns Array of reason strings
*/
function getUpdateReasons(
sha: string,
annotation: string,
existing: ExistingTagInfo
): string[] {
const { commitMatches, annotationMatches } = compareTagState(
sha,
annotation,
existing
)
const reasons: string[] = []
if (!commitMatches) {
reasons.push(`commit SHA ${sha} (was ${existing.commitSHA})`)
}
if (!annotationMatches && annotation) {
if (existing.isAnnotated === true) {
reasons.push('annotation message changed')
} else {
reasons.push('adding annotation')
}
} else if (
!annotationMatches &&
!annotation &&
existing.isAnnotated === true
) {
reasons.push('removing annotation')
}
return reasons
}

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

1222
tests/main.test.ts Normal file

File diff suppressed because it is too large Load Diff

37
tests/tags.test.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Unit tests for tag operation execution, src/tags.ts
*/
import { jest } from '@jest/globals'
import * as core from './fixtures/core.js'
import * as github from './fixtures/github.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'
describe('executeTagOperation', () => {
beforeEach(() => {
jest.resetAllMocks()
github.getOctokit.mockReturnValue(github.mockOctokit)
})
it('throws error for unknown operation type', async () => {
const invalidOperation = {
operation: 'invalid',
name: 'v1',
ref: 'main',
sha: 'abc123',
owner: 'test-owner',
repo: 'test-repo'
} as unknown as TagOperation
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
executeTagOperation(invalidOperation, github.mockOctokit as any)
).rejects.toThrow('Unknown operation type: invalid')
})
})

View File

@@ -7,8 +7,7 @@
},
"exclude": ["dist", "node_modules"],
"include": [
"__fixtures__",
"__tests__",
"tests",
"src",
"eslint.config.mjs",
"jest.config.js",

View File

@@ -6,6 +6,6 @@
"moduleResolution": "NodeNext",
"outDir": "./dist"
},
"exclude": ["__fixtures__", "__tests__", "coverage", "dist", "node_modules"],
"exclude": ["tests", "coverage", "dist", "node_modules"],
"include": ["src"]
}