mirror of
https://github.com/jimeh/update-tags-action.git
synced 2026-02-19 01:26:40 +00:00
feat(tags/derive): add semver tag derivation functionality (#79)
This commit is contained in:
36
.licenses/npm/handlebars.dep.yml
Normal file
36
.licenses/npm/handlebars.dep.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: handlebars
|
||||
version: 4.7.8
|
||||
type: npm
|
||||
summary: Handlebars provides the power necessary to let you build semantic templates
|
||||
effectively with no frustration
|
||||
homepage: https://www.handlebarsjs.com/
|
||||
license: mit
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
Copyright (C) 2011-2019 by Yehuda Katz
|
||||
|
||||
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.
|
||||
- sources: README.markdown
|
||||
text: |-
|
||||
Handlebars.js is released under the MIT license.
|
||||
|
||||
[pull-request]: https://github.com/handlebars-lang/handlebars.js/pull/new/master
|
||||
notices: []
|
||||
44
.licenses/npm/minimist.dep.yml
Normal file
44
.licenses/npm/minimist.dep.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: minimist
|
||||
version: 1.2.8
|
||||
type: npm
|
||||
summary: parse argument options
|
||||
homepage: https://github.com/minimistjs/minimist
|
||||
license: other
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
This software is released under the 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.
|
||||
- sources: README.md
|
||||
text: |-
|
||||
MIT
|
||||
|
||||
[package-url]: https://npmjs.org/package/minimist
|
||||
[npm-version-svg]: https://versionbadg.es/minimistjs/minimist.svg
|
||||
[npm-badge-png]: https://nodei.co/npm/minimist.png?downloads=true&stars=true
|
||||
[license-image]: https://img.shields.io/npm/l/minimist.svg
|
||||
[license-url]: LICENSE
|
||||
[downloads-image]: https://img.shields.io/npm/dm/minimist.svg
|
||||
[downloads-url]: https://npm-stat.com/charts.html?package=minimist
|
||||
[codecov-image]: https://codecov.io/gh/minimistjs/minimist/branch/main/graphs/badge.svg
|
||||
[codecov-url]: https://app.codecov.io/gh/minimistjs/minimist/
|
||||
[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/minimistjs/minimist
|
||||
[actions-url]: https://github.com/minimistjs/minimist/actions
|
||||
notices: []
|
||||
36
.licenses/npm/neo-async.dep.yml
Normal file
36
.licenses/npm/neo-async.dep.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: neo-async
|
||||
version: 2.6.2
|
||||
type: npm
|
||||
summary: 'Neo-Async is a drop-in replacement for Async, it almost fully covers its
|
||||
functionality and runs faster '
|
||||
homepage: https://github.com/suguru03/neo-async
|
||||
license: other
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014-2018 Suguru Motegi
|
||||
Based on Async.js, Copyright Caolan McMahon
|
||||
|
||||
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.
|
||||
- sources: README.md
|
||||
text: "[](https://app.fossa.io/projects/git%2Bgithub.com%2Fsuguru03%2Fneo-async?ref=badge_large)"
|
||||
notices: []
|
||||
26
.licenses/npm/semver.dep.yml
Normal file
26
.licenses/npm/semver.dep.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: semver
|
||||
version: 7.7.3
|
||||
type: npm
|
||||
summary: The semantic version parser used by npm.
|
||||
homepage:
|
||||
license: isc
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
notices: []
|
||||
39
.licenses/npm/source-map.dep.yml
Normal file
39
.licenses/npm/source-map.dep.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: source-map
|
||||
version: 0.6.1
|
||||
type: npm
|
||||
summary: Generates and consumes source maps
|
||||
homepage: https://github.com/mozilla/source-map
|
||||
license: other
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |2
|
||||
|
||||
Copyright (c) 2009-2011, Mozilla Foundation and contributors
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the names of the Mozilla Foundation nor the names of project
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
notices: []
|
||||
40
.licenses/npm/uglify-js.dep.yml
Normal file
40
.licenses/npm/uglify-js.dep.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: uglify-js
|
||||
version: 3.19.3
|
||||
type: npm
|
||||
summary: JavaScript parser, mangler/compressor and beautifier toolkit
|
||||
homepage:
|
||||
license: other
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
UglifyJS is released under the BSD license:
|
||||
|
||||
Copyright 2012-2024 (c) Mihai Bazon <mihai.bazon@gmail.com>
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
* Redistributions of source code must retain the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials
|
||||
provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER “AS IS” AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
|
||||
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
||||
THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
notices: []
|
||||
29
.licenses/npm/wordwrap.dep.yml
Normal file
29
.licenses/npm/wordwrap.dep.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: wordwrap
|
||||
version: 1.0.0
|
||||
type: npm
|
||||
summary: Wrap those words. Show them at what columns to start and stop.
|
||||
homepage:
|
||||
license: other
|
||||
licenses:
|
||||
- sources: LICENSE
|
||||
text: |
|
||||
This software is released under the 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: []
|
||||
30
AGENTS.md
30
AGENTS.md
@@ -20,7 +20,8 @@ up-to-date. Always run `npm run package` (or `npm run bundle`) after modifying
|
||||
`src/` files.
|
||||
|
||||
**License Compliance**: After modifying NPM dependencies, check license status
|
||||
and re-cache if needed via `npm run licensed:status` and `npm run licensed:cache`.
|
||||
and re-cache if needed via `npm run licensed:status` and
|
||||
`npm run licensed:cache`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -38,7 +39,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 jest tests/main.test.ts
|
||||
|
||||
# CI variants (suppress warnings)
|
||||
npm run ci-test # Run tests in CI mode
|
||||
@@ -66,6 +67,11 @@ npm run licensed:cache # Re-cache licenses if needed
|
||||
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/derive.ts](src/derive.ts)**: Semver parsing and tag derivation:
|
||||
- `parseSemver()`: Parses version strings into components (prefix, major,
|
||||
minor, patch, prerelease, build)
|
||||
- `renderTemplate()`: Renders Handlebars templates with semver context
|
||||
- `deriveTags()`: Derives tags from a version string using a template
|
||||
- **[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
|
||||
@@ -81,6 +87,20 @@ npm run licensed:cache # Re-cache licenses if needed
|
||||
Uses `csv-parse/sync` to handle both CSV and newline-delimited formats. Supports
|
||||
per-tag ref overrides: `v1:main` tags `v1` to `main` branch.
|
||||
|
||||
### Tag Derivation
|
||||
|
||||
The `derive_from` input allows automatic generation of tags from a semver
|
||||
version string. Uses Handlebars templates with these placeholders:
|
||||
|
||||
- `{{prefix}}`: "v" or "V" if input had prefix, empty otherwise
|
||||
- `{{major}}`, `{{minor}}`, `{{patch}}`: Version numbers
|
||||
- `{{prerelease}}`, `{{build}}`: Optional semver components
|
||||
- `{{version}}`: Full version without prefix
|
||||
|
||||
Default template `{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}` generates
|
||||
major and minor tags (e.g., `v1.2.3` → `v1`, `v1.2`). Supports Handlebars
|
||||
conditionals like `{{#if prerelease}}...{{/if}}`.
|
||||
|
||||
### Tag Update Logic
|
||||
|
||||
1. Parse and validate inputs ([inputs.ts](src/inputs.ts))
|
||||
@@ -196,11 +216,17 @@ chore(deps): bump @actions/core to v1.10.0
|
||||
**Inputs:**
|
||||
|
||||
- `tags`: CSV/newline list, supports `tag:ref` syntax
|
||||
- `derive_from`: Semver version string to derive tags from (e.g., "v1.2.3")
|
||||
- `derive_from_template`: Handlebars template for tag derivation (default:
|
||||
`{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}`)
|
||||
- `ref`: SHA/ref to tag (default: current commit)
|
||||
- `when_exists`: update|skip|fail (default: update)
|
||||
- `annotation`: Optional message for annotated tags (default: lightweight)
|
||||
- `dry_run`: Log planned operations without executing (default: false)
|
||||
- `github_token`: Auth token (default: github.token)
|
||||
|
||||
Either `tags` or `derive_from` (or both) must be provided.
|
||||
|
||||
**Outputs:**
|
||||
|
||||
- `tags`: All created/updated tags
|
||||
|
||||
112
README.md
112
README.md
@@ -40,6 +40,37 @@ to move its own major and minor tags.
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
### Deriving Tags from Version
|
||||
|
||||
Automatically derive major and minor tags from a semver version string:
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
derive_from: v1.2.3
|
||||
# Creates tags: v1, v1.2
|
||||
```
|
||||
|
||||
With a custom template (major tag only):
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
derive_from: v1.2.3
|
||||
derive_from_template: '{{prefix}}{{major}}'
|
||||
# Creates tag: v1
|
||||
```
|
||||
|
||||
Combine derived tags with explicit tags:
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
derive_from: v1.2.3
|
||||
tags: latest
|
||||
# Creates tags: latest, v1, v1.2
|
||||
```
|
||||
|
||||
### With Release Please
|
||||
|
||||
This example uses
|
||||
@@ -65,8 +96,7 @@ jobs:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
outputs:
|
||||
release_created: ${{ steps.release-please.outputs.release_created }}
|
||||
major: ${{ steps.release-please.outputs.major }}
|
||||
minor: ${{ steps.release-please.outputs.minor }}
|
||||
tag_name: ${{ steps.release-please.outputs.tag_name }}
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
@@ -93,9 +123,8 @@ jobs:
|
||||
steps:
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
tags: |
|
||||
v${{ needs.release-please.outputs.major }}
|
||||
v${{ needs.release-please.outputs.major }}.${{ needs.release-please.outputs.minor }}
|
||||
derive_from: ${{ needs.release-please.outputs.tag_name }}
|
||||
# Creates tags: v2, v2.2 (for tag_name v2.2.0)
|
||||
```
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
@@ -104,16 +133,73 @@ jobs:
|
||||
|
||||
## 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` | `""` |
|
||||
| `dry_run` | <p>If true, logs planned operations without executing them.</p> | `false` | `false` |
|
||||
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
|
||||
| name | description | required | default |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------------------------------------------- |
|
||||
| `tags` | <p>List/CSV of tags to create/update.</p> | `false` | `""` |
|
||||
| `derive_from` | <p>Semver version string to derive tags from (e.g., 'v1.2.3'). When provided, generates tags using <code>derive_from_template</code> input. Default template will produce major and minor tags. (e.g., 'v1', 'v1.2')</p> | `false` | `""` |
|
||||
| `derive_from_template` | <p>Handlebars template for deriving tags from the <code>derive_from</code> input. CSV/newline-delimited list with placeholders: {{prefix}}, {{major}}, {{minor}}, {{patch}}, {{prerelease}}, {{build}}, {{version}}.</p> | `false` | `{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}` |
|
||||
| `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` | `""` |
|
||||
| `dry_run` | <p>If true, logs planned operations without executing them.</p> | `false` | `false` |
|
||||
| `github_token` | <p>The GitHub token to use for authentication.</p> | `false` | `${{ github.token }}` |
|
||||
|
||||
<!-- action-docs-inputs source="action.yml" -->
|
||||
|
||||
### Derive Template Syntax
|
||||
|
||||
The `derive_from_template` input uses [Handlebars](https://handlebarsjs.com/)
|
||||
for template rendering. Splitting the template into separate tags by comma or
|
||||
newline is done after the template is rendered.
|
||||
|
||||
Available placeholders:
|
||||
|
||||
| Placeholder | Description |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| `{{prefix}}` | `v` or `V` if input had a prefix, empty otherwise |
|
||||
| `{{major}}` | Major version number |
|
||||
| `{{minor}}` | Minor version number |
|
||||
| `{{patch}}` | Patch version number |
|
||||
| `{{prerelease}}` | Prerelease identifier (e.g., `beta.1`), empty if none |
|
||||
| `{{build}}` | Build metadata (e.g., `build.123`), empty if none |
|
||||
| `{{version}}` | Full version string without prefix |
|
||||
|
||||
#### Conditional Sections
|
||||
|
||||
Use Handlebars `{{#if}}` blocks to include content only when a variable has a
|
||||
value. This is useful for optional components like prerelease or build metadata:
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
# Creates tag: v1-beta.1
|
||||
derive_from: v1.2.3-beta.1
|
||||
derive_from_template: |
|
||||
{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}
|
||||
```
|
||||
|
||||
For a stable release without prerelease:
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
# Creates tag: v1 (prerelease section omitted)
|
||||
derive_from: v1.2.3
|
||||
derive_from_template: |
|
||||
{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}
|
||||
```
|
||||
|
||||
You can also use `{{#unless}}` for inverse logic:
|
||||
|
||||
```yaml
|
||||
- uses: jimeh/update-tags-action@v2
|
||||
with:
|
||||
# Creates tag: v1-stable (only for non-prerelease versions)
|
||||
derive_from: v1.2.3
|
||||
derive_from_template: |
|
||||
{{prefix}}{{major}}{{#unless prerelease}}-stable{{/unless}}
|
||||
```
|
||||
|
||||
<!-- action-docs-outputs source="action.yml" -->
|
||||
|
||||
## Outputs
|
||||
|
||||
15
action.yml
15
action.yml
@@ -9,7 +9,20 @@ branding:
|
||||
inputs:
|
||||
tags:
|
||||
description: "List/CSV of tags to create/update."
|
||||
required: true
|
||||
required: false
|
||||
derive_from:
|
||||
description: >-
|
||||
Semver version string to derive tags from (e.g., 'v1.2.3'). When provided,
|
||||
generates tags using `derive_from_template` input. Default template will
|
||||
produce major and minor tags. (e.g., 'v1', 'v1.2')
|
||||
required: false
|
||||
derive_from_template:
|
||||
description: >-
|
||||
Handlebars template for deriving tags from the `derive_from` input.
|
||||
CSV/newline-delimited list with placeholders: {{prefix}}, {{major}},
|
||||
{{minor}}, {{patch}}, {{prerelease}}, {{build}}, {{version}}.
|
||||
required: false
|
||||
default: "{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}"
|
||||
ref:
|
||||
description: "The SHA or ref to tag. Defaults to SHA of current commit."
|
||||
required: false
|
||||
|
||||
11769
dist/index.js
generated
vendored
11769
dist/index.js
generated
vendored
File diff suppressed because one or more lines are too long
2
dist/index.js.map
generated
vendored
2
dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
19
package-lock.json
generated
19
package-lock.json
generated
@@ -10,7 +10,9 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^2.0.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"csv-parse": "^6.1.0"
|
||||
"csv-parse": "^6.1.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
@@ -20,6 +22,7 @@
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"action-docs": "^2.5.1",
|
||||
@@ -2333,6 +2336,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
@@ -5215,7 +5225,6 @@
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@@ -7093,7 +7102,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -7153,7 +7161,6 @@
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nice-try": {
|
||||
@@ -8896,7 +8903,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -9120,7 +9126,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10134,7 +10139,6 @@
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
@@ -10480,7 +10484,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^2.0.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"csv-parse": "^6.1.0"
|
||||
"csv-parse": "^6.1.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
@@ -53,6 +55,7 @@
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"action-docs": "^2.5.1",
|
||||
|
||||
105
src/derive.ts
Normal file
105
src/derive.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { parse } from 'csv-parse/sync'
|
||||
import Handlebars from 'handlebars'
|
||||
import * as semver from 'semver'
|
||||
|
||||
/**
|
||||
* Context containing parsed semver components for template rendering.
|
||||
*/
|
||||
export interface SemverContext {
|
||||
/** "v" or "V" if input had a prefix, empty string otherwise */
|
||||
prefix: string
|
||||
/** Major version number */
|
||||
major: number
|
||||
/** Minor version number */
|
||||
minor: number
|
||||
/** Patch version number */
|
||||
patch: number
|
||||
/** Prerelease identifier (e.g., "beta.1"), empty if none */
|
||||
prerelease: string
|
||||
/** Build metadata (e.g., "build.123"), empty if none */
|
||||
build: string
|
||||
/** Full version string without prefix */
|
||||
version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a version string into semver components.
|
||||
*
|
||||
* @param input - Version string (e.g., "v1.2.3", "1.2.3-beta.1+build.456")
|
||||
* @returns Parsed semver context
|
||||
* @throws Error if the version string is not valid semver
|
||||
*/
|
||||
export function parseSemver(input: string): SemverContext {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('Invalid semver: empty string')
|
||||
}
|
||||
|
||||
// Check for v/V prefix and preserve original case
|
||||
const firstChar = trimmed[0]
|
||||
const hasPrefix = firstChar === 'v' || firstChar === 'V'
|
||||
const prefix = hasPrefix ? firstChar : ''
|
||||
const versionStr = hasPrefix ? trimmed.slice(1) : trimmed
|
||||
|
||||
// Parse with semver library
|
||||
const parsed = semver.parse(versionStr)
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid semver: '${input}'`)
|
||||
}
|
||||
|
||||
return {
|
||||
prefix,
|
||||
major: parsed.major,
|
||||
minor: parsed.minor,
|
||||
patch: parsed.patch,
|
||||
prerelease: parsed.prerelease.join('.'),
|
||||
build: parsed.build.join('.'),
|
||||
version: parsed.version
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single template string with semver context using Handlebars.
|
||||
* Supports {{#if variable}}...{{/if}} conditionals for optional sections.
|
||||
*
|
||||
* @param template - Handlebars template string
|
||||
* @param ctx - Semver context for substitution
|
||||
* @returns Rendered string
|
||||
*/
|
||||
export function renderTemplate(template: string, ctx: SemverContext): string {
|
||||
const compiled = Handlebars.compile(template, { noEscape: true })
|
||||
// Convert numbers to strings so 0 is truthy in conditionals
|
||||
const stringCtx = {
|
||||
...ctx,
|
||||
major: String(ctx.major),
|
||||
minor: String(ctx.minor),
|
||||
patch: String(ctx.patch)
|
||||
}
|
||||
return compiled(stringCtx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive tags from a semver version string using a template.
|
||||
*
|
||||
* @param deriveFrom - Semver version string (e.g., "v1.2.3")
|
||||
* @param template - CSV/newline-delimited Handlebars template string
|
||||
* @returns Array of derived tag strings
|
||||
*/
|
||||
export function deriveTags(deriveFrom: string, template: string): string[] {
|
||||
const ctx = parseSemver(deriveFrom)
|
||||
|
||||
// Render template with Handlebars first, enabling conditional tag inclusion
|
||||
const rendered = renderTemplate(template, ctx)
|
||||
|
||||
// Parse rendered result as CSV/newline delimited
|
||||
const tags = (
|
||||
parse(rendered, {
|
||||
delimiter: ',',
|
||||
trim: true,
|
||||
relax_column_count: true
|
||||
}) as string[][]
|
||||
).flat()
|
||||
|
||||
// Exclude empty tags and tags that are just the prefix with no version data
|
||||
return tags.filter((tag) => tag.length > 0 && tag !== ctx.prefix)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as github from '@actions/github'
|
||||
import { parse } from 'csv-parse/sync'
|
||||
import { deriveTags } from './derive.js'
|
||||
|
||||
const WHEN_EXISTS_MODES = ['update', 'skip', 'fail'] as const
|
||||
export type WhenExistsMode = (typeof WHEN_EXISTS_MODES)[number]
|
||||
@@ -33,13 +34,19 @@ function validateWhenExists(input: string): WhenExistsMode {
|
||||
return input as WhenExistsMode
|
||||
}
|
||||
|
||||
const DEFAULT_DERIVE_FROM_TEMPLATE =
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
|
||||
|
||||
/**
|
||||
* Get and validate action inputs.
|
||||
*
|
||||
* @returns Parsed and validated inputs
|
||||
*/
|
||||
export function getInputs(): Inputs {
|
||||
const tagsInput: string = core.getInput('tags', { required: true })
|
||||
const tagsInput: string = core.getInput('tags')
|
||||
const deriveFrom: string = core.getInput('derive_from')
|
||||
const deriveFromTemplate: string =
|
||||
core.getInput('derive_from_template') || DEFAULT_DERIVE_FROM_TEMPLATE
|
||||
const defaultRef: string = core.getInput('ref')
|
||||
const whenExistsInput = core.getInput('when_exists') || 'update'
|
||||
const whenExists = validateWhenExists(whenExistsInput)
|
||||
@@ -51,14 +58,31 @@ export function getInputs(): Inputs {
|
||||
|
||||
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()
|
||||
// Parse explicit tags as CSV/newline delimited strings
|
||||
const explicitTags: string[] = tagsInput
|
||||
? (
|
||||
parse(tagsInput, {
|
||||
delimiter: ',',
|
||||
trim: true,
|
||||
relax_column_count: true
|
||||
}) as string[][]
|
||||
).flat()
|
||||
: []
|
||||
|
||||
// Derive tags from semver version string if provided
|
||||
const derivedTags: string[] = deriveFrom
|
||||
? deriveTags(deriveFrom, deriveFromTemplate)
|
||||
: []
|
||||
|
||||
// Combine explicit and derived tags
|
||||
const tags = [...explicitTags, ...derivedTags]
|
||||
|
||||
// Validate that at least one tag source is provided
|
||||
if (tags.length === 0) {
|
||||
throw new Error(
|
||||
"No tags specified. Provide 'tags' input, 'derive_from' input, or both."
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
tags,
|
||||
|
||||
420
tests/derive.test.ts
Normal file
420
tests/derive.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Unit tests for the derive module, src/derive.ts
|
||||
*/
|
||||
import { parseSemver, renderTemplate, deriveTags } from '../src/derive.js'
|
||||
|
||||
describe('parseSemver', () => {
|
||||
it('parses simple version without prefix', () => {
|
||||
const result = parseSemver('1.2.3')
|
||||
expect(result).toEqual({
|
||||
prefix: '',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: '',
|
||||
build: '',
|
||||
version: '1.2.3'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses version with v prefix', () => {
|
||||
const result = parseSemver('v1.2.3')
|
||||
expect(result).toEqual({
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: '',
|
||||
build: '',
|
||||
version: '1.2.3'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses version with uppercase V prefix', () => {
|
||||
const result = parseSemver('V1.2.3')
|
||||
expect(result).toEqual({
|
||||
prefix: 'V',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: '',
|
||||
build: '',
|
||||
version: '1.2.3'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses version with prerelease', () => {
|
||||
const result = parseSemver('v1.0.0-beta.1')
|
||||
expect(result).toEqual({
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: 'beta.1',
|
||||
build: '',
|
||||
version: '1.0.0-beta.1'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses version with build metadata', () => {
|
||||
const result = parseSemver('v1.0.0+build.456')
|
||||
expect(result).toEqual({
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: '',
|
||||
build: 'build.456',
|
||||
version: '1.0.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses version with both prerelease and build metadata', () => {
|
||||
const result = parseSemver('v1.2.3-alpha.1+build.789')
|
||||
expect(result).toEqual({
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: 'alpha.1',
|
||||
build: 'build.789',
|
||||
version: '1.2.3-alpha.1'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles whitespace around version string', () => {
|
||||
const result = parseSemver(' v1.2.3 ')
|
||||
expect(result).toEqual({
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: '',
|
||||
build: '',
|
||||
version: '1.2.3'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on empty string', () => {
|
||||
expect(() => parseSemver('')).toThrow('Invalid semver: empty string')
|
||||
})
|
||||
|
||||
it('throws on whitespace only', () => {
|
||||
expect(() => parseSemver(' ')).toThrow('Invalid semver: empty string')
|
||||
})
|
||||
|
||||
it('throws on invalid semver', () => {
|
||||
expect(() => parseSemver('not-a-version')).toThrow(
|
||||
"Invalid semver: 'not-a-version'"
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on incomplete version', () => {
|
||||
expect(() => parseSemver('v1.2')).toThrow("Invalid semver: 'v1.2'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
const ctx = {
|
||||
prefix: 'v',
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3,
|
||||
prerelease: 'beta.1',
|
||||
build: 'build.456',
|
||||
version: '1.2.3-beta.1'
|
||||
}
|
||||
|
||||
it('renders prefix placeholder', () => {
|
||||
expect(renderTemplate('{{prefix}}', ctx)).toBe('v')
|
||||
})
|
||||
|
||||
it('renders major placeholder', () => {
|
||||
expect(renderTemplate('{{major}}', ctx)).toBe('1')
|
||||
})
|
||||
|
||||
it('renders minor placeholder', () => {
|
||||
expect(renderTemplate('{{minor}}', ctx)).toBe('2')
|
||||
})
|
||||
|
||||
it('renders patch placeholder', () => {
|
||||
expect(renderTemplate('{{patch}}', ctx)).toBe('3')
|
||||
})
|
||||
|
||||
it('renders prerelease placeholder', () => {
|
||||
expect(renderTemplate('{{prerelease}}', ctx)).toBe('beta.1')
|
||||
})
|
||||
|
||||
it('renders build placeholder', () => {
|
||||
expect(renderTemplate('{{build}}', ctx)).toBe('build.456')
|
||||
})
|
||||
|
||||
it('renders version placeholder', () => {
|
||||
expect(renderTemplate('{{version}}', ctx)).toBe('1.2.3-beta.1')
|
||||
})
|
||||
|
||||
it('renders multiple placeholders', () => {
|
||||
expect(renderTemplate('{{prefix}}{{major}}.{{minor}}', ctx)).toBe('v1.2')
|
||||
})
|
||||
|
||||
it('renders same placeholder multiple times', () => {
|
||||
expect(renderTemplate('{{major}}-{{major}}', ctx)).toBe('1-1')
|
||||
})
|
||||
|
||||
it('preserves text without placeholders', () => {
|
||||
expect(renderTemplate('latest', ctx)).toBe('latest')
|
||||
})
|
||||
|
||||
it('handles empty prefix', () => {
|
||||
const noPrefix = { ...ctx, prefix: '' }
|
||||
expect(renderTemplate('{{prefix}}{{major}}', noPrefix)).toBe('1')
|
||||
})
|
||||
|
||||
it('handles empty prerelease', () => {
|
||||
const noPrerelease = { ...ctx, prerelease: '' }
|
||||
expect(renderTemplate('{{major}}-{{prerelease}}', noPrerelease)).toBe('1-')
|
||||
})
|
||||
|
||||
describe('Handlebars conditionals', () => {
|
||||
it('renders {{#if}} section when variable has value', () => {
|
||||
expect(
|
||||
renderTemplate('{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}', ctx)
|
||||
).toBe('1-beta.1')
|
||||
})
|
||||
|
||||
it('omits {{#if}} section when variable is empty', () => {
|
||||
const noPrerelease = { ...ctx, prerelease: '' }
|
||||
expect(
|
||||
renderTemplate(
|
||||
'{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}',
|
||||
noPrerelease
|
||||
)
|
||||
).toBe('1')
|
||||
})
|
||||
|
||||
it('handles {{#if}} with zero-valued numbers (converted to strings)', () => {
|
||||
const zeroMinor = { ...ctx, minor: 0 }
|
||||
expect(
|
||||
renderTemplate('{{major}}{{#if minor}}.{{minor}}{{/if}}', zeroMinor)
|
||||
).toBe('1.0')
|
||||
})
|
||||
|
||||
it('handles nested conditionals', () => {
|
||||
expect(
|
||||
renderTemplate(
|
||||
'{{major}}{{#if prerelease}}-{{prerelease}}{{#if build}}+{{build}}{{/if}}{{/if}}',
|
||||
ctx
|
||||
)
|
||||
).toBe('1-beta.1+build.456')
|
||||
})
|
||||
|
||||
it('handles nested conditionals with inner empty', () => {
|
||||
const noBuild = { ...ctx, build: '' }
|
||||
expect(
|
||||
renderTemplate(
|
||||
'{{major}}{{#if prerelease}}-{{prerelease}}{{#if build}}+{{build}}{{/if}}{{/if}}',
|
||||
noBuild
|
||||
)
|
||||
).toBe('1-beta.1')
|
||||
})
|
||||
|
||||
it('handles {{#unless}} for inverse logic', () => {
|
||||
const noPrerelease = { ...ctx, prerelease: '' }
|
||||
expect(
|
||||
renderTemplate('{{major}}{{#unless prerelease}}-stable{{/unless}}', ctx)
|
||||
).toBe('1')
|
||||
expect(
|
||||
renderTemplate(
|
||||
'{{major}}{{#unless prerelease}}-stable{{/unless}}',
|
||||
noPrerelease
|
||||
)
|
||||
).toBe('1-stable')
|
||||
})
|
||||
|
||||
it('handles multiple {{#if}} sections', () => {
|
||||
const noBuild = { ...ctx, build: '' }
|
||||
expect(
|
||||
renderTemplate(
|
||||
'{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}{{#if build}}+{{build}}{{/if}}',
|
||||
noBuild
|
||||
)
|
||||
).toBe('1-beta.1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTags', () => {
|
||||
it('generates tags with default-style template', () => {
|
||||
const result = deriveTags(
|
||||
'v1.2.3',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
|
||||
)
|
||||
expect(result).toEqual(['v1', 'v1.2'])
|
||||
})
|
||||
|
||||
it('generates single tag from simple template', () => {
|
||||
const result = deriveTags('v1.2.3', '{{prefix}}{{major}}')
|
||||
expect(result).toEqual(['v1'])
|
||||
})
|
||||
|
||||
it('handles version without prefix', () => {
|
||||
const result = deriveTags(
|
||||
'1.2.3',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}'
|
||||
)
|
||||
expect(result).toEqual(['1', '1.2'])
|
||||
})
|
||||
|
||||
it('handles prerelease in template', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0-rc.1',
|
||||
'{{prefix}}{{major}}-{{prerelease}}'
|
||||
)
|
||||
expect(result).toEqual(['v1-rc.1'])
|
||||
})
|
||||
|
||||
it('filters empty tags and prefix-only tags from result', () => {
|
||||
const result = deriveTags('v1.2.3', '{{prefix}}{{major}},,{{prefix}}')
|
||||
expect(result).toEqual(['v1'])
|
||||
})
|
||||
|
||||
it('trims whitespace around template parts', () => {
|
||||
const result = deriveTags('v1.2.3', ' {{prefix}}{{major}} , latest ')
|
||||
expect(result).toEqual(['v1', 'latest'])
|
||||
})
|
||||
|
||||
it('generates full version tag', () => {
|
||||
const result = deriveTags('v1.2.3', '{{prefix}}{{version}}')
|
||||
expect(result).toEqual(['v1.2.3'])
|
||||
})
|
||||
|
||||
it('throws on invalid semver', () => {
|
||||
expect(() => deriveTags('invalid', '{{major}}')).toThrow(
|
||||
"Invalid semver: 'invalid'"
|
||||
)
|
||||
})
|
||||
|
||||
it('handles newline-delimited template', () => {
|
||||
const result = deriveTags(
|
||||
'v1.2.3',
|
||||
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}'
|
||||
)
|
||||
expect(result).toEqual(['v1', 'v1.2'])
|
||||
})
|
||||
|
||||
it('handles mixed newlines and commas in template', () => {
|
||||
const result = deriveTags(
|
||||
'v1.2.3',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}\nlatest'
|
||||
)
|
||||
expect(result).toEqual(['v1', 'v1.2', 'latest'])
|
||||
})
|
||||
|
||||
describe('with Handlebars conditionals', () => {
|
||||
it('generates tag with optional prerelease when present', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0-beta.1',
|
||||
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}'
|
||||
)
|
||||
expect(result).toEqual(['v1-beta.1'])
|
||||
})
|
||||
|
||||
it('generates tag without prerelease section when absent', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0',
|
||||
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}'
|
||||
)
|
||||
expect(result).toEqual(['v1'])
|
||||
})
|
||||
|
||||
it('generates multiple tags with conditionals', () => {
|
||||
const result = deriveTags(
|
||||
'v1.2.3-rc.1',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#if prerelease}}-{{prerelease}}{{/if}}'
|
||||
)
|
||||
expect(result).toEqual(['v1', 'v1.2-rc.1'])
|
||||
})
|
||||
|
||||
it('handles build metadata conditional', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0+build.123',
|
||||
'{{prefix}}{{major}}{{#if build}}+{{build}}{{/if}}'
|
||||
)
|
||||
expect(result).toEqual(['v1+build.123'])
|
||||
})
|
||||
|
||||
it('handles both prerelease and build conditionals', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0-alpha.1+build.789',
|
||||
'{{prefix}}{{major}}{{#if prerelease}}-{{prerelease}}{{/if}}{{#if build}}+{{build}}{{/if}}'
|
||||
)
|
||||
expect(result).toEqual(['v1-alpha.1+build.789'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('conditional tag inclusion', () => {
|
||||
it('includes latest tag only for stable releases', () => {
|
||||
// Stable release gets latest tag
|
||||
const stable = deriveTags(
|
||||
'v1.2.3',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#unless prerelease}},latest{{/unless}}'
|
||||
)
|
||||
expect(stable).toEqual(['v1', 'v1.2', 'latest'])
|
||||
|
||||
// Prerelease does not get latest tag
|
||||
const prerelease = deriveTags(
|
||||
'v1.2.3-beta.1',
|
||||
'{{prefix}}{{major}},{{prefix}}{{major}}.{{minor}}{{#unless prerelease}},latest{{/unless}}'
|
||||
)
|
||||
expect(prerelease).toEqual(['v1', 'v1.2'])
|
||||
})
|
||||
|
||||
it('conditionally includes entire tag group with {{#if}}', () => {
|
||||
// With prerelease: include prerelease-specific tags
|
||||
const withPre = deriveTags(
|
||||
'v2.0.0-rc.1',
|
||||
'{{prefix}}{{major}}{{#if prerelease}},{{prefix}}{{major}}-{{prerelease}}{{/if}}'
|
||||
)
|
||||
expect(withPre).toEqual(['v2', 'v2-rc.1'])
|
||||
|
||||
// Without prerelease: only major tag
|
||||
const withoutPre = deriveTags(
|
||||
'v2.0.0',
|
||||
'{{prefix}}{{major}}{{#if prerelease}},{{prefix}}{{major}}-{{prerelease}}{{/if}}'
|
||||
)
|
||||
expect(withoutPre).toEqual(['v2'])
|
||||
})
|
||||
|
||||
it('conditionally includes newline-separated tags', () => {
|
||||
// Stable release includes all tags
|
||||
const stable = deriveTags(
|
||||
'v1.0.0',
|
||||
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}{{#unless prerelease}}\nstable{{/unless}}'
|
||||
)
|
||||
expect(stable).toEqual(['v1', 'v1.0', 'stable'])
|
||||
|
||||
// Prerelease excludes stable tag
|
||||
const prerelease = deriveTags(
|
||||
'v1.0.0-alpha.1',
|
||||
'{{prefix}}{{major}}\n{{prefix}}{{major}}.{{minor}}{{#unless prerelease}}\nstable{{/unless}}'
|
||||
)
|
||||
expect(prerelease).toEqual(['v1', 'v1.0'])
|
||||
})
|
||||
|
||||
it('excludes multiple tags conditionally', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0-beta.1',
|
||||
'{{prefix}}{{major}}{{#unless prerelease}},latest,stable,production{{/unless}}'
|
||||
)
|
||||
expect(result).toEqual(['v1'])
|
||||
})
|
||||
|
||||
it('includes multiple tags conditionally for stable', () => {
|
||||
const result = deriveTags(
|
||||
'v1.0.0',
|
||||
'{{prefix}}{{major}}{{#unless prerelease}},latest,stable,production{{/unless}}'
|
||||
)
|
||||
expect(result).toEqual(['v1', 'latest', 'stable', 'production'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1445,4 +1445,226 @@ describe('run', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('derive_from input', () => {
|
||||
it('derives tags from semver using default template', async () => {
|
||||
setupInputs({
|
||||
derive_from: 'v1.2.3',
|
||||
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.2',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
|
||||
expect(getOutputs()).toEqual({
|
||||
created: ['v1', 'v1.2'],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
tags: ['v1', 'v1.2']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives tags using custom template', async () => {
|
||||
setupInputs({
|
||||
derive_from: 'v2.5.0',
|
||||
derive_from_template: '{{prefix}}{{major}}',
|
||||
ref: 'abc123',
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
setupCommitResolver('sha-abc123')
|
||||
setupTagDoesNotExist()
|
||||
|
||||
await run()
|
||||
|
||||
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(1)
|
||||
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
ref: 'refs/tags/v2',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
|
||||
expect(getOutputs()).toEqual({
|
||||
created: ['v2'],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
tags: ['v2']
|
||||
})
|
||||
})
|
||||
|
||||
it('combines derive_from with explicit tags', async () => {
|
||||
setupInputs({
|
||||
tags: 'latest',
|
||||
derive_from: 'v1.0.0',
|
||||
ref: 'abc123',
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
setupCommitResolver('sha-abc123')
|
||||
setupTagDoesNotExist()
|
||||
|
||||
await run()
|
||||
|
||||
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledTimes(3)
|
||||
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
ref: 'refs/tags/latest',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
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(getOutputs()).toEqual({
|
||||
created: ['latest', 'v1', 'v1.0'],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
tags: ['latest', 'v1', 'v1.0']
|
||||
})
|
||||
})
|
||||
|
||||
it('handles version without v prefix', async () => {
|
||||
setupInputs({
|
||||
derive_from: '3.0.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/3',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
expect(github.mockOctokit.rest.git.createRef).toHaveBeenCalledWith({
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
ref: 'refs/tags/3.0',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
|
||||
expect(getOutputs()).toEqual({
|
||||
created: ['3', '3.0'],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
tags: ['3', '3.0']
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when neither tags nor derive_from is provided', async () => {
|
||||
setupInputs({
|
||||
ref: 'abc123',
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
"No tags specified. Provide 'tags' input, 'derive_from' input, or both."
|
||||
)
|
||||
})
|
||||
|
||||
it('fails on invalid semver in derive_from', async () => {
|
||||
setupInputs({
|
||||
derive_from: 'not-a-version',
|
||||
ref: 'abc123',
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
"Invalid semver: 'not-a-version'"
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates when tags and derive_from produce overlapping tags', async () => {
|
||||
setupInputs({
|
||||
tags: 'v1,v1.2',
|
||||
derive_from: 'v1.2.3', // Derives v1 and v1.2, overlapping with explicit
|
||||
ref: 'abc123',
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
setupCommitResolver('sha-abc123')
|
||||
setupTagDoesNotExist()
|
||||
|
||||
await run()
|
||||
|
||||
// Should only create 2 tags (deduplicated), not 4
|
||||
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.2',
|
||||
sha: 'sha-abc123'
|
||||
})
|
||||
|
||||
expect(getOutputs()).toEqual({
|
||||
created: ['v1', 'v1.2'],
|
||||
updated: [],
|
||||
skipped: [],
|
||||
tags: ['v1', 'v1.2']
|
||||
})
|
||||
})
|
||||
|
||||
it('fails when tags and derive_from produce same tag with different refs', async () => {
|
||||
setupInputs({
|
||||
tags: 'v1:main', // Explicit v1 pointing to main
|
||||
derive_from: 'v1.2.3', // Derives v1 (using default ref)
|
||||
ref: 'develop', // Different default ref
|
||||
github_token: 'test-token',
|
||||
when_exists: 'update'
|
||||
})
|
||||
|
||||
await run()
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(
|
||||
"Duplicate tag 'v1' with different refs: 'main' and 'develop'"
|
||||
)
|
||||
expect(github.mockOctokit.rest.git.createRef).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user