4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
eb98d277e4 refactor: extract tag object creation and improve annotation handling
Co-authored-by: jimeh <39563+jimeh@users.noreply.github.com>
2025-10-27 22:17:36 +00:00
copilot-swe-agent[bot]
937c3e6f65 feat: add support for annotated tags via annotation input
Co-authored-by: jimeh <39563+jimeh@users.noreply.github.com>
2025-10-27 22:14:27 +00:00
copilot-swe-agent[bot]
fed61b3f3c Initial exploration of annotated tags feature
Co-authored-by: jimeh <39563+jimeh@users.noreply.github.com>
2025-10-27 22:08:25 +00:00
copilot-swe-agent[bot]
a77500883a Initial plan 2025-10-27 22:05:41 +00:00
39 changed files with 1626 additions and 36666 deletions

View File

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

View File

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

View File

@@ -1,30 +1,15 @@
{
"always-update": true,
"packages": {
".": {
"release-type": "node",
"release-type": "simple",
"changelog-path": "CHANGELOG.md",
"extra-files": ["README.md"],
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": false,
"prerelease": false,
"include-component-in-tag": false
"initial-version": "0.0.1"
}
},
"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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: dist
path: dist/
@@ -51,10 +51,10 @@ jobs:
packages: read
statuses: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
@@ -64,10 +64,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
@@ -77,6 +77,7 @@ 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 }}
@@ -85,17 +86,17 @@ jobs:
- uses: jimeh/release-please-manifest-action@84f33fd2828210488c36f3e0a7e3209252d2ae7d # v3.0.0
id: release-please
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
release-tags:
runs-on: ubuntu-latest
needs: [release-please, check-dist, lint, test]
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write
steps:
- uses: jimeh/update-tags-action@eecd8caae9a536ed536cff9b2b7f0bd187f67c13 # v2.2.0
- uses: jimeh/update-tags-action@e58fa0f2f874a12bf0eb90ef8ab4256808c0f373 # v1.0.1
with:
tags: |
v${{ needs.release-please.outputs.major }}

View File

@@ -1,27 +0,0 @@
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@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@d354de180d0c9e813cfddfcbdc079945d4be589b # v1.275.0
with:
ruby-version: ruby
bundler-cache: true

View File

@@ -1,61 +0,0 @@
---
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,24 +20,13 @@ jobs:
check-licenses:
runs-on: ubuntu-latest
steps:
# 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
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- uses: ruby/setup-ruby@d354de180d0c9e813cfddfcbdc079945d4be589b # v1.275.0
- uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 # v1.266.0
with:
ruby-version: ruby
bundler-cache: true
@@ -47,16 +36,17 @@ 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 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
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
# 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: 2.0.1
version: 1.11.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: 2.0.0
version: 1.1.1
type: npm
summary: Actions exec lib
homepage: https://github.com/actions/toolkit/tree/main/packages/exec

View File

@@ -1,32 +0,0 @@
---
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: 2.0.0
version: 1.1.3
type: npm
summary: Actions io lib
homepage: https://github.com/actions/toolkit/tree/main/packages/io

View File

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

View File

@@ -1 +1 @@
24.12.0
24.4.0

View File

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

View File

@@ -1 +0,0 @@
3.4.8

View File

@@ -1,14 +1,5 @@
{
"[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
Package manager: npm (Node 24 via mise.toml)
```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,14 +59,13 @@ 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 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
- **[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)
- **[action.yml](action.yml)**: GitHub Action metadata (inputs/outputs)
- **[tests/fixtures/](tests/fixtures)**: Mock implementations of @actions/core,
- **[\_\_fixtures\_\_/](__fixtures__)**: Mock implementations of @actions/core,
@actions/github, and csv-parse for testing
### Tag Input Parsing
@@ -77,16 +76,14 @@ 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. 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))
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))
### Testing Patterns
@@ -100,12 +97,12 @@ jest.unstable_mockModule('@actions/core', () => core)
const { run } = await import('../src/main.ts')
```
Mock fixtures live in `tests/fixtures/` (e.g., `core.ts` mocks @actions/core).
Mock fixtures live in `__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 `tests/fixtures/`
- Tests live in `__tests__/` directory, fixtures in `__fixtures__/`
- Run tests after any refactoring to ensure coverage requirements are met
- Use `@actions/core` package for logging (not `console`) for GitHub Actions
compatibility
@@ -122,10 +119,9 @@ Mock fixtures live in `tests/fixtures/` (e.g., `core.ts` mocks @actions/core).
`.github/workflows/ci.yml` runs:
1. **check-dist**: Verify bundled dist/ matches source
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
2. **lint**: ESLint with GitHub formatter
3. **release-please**: Semantic versioning releases
4. **release-tags**: Self-referential tag updates after release
## Release Process
@@ -191,7 +187,6 @@ 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:**
@@ -199,7 +194,6 @@ 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,38 +1,5 @@
# 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)
### 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)
@@ -42,25 +9,22 @@
## [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,24 +22,43 @@ to move its own major and minor tags.
### Basic
<!-- x-release-please-start-minor -->
<!-- x-release-please-start-major -->
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: v2,v2.2
tags: v1,v1.2
```
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: |
v2
v2.2
v1
v1.2
```
<!-- x-release-please-end -->
### Annotated Tags
Create annotated tags with a custom message:
```yaml
- uses: jimeh/update-tags-action@v2
with:
tags: v1.0.0
annotation: |
Release version 1.0.0
This is a major release with new features and bug fixes.
```
Annotated tags in Git include metadata such as the tagger's name, email, date,
and a message. They are stored as full objects in the Git database and are
recommended for releases. If the `annotation` input is not provided (or is
empty), lightweight tags will be created instead.
### With Release Please
This example uses
@@ -100,38 +119,31 @@ jobs:
<!-- x-release-please-end -->
<!-- action-docs-inputs source="action.yml" -->
<!-- action-docs-inputs -->
## Inputs
| 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 }}` |
| parameter | description | required | default |
| ------------ | ------------------------------------------------------------------------------------------------------------ | -------- | ------------------- |
| tags | List/CSV of tags to create/update. | `true` | |
| ref | The SHA or ref to tag. Defaults to SHA of current commit. | `false` | ${{ github.sha }} |
| when_exists | What to do if the tag already exists. Must be one of 'update', 'skip', or 'fail'. | `false` | update |
| annotation | Optional annotation message for the tag. If provided, creates an annotated tag instead of a lightweight tag. | `false` | |
| github_token | The GitHub token to use for authentication. | `false` | ${{ github.token }} |
<!-- action-docs-inputs source="action.yml" -->
<!-- action-docs-outputs source="action.yml" -->
<!-- action-docs-inputs -->
<!-- action-docs-outputs -->
## Outputs
| 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> |
| parameter | description |
| --------- | --------------------------------------- |
| tags | List of tags that were created/updated. |
| created | List of tags that were created. |
| updated | List of tags that were updated. |
<!-- 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" -->
<!-- action-docs-outputs -->
## License

895
__tests__/main.test.ts Normal file
View File

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

View File

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

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

2
mise.toml Normal file
View File

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

603
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.2.1",
"version": "1.0.1",
"author": "jimeh",
"type": "module",
"private": true,
@@ -41,34 +41,35 @@
"update-readme": "npx action-docs --update-readme && npx prettier --write README.md"
},
"dependencies": {
"@actions/core": "^2.0.1",
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"csv-parse": "^6.1.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/compat": "^1.4.0",
"@jest/globals": "^30.2.0",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@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": "^25.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@types/node": "^24.9.1",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"action-docs": "^2.5.1",
"eslint": "^9.39.2",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.9.0",
"eslint-plugin-jest": "^29.0.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.7.4",
"prettier": "^3.6.2",
"prettier-eslint": "^16.4.2",
"rollup": "^4.54.0",
"ts-jest": "^29.4.6",
"rollup": "^4.52.5",
"ts-jest": "^29.4.5",
"ts-jest-resolver": "^2.0.1",
"typescript": "^5.9.3"
}

View File

@@ -2,6 +2,7 @@
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 = {
@@ -12,7 +13,12 @@ const config = {
format: 'es',
sourcemap: true
},
plugins: [typescript(), nodeResolve({ preferBuiltins: true }), commonjs()]
plugins: [
typescript(),
nodeResolve({ preferBuiltins: true }),
commonjs(),
terser()
]
}
export default config

View File

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

View File

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

View File

@@ -1,63 +1,44 @@
import * as core from '@actions/core'
import * as github from '@actions/github'
import type { Inputs } from './inputs.js'
import { parse } from 'csv-parse/sync'
export interface ExistingTagInfo {
commitSHA: string
isAnnotated: boolean
annotation?: string
}
interface BaseOperation {
export interface Tag {
name: string
ref: string
sha: string
owner: string
repo: string
}
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>
}
export type TagResult = 'created' | 'updated' | 'skipped' | 'failed'
/**
* Plan tag operations based on inputs.
* Parse tags input string and resolve refs to SHAs.
*
* @param inputs - The validated inputs containing tags, refs, and configuration
* @param octokit - The GitHub API client
* @returns Array of planned tag operations (create, update, or skip)
* @param tagsInput - The raw tags input string
* @param defaultRef - The default ref to use if not specified per-tag
* @param owner - The repository owner
* @param repo - The repository name
* @returns Array of desired tags with resolved SHAs
*/
export async function planTagOperations(
inputs: Inputs,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagOperation[]> {
const uniqueRefs = new Set<string>()
const tagRefs: Record<string, string> = {}
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()
for (const tag of inputs.tags) {
const uniqueRefs = new Set<string>()
const tags: Record<string, string> = {}
for (const tag of parsedTags) {
const parts = tag.split(':').map((s) => s.trim())
if (parts.length > 2) {
throw new Error(
@@ -74,221 +55,246 @@ export async function planTagOperations(
continue
}
const ref = tagRef || inputs.defaultRef
const ref = tagRef || defaultRef
if (!ref) {
throw new Error("Missing ref: provide 'ref' input or specify per-tag ref")
}
// Check for duplicate tag with different ref
if (tagRefs[tagName] && tagRefs[tagName] !== ref) {
// Check for duplicate tag with different ref.
if (tags[tagName] && tags[tagName] !== ref) {
throw new Error(
`Duplicate tag '${tagName}' with different refs: ` +
`'${tagRefs[tagName]}' and '${ref}'`
`'${tags[tagName]}' and '${ref}'`
)
}
tagRefs[tagName] = ref
tags[tagName] = ref
uniqueRefs.add(ref)
}
// Pre-resolve all unique refs in parallel.
const ctx: Context = { owner: inputs.owner, repo: inputs.repo, octokit }
const refSHAs: Record<string, string> = {}
const refToSha: Record<string, string> = {}
await Promise.all(
Array.from(uniqueRefs).map(async (ref) => {
refSHAs[ref] = await resolveRefToSha(ctx, ref)
refToSha[ref] = await resolveRefToSha(octokit, owner, repo, ref)
})
)
// 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
// 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]
})
)
}
return result
}
/**
* Execute a planned tag operation.
* Process a single desired tag: create or update it based on configuration.
*
* @param operation - The planned tag operation to execute
* @param tag - The desired tag to process
* @param whenExists - What to do if the tag already exists
* @param annotation - Optional annotation message for the tag
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
* @returns The result of the tag operation
*/
export async function processTag(
tag: Tag,
whenExists: 'update' | 'skip' | 'fail',
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<TagResult> {
const { name: tagName, sha } = tag
try {
// Check if the tag exists.
const existing = await octokit.rest.git.getRef({
owner,
repo,
ref: `tags/${tagName}`
})
// If the tag exists, decide action based on 'when_exists'.
if (whenExists === 'update') {
const existingSHA = existing.data.object.sha
// For annotated tags, we need to get the commit SHA from the tag object
let existingCommitSHA = existingSHA
if (existing.data.object.type === 'tag') {
const tagObject = await octokit.rest.git.getTag({
owner,
repo,
tag_sha: existingSHA
})
existingCommitSHA = tagObject.data.object.sha
}
if (existingCommitSHA === sha) {
core.info(`Tag '${tagName}' already exists with desired SHA ${sha}.`)
return 'skipped'
}
core.info(
`Tag '${tagName}' exists, updating to SHA ${sha} ` +
`(was ${existingCommitSHA}).`
)
await updateTag(tagName, sha, annotation, owner, repo, octokit)
return 'updated'
} else if (whenExists === 'skip') {
core.info(`Tag '${tagName}' exists, skipping.`)
return 'skipped'
} else {
// whenExists === 'fail'
core.setFailed(`Tag '${tagName}' already exists.`)
return 'failed'
}
} catch (error: unknown) {
const err = error as { status?: number }
if (err?.status !== 404) {
throw error
}
// If the tag doesn't exist (404), create it.
core.info(`Tag '${tagName}' does not exist, creating with SHA ${sha}.`)
await createTag(tagName, sha, annotation, owner, repo, octokit)
return 'created'
}
}
/**
* Create a tag object for an annotated tag.
*
* @param tagName - Name of the tag
* @param sha - Commit SHA to tag
* @param annotation - Annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
* @returns SHA of the created tag object
*/
async function createTagObject(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<string> {
const tagObject = await octokit.rest.git.createTag({
owner,
repo,
tag: tagName,
message: annotation,
object: sha,
type: 'commit'
})
return tagObject.data.sha
}
/**
* Create a tag (annotated or lightweight based on annotation parameter).
*
* @param tagName - Name of the tag
* @param sha - Commit SHA to tag
* @param annotation - Optional annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
*/
export async function executeTagOperation(
operation: TagOperation,
async function createTag(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<void> {
const ctx: Context = {
owner: operation.owner,
repo: operation.repo,
octokit
let refSha = sha
// If annotation is provided and non-empty, create an annotated tag object first
if (annotation && annotation.trim()) {
refSha = await createTagObject(
tagName,
sha,
annotation,
owner,
repo,
octokit
)
}
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}`
)
// Create the reference pointing to the tag object (or commit for lightweight)
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${tagName}`,
sha: refSha
})
}
/**
* Fetch information about an existing tag, dereferencing if annotated.
* Update a tag to point to a new SHA.
*
* @param ctx - Operation context
* @param tagName - The name of the tag to fetch
* @returns Information about the existing tag
* @param tagName - Name of the tag
* @param sha - New commit SHA
* @param annotation - Optional annotation message
* @param owner - Repository owner
* @param repo - Repository name
* @param octokit - GitHub API client
*/
async function 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'
async function updateTag(
tagName: string,
sha: string,
annotation: string,
owner: string,
repo: string,
octokit: ReturnType<typeof github.getOctokit>
): Promise<void> {
let refSha = sha
if (!isAnnotated) {
return {
commitSHA: object.sha,
isAnnotated: false
}
// If annotation is provided and non-empty, create an annotated tag object first
if (annotation && annotation.trim()) {
refSha = await createTagObject(
tagName,
sha,
annotation,
owner,
repo,
octokit
)
}
// 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
// Update the reference
await octokit.rest.git.updateRef({
owner,
repo,
ref: `tags/${tagName}`,
sha: refSha,
force: true
})
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> {
async function resolveRefToSha(
octokit: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
ref: string
): Promise<string> {
try {
const {
data: { sha }
} = await ctx.octokit.rest.repos.getCommit({
owner: ctx.owner,
repo: ctx.repo,
} = await octokit.rest.repos.getCommit({
owner,
repo,
ref
})
@@ -297,165 +303,3 @@ async function resolveRefToSha(ctx: Context, ref: string): Promise<string> {
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
/**
* 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,7 +7,8 @@
},
"exclude": ["dist", "node_modules"],
"include": [
"tests",
"__fixtures__",
"__tests__",
"src",
"eslint.config.mjs",
"jest.config.js",

View File

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