9 Commits

22 changed files with 587 additions and 346 deletions

View File

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

View File

@@ -0,0 +1,44 @@
---
name: Dependabot Rebuild
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: write
pull-requests: read
jobs:
rebuild-dist:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' && github.event.sender.login == 'dependabot[bot]' }}
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
token: ${{ steps.app-token.outputs.token }}
ref: ${{ github.head_ref }}
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- name: Rebuild dist
run: npm run bundle
- name: Commit and push if changed
run: |
if [ "$(git diff --ignore-space-at-eol --text dist/ | wc -l)" -gt "0" ]; then
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add dist/
git commit -m "chore: rebuild dist"
git push
else
echo "No changes to dist/"
fi

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,5 +1,14 @@
{
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

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

View File

@@ -1,5 +1,12 @@
# Changelog
## [2.2.0](https://github.com/jimeh/update-tags-action/compare/v2.1.1...v2.2.0) (2025-10-29)
### Features
* **action:** add skipped tags output and tracking ([#33](https://github.com/jimeh/update-tags-action/issues/33)) ([6723e4d](https://github.com/jimeh/update-tags-action/commit/6723e4d4aceb3ba7314907830d8b1d5186f0a5d9))
## [2.1.1](https://github.com/jimeh/update-tags-action/compare/v2.1.0...v2.1.1) (2025-10-28)

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 -->
@@ -122,6 +122,7 @@ jobs:
| `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 source="action.yml" -->
<!-- action-docs-runs source="action.yml" -->

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

2
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

84
package-lock.json generated
View File

@@ -1,30 +1,30 @@
{
"name": "update-tags-action",
"version": "2.1.1",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "update-tags-action",
"version": "2.1.1",
"version": "2.2.0",
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"csv-parse": "^6.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/compat": "^1.4.1",
"@jest/globals": "^30.2.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": "^24.9.1",
"@types/node": "^24.10.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"action-docs": "^2.5.1",
"eslint": "^9.38.0",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
@@ -125,7 +125,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -675,13 +674,13 @@
}
},
"node_modules/@eslint/compat": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz",
"integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz",
"integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0"
"@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -735,22 +734,22 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0"
"@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -819,9 +818,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz",
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -842,13 +841,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0",
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
@@ -1567,7 +1566,6 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz",
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -2329,9 +2327,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2375,7 +2373,6 @@
"integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.2",
@@ -2406,7 +2403,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2888,7 +2884,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3356,7 +3351,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -4151,21 +4145,20 @@
}
},
"node_modules/eslint": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz",
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1",
"@eslint/core": "^0.16.0",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.38.0",
"@eslint/plugin-kit": "^0.4.0",
"@eslint/js": "9.39.0",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -4217,7 +4210,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4344,7 +4336,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6065,7 +6056,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -7899,7 +7889,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8178,7 +8167,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -8800,7 +8788,6 @@
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10088,8 +10075,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tunnel": {
"version": "0.0.6",
@@ -10220,7 +10206,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10294,7 +10279,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},

View File

@@ -1,6 +1,6 @@
{
"name": "update-tags-action",
"version": "2.1.1",
"version": "2.2.0",
"author": "jimeh",
"type": "module",
"private": true,
@@ -46,18 +46,18 @@
"csv-parse": "^6.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/compat": "^1.4.1",
"@jest/globals": "^30.2.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": "^24.9.1",
"@types/node": "^24.10.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"action-docs": "^2.5.1",
"eslint": "^9.38.0",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",

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