Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
05185b9
Add resolve-floating-tags and move-git-tags composite actions
d4mation Sep 30, 2026
4a8a676
Decide each floating tag on its own line, and infer prereleases
d4mation Sep 30, 2026
b5bd8a2
Cut evaluative filler from the tag action docs
d4mation Sep 30, 2026
79c5e3f
Read the version lines from tags, not the release list
d4mation Sep 30, 2026
49864cb
Move the tag logic into JavaScript run by github-script
d4mation Sep 30, 2026
233462f
Run the tests without a path argument
d4mation Sep 30, 2026
3aa4ccf
Document building between resolve and move
d4mation Sep 30, 2026
d7ac0ce
Say when a floating tag is repointed
d4mation Sep 30, 2026
8b52897
Say why the checkout takes no ref
d4mation Sep 30, 2026
2f5513f
Group the tests under describe blocks
d4mation Oct 1, 2026
ad1ee76
Say what the two test fakes stand in for
d4mation Oct 1, 2026
90abf8b
Test against a real Octokit instead of hand-rolled fakes
d4mation Oct 1, 2026
d0d484f
Capture the action log in tests instead of emitting it
d4mation Oct 1, 2026
9560e8b
Finish the JSDoc on every function
d4mation Oct 1, 2026
4a404d9
Separate the JSDoc tag groups, and put @throws before @returns
d4mation Oct 1, 2026
e3fb2e8
Group resolveFloatingTags's parameters by what they are
d4mation Oct 1, 2026
98eecac
Split resolveFloatingTags into one function per job
d4mation Oct 1, 2026
09b2a79
Test each function the split produced
d4mation Oct 1, 2026
e92960d
Nest the behaviour cases under resolveFloatingTags
d4mation Oct 1, 2026
7628ac2
Drop the resolveFloatingTags cases the function tests now cover
d4mation Oct 1, 2026
c42aa79
Call resolveFloatingTags directly in its tests
d4mation Oct 1, 2026
1ef9d27
Say what each form of a release tag is for
d4mation Oct 1, 2026
20a4ebc
Lead the release tag docs with the shape a tag usually takes
d4mation Oct 1, 2026
4a06b98
Say why compareVersions is not the semver package
d4mation Oct 1, 2026
117f072
Test readTagNames and readPrereleaseFlag directly
d4mation Oct 1, 2026
270993a
Fail the job when the repository's tags cannot be read
d4mation Oct 1, 2026
fcd3fa5
Fail the job when a release's prerelease flag cannot be read
d4mation Oct 1, 2026
1b53059
Rewrite the failed-read prose
d4mation Oct 1, 2026
edada65
Say what the default token is scoped to, and what it cannot trigger
d4mation Oct 1, 2026
664bf03
Say what a 0.x release resolves, and that v0 can break
d4mation Oct 1, 2026
2604f42
Cut the conversational tone from the 0.x section
d4mation Oct 1, 2026
6cecaa2
Say whose v0 crosses a minor, and put it after the levels note
d4mation Oct 1, 2026
6e290bb
Note the workflow_call wrapper under Phase 3
d4mation Oct 1, 2026
470dda0
Say that a 0.x release resolves its own minor tag
d4mation Oct 1, 2026
a6f0f2b
Stop attributing the 0.x rule to a semver requirement
d4mation Oct 1, 2026
147cde8
Serialise the tag-moving runs, and queue rather than drop them
d4mation Oct 6, 2026
b4c9727
Stop the checkout leaving credentials in the tree
d4mation Oct 6, 2026
110542f
Ask the API only once the answer still depends on it
d4mation Oct 6, 2026
c7638de
Install the test dependencies with npm, on one runtime
d4mation Oct 6, 2026
d7eba10
Stop the action reading its own floating tags as releases
d4mation Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/actions/move-git-tags/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# move-git-tags

Creates or moves the named git tags so that they point at a commit. A tag that does not exist is
created, one that does is repointed.

It knows nothing about versions. [resolve-floating-tags](../resolve-floating-tags/) decides which
floating version tags a release should own and this action writes them:

```yaml
- name: Resolve the floating tags for this release
id: floating
uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1
with:
tag: ${{ github.event.release.tag_name }}

- name: Move them onto the released commit
uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1
with:
tags: ${{ steps.floating.outputs.tags }}
```

Any tag name works, so it also moves a `stable` or `latest` pointer, or a release tag that has to be
repointed at a build commit. `sha` defaults to the commit the run is for, so most callers pass only
the names:

```yaml
- uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1
with:
tags: stable
```

## Inputs

| Input | Required | Default | What it does |
|---|---|---|---|
| `tags` | yes | | Tag names to create or move, separated by spaces or newlines, e.g. `v1 v1.2` |
| `sha` | no | the commit the run is for | The commit the tags should point at |
| `repository` | no | current repo | The `<owner>/<repo>` to write the tags in |
| `token` | no | `github.token` | Writes the tag refs. See [Permissions](#permissions) |

## Outputs

| Output | Example | What it is |
|---|---|---|
| `created` | `v1.2` | Tags that did not exist and were created |
| `moved` | `v1` | Tags that already existed and were repointed |

## An empty tag list is not an error

`tags: ''` moves nothing and succeeds, so a caller feeding it another step's output needs no `if:`
of its own. resolve-floating-tags returns an empty `tags` whenever the release should own no
floating tags, and the job stays green.

## Permissions

The job needs `contents: write`, which is not the default when a workflow sets
`permissions: contents: read` at the top:

```yaml
jobs:
move-floating-tags:
permissions:
contents: write
```

`github.token` is the token of the workflow that runs this action, scoped to that repository, so a
repo calling this action writes its own tags with its own token and needs no secret.

Pass a PAT or app token as `token` instead in either of two cases. One is a ruleset that protects the
tags in a way `github.token` cannot satisfy; a tag protection rule that blocks the default token makes
the API call fail rather than silently skip.

The other is a repo whose own workflows have to react to the tag. GitHub creates no workflow run for
an event made with `GITHUB_TOKEN`, apart from `workflow_dispatch` and `repository_dispatch`, so a
workflow listening on `push:` for `tags:` will not see a tag this action moved.

## Why the API and not git push

Writing the refs through the API rather than `git push --force` means the tag name never disappears
for a moment, which is what keeps a GitHub Release attached to it. It also means the job needs no
checkout of the repo to move a tag.

Each tag is read with `git.getRef` before being written, instead of trying `git.updateRef` and
creating the tag when that fails. An update also fails for a token without permission or a protected
tag, and a fallback would report those as a successful creation. Only a 404 is read as "does not
exist"; any other status fails the job.

`getRef` is the single-ref endpoint on purpose. `listMatchingRefs` matches by prefix, so it answers
for `tags/v1` whenever a `v1.0.0` tag exists even when there is no `v1` to update:

```console
$ gh api repos/stellarwp/changelogger/git/refs/tags/0.1 --jq 'map(.ref)'
["refs/tags/0.1.0","refs/tags/0.10.0"]

$ gh api repos/stellarwp/changelogger/git/ref/tags/0.1
gh: Not Found (HTTP 404)
```

The two calls also spell the ref differently: `getRef` and `updateRef` take `tags/v1`, while
`createRef` takes the full `refs/tags/v1`.

## Tag names

Pass tag names, not refs. `refs/tags/v1` is refused, because writing it would create a tag named
`refs/tags/v1`.

A name has to start with a letter or digit and hold only letters, digits, `.`, `_`, `-` and `/`.
`..` is refused as well. Each name goes into an API path, so anything else stops the job instead of
being sent.

## Lightweight tags only

The refs are written straight to a commit, so these are lightweight tags with no tagger, date or
message of their own. A floating tag is repointed every time a release claims it, so an annotated tag
object would be replaced each time.

A release tag that has to be annotated or signed is created by whoever cuts the release, not here.

## How it runs

The logic is in `move.js`, run by [actions/github-script](https://github.com/actions/github-script),
which supplies the Node runtime and an Octokit already authenticated with `token`. There is no
dependency to install and no bundle to build: the file in the repository is the file that runs, so a
change to it is reviewable as a diff.

The script is loaded by path rather than inlined in the manifest, because github-script's `require`
resolves against the workspace of the repository being built, not this action's directory. The path
comes from `${{ github.action_path }}`.

Inputs reach the script through `process.env`, never by being interpolated into it, so a tag name is
data and cannot become part of the program.

`tests/actions/move-git-tags/move.test.js` covers the rules above. It drives the
real Octokit with the network replaced, so the assertions are the requests this action would
send. Run `npm install` once, then `node --test`.
63 changes: 63 additions & 0 deletions .github/actions/move-git-tags/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Creates or moves git tags through the GitHub API. Knows nothing about versions: pair it with
# .github/actions/resolve-floating-tags to move floating version tags, or hand it any tag name.
#
# The work lives in move.js. actions/github-script runs it with a pre-authenticated Octokit, so
# there is no dependency to install and nothing to build: the file in the repository is the file
# that runs.
name: Move git tags
description: Creates or moves the named git tags so that they point at a commit

inputs:
tags:
description: >-
The tags to create or move, separated by spaces or newlines, e.g. `v1 v1.2`. An empty value
moves nothing and succeeds, so a caller passing another step's output needs no `if:` of its
own. Names are tag names, not refs: pass `v1`, not `refs/tags/v1`.
required: true
sha:
description: >-
The commit the tags should point at. Defaults to the commit the workflow is running for, which
on a `release` event is the commit the release tag names.
required: false
default: ''
repository:
description: 'The `<owner>/<repo>` to write the tags in. Defaults to the current repository.'
required: false
default: ''
token:
description: >-
Token used to write the tag refs. It needs `contents: write` on the repository, so the job
needs that permission too. Pass a PAT or app token instead when a ruleset protects the tags in
a way the default token cannot satisfy.
required: false
default: ${{ github.token }}

outputs:
created:
description: 'The tags that did not exist and were created, space separated.'
value: ${{ steps.move.outputs.created }}
moved:
description: 'The tags that already existed and were repointed, space separated.'
value: ${{ steps.move.outputs.moved }}

runs:
using: 'composite'
steps:
- name: Create or move the tags
id: move
uses: actions/github-script@v9
env:
# github-script's require() resolves against the workspace of the repository being built,
# not this action's directory, so the path has to be passed in. github.action_path is the
# documented expression for it.
ACTION_PATH: ${{ github.action_path }}
# Read from process.env rather than interpolated into the script, so a tag name is data and
# never part of the program.
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SHA: ${{ inputs.sha }}
INPUT_REPOSITORY: ${{ inputs.repository }}
with:
github-token: ${{ inputs.token }}
script: |
const { run } = require(`${process.env.ACTION_PATH}/move.js`)
await run({ github, core, env: process.env })
131 changes: 131 additions & 0 deletions .github/actions/move-git-tags/move.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use strict'

/**
* Creates or moves git tags so that they point at a commit.
*
* parseTagNames is pure and holds the validation. moveTags takes an Octokit and does the writing,
* so a test can pass a stand-in and assert the calls.
*/

/** A plain tag name: no spaces, no leading dash or dot, nothing that could reshape a request. */
const PLAIN_TAG_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/

/**
* Splits the `tags` input and checks every name.
*
* @param {string} tags Tag names separated by spaces or newlines.
*
* @throws {Error} When a name is not a plain tag name.
*
* @returns {string[]} The names, in the order given.
*/
function parseTagNames(tags) {
const names = String(tags ?? '')
.trim()
.split(/\s+/)
.filter(Boolean)

for (const name of names) {
// A full ref would otherwise be written as a tag literally named refs/tags/v1.
if (name.startsWith('refs/')) {
throw new Error(`Refusing to write '${name}': pass the tag name, e.g. '${name.split('/').pop()}'.`)
}

if (!PLAIN_TAG_NAME.test(name) || name.includes('..')) {
throw new Error(`Refusing to write '${name}': it is not a plain tag name.`)
}
}

return names
}

/**
* Points each tag at a commit, creating the ones that do not exist.
*
* The ref is read first rather than trying an update and creating on failure. An update also fails
* for a token without permission or a protected tag, and treating that as "does not exist yet"
* would report a refused write as a successful creation. getRef is the single-ref endpoint, which
* answers 404 for a missing ref; the matching-refs endpoint matches by prefix instead, and would
* answer for `tags/v1` whenever a v1.0.0 tag exists.
*
* The refs are written through the API rather than by pushing, so a tag name never disappears for a
* moment, which is what keeps a GitHub Release attached to it.
*
* @param {object} github An Octokit, as actions/github-script supplies it.
* @param {object} core The @actions/core toolkit, for the log.
* @param {string} owner The repository owner.
* @param {string} repo The repository name.
* @param {string[]} names The tag names to point at `sha`, already validated.
* @param {string} sha The commit the tags should point at.
*
* @throws {Error} When a write fails, or a read fails with anything but a 404.
*
* @returns {Promise<{created: string[], moved: string[]}>} Which names were created, and which
* already existed and were repointed.
*/
async function moveTags({ github, core, owner, repo, names, sha }) {
const created = []
const moved = []

for (const name of names) {
let exists = true

try {
// getRef takes the ref without its `refs/` prefix; createRef takes the full ref below.
await github.rest.git.getRef({ owner, repo, ref: `tags/${name}` })
} catch (error) {
if (error.status !== 404) {
throw error
}

exists = false
}

if (exists) {
await github.rest.git.updateRef({ owner, repo, ref: `tags/${name}`, sha, force: true })
core.info(`Moved ${name} to ${sha}.`)
moved.push(name)
} else {
await github.rest.git.createRef({ owner, repo, ref: `refs/tags/${name}`, sha })
core.info(`Created ${name} at ${sha}.`)
created.push(name)
}
}

return { created, moved }
}

/**
* Moves the tags named by the inputs and writes the action's outputs.
*
* @param {object} github An Octokit, as actions/github-script supplies it.
* @param {object} core The @actions/core toolkit, for the log and the outputs.
* @param {object} env The environment the action manifest put its inputs in.
*
* @throws {Error} When a tag name is not a plain tag name, or a write fails.
*
* @returns {Promise<void>} Resolves once every tag has been written and the outputs are set.
*/
async function run({ github, core, env }) {
const repository = env.INPUT_REPOSITORY || env.GITHUB_REPOSITORY
const [owner, repo] = repository.split('/')
const sha = env.INPUT_SHA || env.GITHUB_SHA
const names = parseTagNames(env.INPUT_TAGS)

// An empty list succeeds, so a caller feeding this another step's output needs no `if:` of
// its own.
if (names.length === 0) {
core.info('No tags to move.')
core.setOutput('created', '')
core.setOutput('moved', '')

return
}

const { created, moved } = await moveTags({ github, core, owner, repo, names, sha })

core.setOutput('created', created.join(' '))
core.setOutput('moved', moved.join(' '))
}

module.exports = { run, moveTags, parseTagNames, PLAIN_TAG_NAME }
Loading
Loading