41 Commits

Author SHA1 Message Date
jimehbot[bot]
46ebed28cc chore(main): release 2.2.1 (#72)
Co-authored-by: jimehbot[bot] <132453784+jimehbot[bot]@users.noreply.github.com>
2025-12-22 15:45:13 +00:00
dependabot[bot]
04e502b026 chore(deps-dev): bump the npm-development group across 1 directory with 5 updates (#74)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-22 14:54:40 +00:00
dependabot[bot]
fa63dca88c chore(deps): bump @actions/core from 1.11.1 to 2.0.1 (#62)
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-12-22 14:50:55 +00:00
dependabot[bot]
6593b10eb9 chore(deps): bump ruby/setup-ruby from 1.270.0 to 1.275.0 in the actions group (#73)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-22 14:42:42 +00:00
daa038fd7f ci(dependabot-rebuild): refactor workflow (#75) 2025-12-22 14:40:18 +00:00
5580fd8611 chore(deps): update dependabot configuration to include major updates (#69) 2025-12-22 13:11:20 +00:00
aa16a0ff75 chore(ci/workflow): improve license file update process (#70) 2025-12-22 13:09:26 +00:00
741ed40bb7 chore(deps): bump node and ruby versions to latest (#68) 2025-12-22 12:58:03 +00:00
a25f37f9e6 docs(AGENTS.md): update to align with current state of project (#71) 2025-12-22 12:55:52 +00:00
dependabot[bot]
82dc01ff9e chore(deps): bump the actions-minor group with 2 updates (#64)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 18:27:37 +00:00
dependabot[bot]
1d0890efd6 chore(deps-dev): bump the npm-development group with 2 updates (#61) 2025-12-15 18:24:14 +00:00
dependabot[bot]
fcb6d5925b chore(deps-dev): bump the npm-development group across 1 directory with 5 updates (#60) 2025-12-09 08:59:56 +00:00
dependabot[bot]
ecd1432d26 chore(deps): bump the actions-minor group with 4 updates (#59) 2025-12-09 08:55:42 +00:00
dependabot[bot]
73adb32d6a chore(deps-dev): bump prettier from 3.6.2 to 3.7.3 in the npm-development group (#57) 2025-12-01 13:36:47 +00:00
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
37 changed files with 35401 additions and 722 deletions

View File

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

View File

@@ -6,8 +6,9 @@ updates:
schedule:
interval: weekly
groups:
actions-minor:
actions:
update-types:
- major
- minor
- patch
- package-ecosystem: npm
@@ -18,9 +19,12 @@ updates:
npm-development:
dependency-type: development
update-types:
- major
- minor
- patch
npm-production:
dependency-type: production
update-types:
- major
- minor
- patch

View File

@@ -12,5 +12,19 @@
"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,8 +16,8 @@ jobs:
check-dist:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .node-version
cache: npm
@@ -39,7 +39,7 @@ jobs:
fi
- name: Upload Artifact
if: ${{ failure() && steps.diff.outcome == 'failure' }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: dist
path: dist/
@@ -51,10 +51,10 @@ jobs:
packages: read
statuses: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .node-version
cache: npm
@@ -64,10 +64,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .node-version
cache: npm
@@ -85,8 +85,8 @@ jobs:
- uses: jimeh/release-please-manifest-action@84f33fd2828210488c36f3e0a7e3209252d2ae7d # v3.0.0
id: release-please
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
release-tags:
runs-on: ubuntu-latest
@@ -95,7 +95,7 @@ jobs:
permissions:
contents: write
steps:
- uses: jimeh/update-tags-action@bf34cb3d0919fe9e601539e11a89b250e00e9cc3 # v2.0.0
- uses: jimeh/update-tags-action@eecd8caae9a536ed536cff9b2b7f0bd187f67c13 # v2.2.0
with:
tags: |
v${{ needs.release-please.outputs.major }}

View File

@@ -15,13 +15,13 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0
- uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # v1.275.0
with:
ruby-version: ruby
bundler-cache: true

View File

@@ -0,0 +1,61 @@
---
name: Dependabot Rebuild
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
pull-requests: read
jobs:
rebuild:
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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
# Update and push dist if changed.
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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
# Update and push .licenses if changed.
- uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # v1.275.0
with:
ruby-version: ruby
bundler-cache: true
- name: Update Licenses
id: update-licenses
run: bin/licensed cache
- 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: |
.licenses/
commit-message: |-
chore(licensed): update license files

View File

@@ -20,13 +20,24 @@ jobs:
check-licenses:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
# Checkout code using app token.
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
# Setup Node and Ruby runtimes.
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0
- uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # v1.275.0
with:
ruby-version: ruby
bundler-cache: true
@@ -36,17 +47,16 @@ jobs:
name: Update Licenses
id: update-licenses
run: bin/licensed cache
# Then, commit the updated licenses to the repository.
- if: ${{ github.event_name == 'workflow_dispatch' }}
name: Commit Licenses
id: commit-licenses
run: |
git config --local user.email "licensed-ci@users.noreply.github.com"
git config --local user.name "licensed-ci"
git add .
git commit -m "Auto-update license files"
git push
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: |
.licenses/
commit-message: |-
chore(licensed): update license files
# Last, check the status of the cached licenses.
- name: Check Licenses

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

@@ -1,6 +1,6 @@
---
name: "@actions/core"
version: 1.11.1
version: 2.0.1
type: npm
summary: Actions core lib
homepage: https://github.com/actions/toolkit/tree/main/packages/core

View File

@@ -1,6 +1,6 @@
---
name: "@actions/exec"
version: 1.1.1
version: 2.0.0
type: npm
summary: Actions exec lib
homepage: https://github.com/actions/toolkit/tree/main/packages/exec

View File

@@ -0,0 +1,32 @@
---
name: "@actions/http-client"
version: 3.0.0
type: npm
summary: Actions Http Client
homepage: https://github.com/actions/toolkit/tree/main/packages/http-client
license: other
licenses:
- sources: LICENSE
text: |
Actions Http Client for Node.js
Copyright (c) GitHub, Inc.
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
notices: []

View File

@@ -1,6 +1,6 @@
---
name: "@actions/io"
version: 1.1.3
version: 2.0.0
type: npm
summary: Actions io lib
homepage: https://github.com/actions/toolkit/tree/main/packages/io

2
.mise.toml Normal file
View File

@@ -0,0 +1,2 @@
[settings]
idiomatic_version_file_enable_tools = ["ruby", "node"]

View File

@@ -1 +1 @@
24.4.0
24.12.0

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.4.8

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

@@ -21,7 +21,7 @@ up-to-date. Always run `npm run package` (or `npm run bundle`) after modifying
## Development Commands
Package manager: npm (Node 24 via mise.toml)
Package manager: npm
```bash
# Install dependencies
@@ -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
@@ -59,13 +59,14 @@ npm run package:watch # Auto-rebuild on changes
function that coordinates input parsing, tag processing, and output setting
- **[src/inputs.ts](src/inputs.ts)**: Input parsing and validation. Exports
`getInputs()` that reads action inputs and `Inputs` interface
- **[src/tags.ts](src/tags.ts)**: Tag parsing and processing logic:
- `parseTagsInput()`: Parses CSV/newline input, handles `tag:ref` syntax,
pre-resolves all unique refs to SHAs in parallel (optimization)
- `processTag()`: Creates/updates individual tags based on `when_exists` mode
- `resolveRefToSha()`: Converts git refs to commit SHAs (private helper)
- **[src/tags.ts](src/tags.ts)**: Tag planning and execution logic:
- `planTagOperations()`: Parses tags, pre-resolves refs to SHAs in parallel,
plans create/update/skip operations
- `executeTagOperation()`: Executes a single planned operation (create,
update, or skip with logging)
- Private helpers for tag creation, updates, and annotation handling
- **[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
@@ -76,14 +77,16 @@ per-tag ref overrides: `v1:main` tags `v1` to `main` branch.
### Tag Update Logic
1. Parse and validate inputs ([inputs.ts](src/inputs.ts))
2. Parse tags and extract per-tag refs ([tags.ts](src/tags.ts):parseTagsInput)
3. Pre-resolve all unique refs to SHAs in parallel (optimization)
4. For each tag ([tags.ts](src/tags.ts):processTag):
- If exists + update mode: Update if SHA differs
- If exists + skip mode: Skip silently
- If exists + fail mode: Fail action
- If doesn't exist (404): Create it
5. Set outputs with created/updated tag lists ([main.ts](src/main.ts))
2. Plan all tag operations ([tags.ts](src/tags.ts):planTagOperations):
- Parse `tag:ref` syntax and extract per-tag refs
- Pre-resolve all unique refs to SHAs in parallel (optimization)
- For each tag, check existence and determine operation:
- If exists + fail mode: Fail action immediately
- If exists + skip mode: Plan skip
- If exists + update mode: Plan update if SHA or annotation differs
- If doesn't exist (404): Plan create
3. Execute each planned operation ([tags.ts](src/tags.ts):executeTagOperation)
4. Set outputs with created/updated/skipped tag lists ([main.ts](src/main.ts))
### Testing Patterns
@@ -97,12 +100,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
@@ -119,9 +122,10 @@ Mock fixtures live in `__fixtures__/` (e.g., `core.ts` mocks @actions/core).
`.github/workflows/ci.yml` runs:
1. **check-dist**: Verify bundled dist/ matches source
2. **lint**: ESLint with GitHub formatter
3. **release-please**: Semantic versioning releases
4. **release-tags**: Self-referential tag updates after release
2. **lint**: ESLint check
3. **test**: Run Jest test suite
4. **release-please**: Semantic versioning releases
5. **release-tags**: Self-referential tag updates after release
## Release Process
@@ -187,6 +191,7 @@ chore(deps): bump @actions/core to v1.10.0
- `tags`: CSV/newline list, supports `tag:ref` syntax
- `ref`: SHA/ref to tag (default: current commit)
- `when_exists`: update|skip|fail (default: update)
- `annotation`: Optional message for annotated tags (default: lightweight)
- `github_token`: Auth token (default: github.token)
**Outputs:**
@@ -194,6 +199,7 @@ chore(deps): bump @actions/core to v1.10.0
- `tags`: All created/updated tags
- `created`: Newly created tags
- `updated`: Updated tags
- `skipped`: Skipped tags (already matching or when_exists=skip)
## Code Style and Guidelines

View File

@@ -1,5 +1,26 @@
# Changelog
## [2.2.1](https://github.com/jimeh/update-tags-action/compare/v2.2.0...v2.2.1) (2025-12-22)
### Documentation
* **AGENTS.md:** update to align with current state of project ([#71](https://github.com/jimeh/update-tags-action/issues/71)) ([a25f37f](https://github.com/jimeh/update-tags-action/commit/a25f37f9e6300ff3fcdf28424cdec9d99944522f))
## [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)

View File

@@ -27,7 +27,7 @@ to move its own major and minor tags.
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: v2,v2.1
tags: v2,v2.2
```
```yaml
@@ -35,7 +35,7 @@ to move its own major and minor tags.
with:
tags: |
v2
v2.1
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

@@ -38,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

View File

@@ -1 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

34431
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
[tools]
node = "24"

585
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": "2.1.0",
"version": "2.2.1",
"author": "jimeh",
"type": "module",
"private": true,
@@ -41,35 +41,34 @@
"update-readme": "npx action-docs --update-readme && npx prettier --write README.md"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/core": "^2.0.1",
"@actions/github": "^6.0.1",
"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": "^25.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.46.2",
"action-docs": "^2.5.1",
"eslint": "^9.38.0",
"eslint": "^9.39.2",
"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.9.0",
"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": "^3.7.4",
"prettier-eslint": "^16.4.2",
"rollup": "^4.52.5",
"ts-jest": "^29.4.5",
"rollup": "^4.54.0",
"ts-jest": "^29.4.6",
"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,7 +1,7 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import { getInputs } from './inputs.js'
import { resolveDesiredTags, processTag } from './tags.js'
import { planTagOperations, executeTagOperation } from './tags.js'
/**
* The main function for the action.
@@ -10,50 +10,33 @@ import { resolveDesiredTags, processTag } from './tags.js'
*/
export async function run(): Promise<void> {
try {
let inputs
try {
inputs = getInputs()
} catch (error) {
// For parsing/validation errors, pass message directly.
const message = error instanceof Error ? error.message : String(error)
core.setFailed(message)
return
}
// Create GitHub API client
const inputs = getInputs()
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 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, 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,6 +1,6 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import type { Inputs, WhenExistsMode } from './inputs.js'
import type { Inputs } from './inputs.js'
export interface ExistingTagInfo {
commitSHA: string
@@ -8,84 +8,56 @@ export interface ExistingTagInfo {
annotation?: string
}
export interface DesiredTag {
interface BaseOperation {
name: string
ref: string
sha: string
whenExists: WhenExistsMode
annotation: string
owner: string
repo: string
existing?: ExistingTagInfo
}
export type TagResult = 'created' | 'updated' | 'skipped' | 'failed'
export interface CreateOperation extends BaseOperation {
operation: 'create'
annotation: string
}
interface TagOperationContext {
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>
}
/**
* 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.
* Plan tag operations based on 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
* @returns Array of planned tag operations (create, update, or skip)
*/
export async function resolveDesiredTags(
export async function planTagOperations(
inputs: Inputs,
octokit: ReturnType<typeof github.getOctokit>
): Promise<DesiredTag[]> {
const {
tags: parsedTags,
defaultRef,
whenExists,
annotation,
owner,
repo
} = inputs
): 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(
@@ -102,51 +74,46 @@ export async function resolveDesiredTags(
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) {
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 ctx: TagOperationContext = { owner, repo, octokit }
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(ctx, ref)
refSHAs[ref] = await resolveRefToSha(ctx, ref)
})
)
// Build result array with resolved SHAs and check for existing tags.
const tagNames = Object.keys(tags)
const result: DesiredTag[] = await Promise.all(
// Build result array with planned operations
const tagNames = Object.keys(tagRefs)
const result: TagOperation[] = await Promise.all(
tagNames.map(async (tagName) => {
const tagRef = tags[tagName]
const sha = refToSha[tagRef]
const tagRef = tagRefs[tagName]
const sha = refSHAs[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)
existing = await fetchTagInfo(ctx, tagName)
// Fail early if when_exists is 'fail'
if (whenExists === 'fail') {
if (inputs.whenExists === 'fail') {
throw new Error(`Tag '${tagName}' already exists.`)
}
} catch (error: unknown) {
@@ -161,37 +128,161 @@ export async function resolveDesiredTags(
`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)}`
)
throw error
}
}
return {
const baseOp = {
name: tagName,
ref: tagRef,
sha,
whenExists,
annotation,
owner,
repo,
existing
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
}
async function resolveRefToSha(
ctx: TagOperationContext,
ref: string
): Promise<string> {
/**
* Execute a planned tag operation.
*
* @param operation - The planned tag operation to execute
* @param octokit - GitHub API client
*/
export async function executeTagOperation(
operation: TagOperation,
octokit: ReturnType<typeof github.getOctokit>
): Promise<void> {
const ctx: Context = {
owner: operation.owner,
repo: operation.repo,
octokit
}
if (operation.operation === 'skip') {
if (operation.reason === 'when_exists_skip') {
core.info(`Tag '${operation.name}' exists, skipping.`)
} else {
core.info(
`Tag '${operation.name}' already exists with desired commit SHA ${operation.sha}` +
(operation.existingIsAnnotated ? ' (annotated).' : '.')
)
}
return
}
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
}
}
/**
* 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 }
@@ -207,131 +298,94 @@ async function resolveRefToSha(
}
}
/**
* 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
ctx: Context,
operation: UpdateOperation
): Promise<void> {
const commitMatches = operation.existingSHA === operation.sha
if (commitMatches) {
core.info(
`Tag '${tag.name}' exists with same commit but ${reasons.join(', ')}.`
`Tag '${operation.name}' exists with same commit but ${operation.reasons.join(', ')}.`
)
} else {
core.info(
`Tag '${tag.name}' exists` +
`${tag.existing.isAnnotated ? ' (annotated)' : ''}` +
`, updating to ${reasons.join(', ')}.`
`Tag '${operation.name}' exists` +
`${operation.existingIsAnnotated ? ' (annotated)' : ''}` +
`, updating to ${operation.reasons.join(', ')}.`
)
}
const targetSha = await resolveTargetSHA(ctx, tag)
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/${tag.name}`,
ref: `tags/${operation.name}`,
sha: targetSha,
force: true
})
return 'updated'
}
/**
* Create a tag (doesn't exist yet).
*/
async function createTag(
ctx: TagOperationContext,
tag: DesiredTag
): Promise<TagResult> {
ctx: Context,
operation: CreateOperation
): Promise<void> {
core.info(
`Tag '${tag.name}' does not exist, creating with commit SHA ${tag.sha}.`
`Tag '${operation.name}' does not exist, creating with commit SHA ${operation.sha}.`
)
const targetSha = await resolveTargetSHA(ctx, tag)
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/${tag.name}`,
ref: `refs/tags/${operation.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
* @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: TagOperationContext,
tag: DesiredTag
ctx: Context,
tagName: string,
commitSha: string,
annotation: string
): Promise<string> {
if (!tag.annotation) {
return tag.sha
if (!annotation) {
return commitSha
}
const tagObject = await ctx.octokit.rest.git.createTag({
owner: ctx.owner,
repo: ctx.repo,
tag: tag.name,
message: tag.annotation,
object: tag.sha,
tag: tagName,
message: annotation,
object: commitSha,
type: 'commit'
})
@@ -341,63 +395,64 @@ async function resolveTargetSHA(
/**
* Compare existing tag state with desired target state.
*
* @param tag - The desired tag with existing info
* @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(tag: DesiredTag): {
function compareTagState(
sha: string,
annotation: string,
existing: ExistingTagInfo
): {
commitMatches: boolean
annotationMatches: boolean
} {
if (!tag.existing) {
return { commitMatches: false, annotationMatches: false }
}
const isAnnotated = existing.isAnnotated === true
const commitMatches = tag.existing.commitSHA === tag.sha
const commitMatches = existing.commitSHA === sha
const annotationMatches =
tag.existing.isAnnotated && tag.annotation
? tag.existing.annotation === tag.annotation
: !tag.existing.isAnnotated && !tag.annotation
(isAnnotated && !!annotation && existing.annotation === annotation) ||
(!isAnnotated && !annotation) ||
false
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
* @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(tag: DesiredTag): string[] {
if (!tag.existing) return []
const { commitMatches, annotationMatches } = compareTagState(tag)
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 ${tag.sha} (was ${tag.existing.commitSHA})`)
reasons.push(`commit SHA ${sha} (was ${existing.commitSHA})`)
}
if (!annotationMatches && tag.annotation) {
if (tag.existing.isAnnotated) {
if (!annotationMatches && annotation) {
if (existing.isAnnotated === true) {
reasons.push('annotation message changed')
} else {
reasons.push('adding annotation')
}
} else if (
!annotationMatches &&
!tag.annotation &&
tag.existing.isAnnotated
!annotation &&
existing.isAnnotated === true
) {
reasons.push('removing annotation')
}

View File

@@ -2,9 +2,9 @@
* 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'
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)
@@ -22,6 +22,19 @@ const setupInputs = (inputs: Record<string, string>): void => {
})
}
let outputs: Record<string, unknown> = {}
const setupOutputCapture = (): void => {
outputs = {}
core.setOutput.mockImplementation((name: string, value: unknown) => {
outputs[name] = value
})
}
const getOutputs = (): Record<string, unknown> => {
return { ...outputs }
}
const setupCommitResolver = (
refToSha: Record<string, string> | string
): void => {
@@ -80,6 +93,7 @@ describe('run', () => {
// Re-setup mocks after reset
github.getOctokit.mockReturnValue(github.mockOctokit)
csvParse.resetToRealImplementation()
setupOutputCapture()
})
it('creates new tags when they do not exist', async () => {
@@ -111,9 +125,12 @@ describe('run', () => {
expect(core.info).toHaveBeenCalledWith(
"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'])
expect(getOutputs()).toEqual({
created: ['v1', 'v1.0'],
updated: [],
skipped: [],
tags: ['v1', 'v1.0']
})
})
it('updates existing tags when commit SHA differs', async () => {
@@ -140,9 +157,12 @@ describe('run', () => {
expect(core.info).toHaveBeenCalledWith(
"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'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('skips updating when tag exists with same commit SHA', async () => {
@@ -162,9 +182,12 @@ describe('run', () => {
expect(core.info).toHaveBeenCalledWith(
"Tag 'v1' already exists with desired commit SHA sha-abc123."
)
expect(core.setOutput).toHaveBeenCalledWith('created', [])
expect(core.setOutput).toHaveBeenCalledWith('updated', [])
expect(core.setOutput).toHaveBeenCalledWith('tags', [])
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: ['v1'],
tags: []
})
})
it('skips tags when when_exists is skip', async () => {
@@ -182,9 +205,12 @@ describe('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', [])
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: ['v1'],
tags: []
})
})
it('handles per-tag ref overrides', async () => {
@@ -226,7 +252,12 @@ describe('run', () => {
sha: 'sha-develop'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
expect(getOutputs()).toEqual({
created: ['v1', 'v2'],
updated: [],
skipped: [],
tags: ['v1', 'v2']
})
})
it('handles various input formats (newlines and whitespace)', async () => {
@@ -242,11 +273,12 @@ describe('run', () => {
await run()
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(3)
expect(core.setOutput).toHaveBeenCalledWith('created', [
'v1',
'v1.0',
'v1.0.1'
])
expect(getOutputs()).toEqual({
created: ['v1', 'v1.0', 'v1.0.1'],
updated: [],
skipped: [],
tags: ['v1', 'v1.0', 'v1.0.1']
})
})
it('creates and updates tags in single run', async () => {
@@ -263,9 +295,12 @@ describe('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'])
expect(getOutputs()).toEqual({
created: ['v2'],
updated: ['v1'],
skipped: [],
tags: ['v2', 'v1']
})
})
it('fails when ref is missing', async () => {
@@ -420,7 +455,12 @@ describe('run', () => {
await run()
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('handles duplicate tags by using last occurrence', async () => {
@@ -437,7 +477,12 @@ describe('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'])
expect(getOutputs()).toEqual({
created: ['v1', 'v2'],
updated: [],
skipped: [],
tags: ['v1', 'v2']
})
})
it('optimizes by resolving unique refs only once', async () => {
@@ -458,6 +503,12 @@ describe('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)
expect(getOutputs()).toEqual({
created: ['v1', 'v2', 'v3'],
updated: [],
skipped: [],
tags: ['v1', 'v2', 'v3']
})
})
it('handles tag with colon but empty ref part', async () => {
@@ -474,7 +525,12 @@ describe('run', () => {
// Both should use default ref
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
expect(getOutputs()).toEqual({
created: ['v1', 'v2'],
updated: [],
skipped: [],
tags: ['v1', 'v2']
})
})
it('fails when tag specification has multiple colons', async () => {
@@ -522,8 +578,12 @@ describe('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', [])
expect(getOutputs()).toEqual({
created: ['v2', 'v3'],
updated: [],
skipped: ['v1'],
tags: ['v2', 'v3']
})
})
it('fails when tag name is empty (e.g., ":main")', async () => {
@@ -618,7 +678,12 @@ describe('run', () => {
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
expect(getOutputs()).toEqual({
created: ['v1', 'v2'],
updated: [],
skipped: [],
tags: ['v1', 'v2']
})
})
it('skips empty lines in multi-line input (e.g., "v1\\n\\nv2")', async () => {
@@ -647,7 +712,12 @@ describe('run', () => {
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(2)
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v2'])
expect(getOutputs()).toEqual({
created: ['v1', 'v2'],
updated: [],
skipped: [],
tags: ['v1', 'v2']
})
})
it('skips empty tags from mix of empty CSV fields and empty lines', async () => {
@@ -688,12 +758,12 @@ describe('run', () => {
sha: 'sha-abc123'
})
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(4)
expect(core.setOutput).toHaveBeenCalledWith('created', [
'v1',
'v2',
'v3',
'v4'
])
expect(getOutputs()).toEqual({
created: ['v1', 'v2', 'v3', 'v4'],
updated: [],
skipped: [],
tags: ['v1', 'v2', 'v3', 'v4']
})
})
it('creates annotated tags when annotation is provided', async () => {
@@ -748,7 +818,12 @@ describe('run', () => {
sha: 'sha-tag-object-v1.0'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1', 'v1.0'])
expect(getOutputs()).toEqual({
created: ['v1', 'v1.0'],
updated: [],
skipped: [],
tags: ['v1', 'v1.0']
})
})
it('updates existing tags with annotation', async () => {
@@ -787,7 +862,12 @@ describe('run', () => {
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('creates lightweight tags when annotation is empty', async () => {
@@ -812,7 +892,12 @@ describe('run', () => {
sha: 'sha-abc123'
})
expect(core.setOutput).toHaveBeenCalledWith('created', ['v1'])
expect(getOutputs()).toEqual({
created: ['v1'],
updated: [],
skipped: [],
tags: ['v1']
})
})
it('updates lightweight tags when annotation is empty', async () => {
@@ -838,7 +923,12 @@ describe('run', () => {
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('detects and dereferences existing annotated tags', async () => {
@@ -872,7 +962,12 @@ describe('run', () => {
// Should update because commit SHAs differ
expect(github.mockOctokit.rest.git.updateRef).toHaveBeenCalledTimes(1)
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('updates existing annotated tags with new annotated tags', async () => {
@@ -931,7 +1026,12 @@ describe('run', () => {
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('updates annotated tag to lightweight when annotation removed', async () => {
@@ -970,7 +1070,12 @@ describe('run', () => {
sha: 'sha-abc123',
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('skips when annotated tag has same commit and same annotation', async () => {
@@ -1000,7 +1105,12 @@ describe('run', () => {
// 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', [])
expect(getOutputs()).toEqual({
created: [],
updated: [],
skipped: ['v1'],
tags: []
})
})
it('updates when annotated tag has same commit but different annotation', async () => {
@@ -1052,7 +1162,12 @@ describe('run', () => {
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
it('updates lightweight tag to annotated when annotation added', async () => {
@@ -1097,6 +1212,11 @@ describe('run', () => {
force: true
})
expect(core.setOutput).toHaveBeenCalledWith('updated', ['v1'])
expect(getOutputs()).toEqual({
created: [],
updated: ['v1'],
skipped: [],
tags: ['v1']
})
})
})

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"]
}