From 05185b938ffe88862fd5fc22c5bee833ba0044cc Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 30 Sep 2026 15:38:27 -0400 Subject: [PATCH 01/40] Add resolve-floating-tags and move-git-tags composite actions House rule 2 has consumers pin `@v1`, so a published release has to move v and v. onto the released commit. Nothing did that yet. The flow is adapted from the tag half of stellarwp/changelogger's publish.yml. Its other half does not apply here: there is no build output to commit, so the release tag already names the commit consumers should get. Four things work differently from the version it was taken from. It writes the minor tag as well as the major, which is what lets a repo pin a line rather than only a major. A floating tag moves only when the release being published is the newest one that tag covers. Moving it unconditionally points v1 at older code as soon as a patch lands on an older line: publishing 1.2.4 after 1.3.0 is out. Each tag is compared only against releases in its own line, so a newer major does not hold a lower one back, and someone pinned to @v1 still receives a backported patch. Prereleases are skipped on two separate signals, because either can occur alone. A suffixed tag such as 1.3.0-rc.1 fails the X.Y.Z pattern on its own. A plain 1.3.0 marked as a prerelease on GitHub does not, so the release event's prerelease flag is passed in as an input. A tag ref's existence is checked before it is written, rather than trying a PATCH and falling back to a POST on failure. A PATCH also fails for a token without permission or for a protected tag, and the fallback would report those as a successful creation. The check uses git/ref (singular), which 404s on a missing ref. The plural git/refs/tags/v1 matches by prefix, so it answers 200 whenever a v1.0.0 tag exists even with no v1 to update. Split across two actions so either half can be used alone. resolve-floating-tags works out which tags a version should own and writes nothing; move-git-tags writes any tag names it is handed and knows nothing about versions, so it also moves a stable or latest pointer. Release tags are accepted as 1.2.3 or v1.2.3, since the brands being merged here do not agree on the prefix. The floating tags always carry the v, because that is what a `uses:` line resolves. --- .github/actions/move-git-tags/README.md | 106 ++++++++++ .github/actions/move-git-tags/action.yml | 115 +++++++++++ .../actions/resolve-floating-tags/README.md | 99 ++++++++++ .../actions/resolve-floating-tags/action.yml | 181 ++++++++++++++++++ .github/workflows/release-tags.yml | 45 +++++ 5 files changed, 546 insertions(+) create mode 100644 .github/actions/move-git-tags/README.md create mode 100644 .github/actions/move-git-tags/action.yml create mode 100644 .github/actions/resolve-floating-tags/README.md create mode 100644 .github/actions/resolve-floating-tags/action.yml create mode 100644 .github/workflows/release-tags.yml diff --git a/.github/actions/move-git-tags/README.md b/.github/actions/move-git-tags/README.md new file mode 100644 index 0000000..61e664d --- /dev/null +++ b/.github/actions/move-git-tags/README.md @@ -0,0 +1,106 @@ +# 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 }} + is-prerelease: ${{ github.event.release.prerelease }} + +- 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. `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 `/` 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 +``` + +Pass a PAT or app token as `token` instead when a ruleset 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. + +## 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 checked with `git/ref/tags/` before being written, instead of trying a PATCH and +falling back to a POST. A PATCH also fails for a token without permission or a protected tag, and a +fallback would report those as a successful creation. + +`git/ref` is singular on purpose. The plural `git/refs/tags/v1` matches by prefix, so it answers +200 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) +``` + +## 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. That is what a floating tag should be: it is a moving pointer, and an +annotated tag object would be rewritten on every release anyway. + +A release tag that has to be annotated or signed is created by whoever cuts the release, not here. diff --git a/.github/actions/move-git-tags/action.yml b/.github/actions/move-git-tags/action.yml new file mode 100644 index 0000000..a6780de --- /dev/null +++ b/.github/actions/move-git-tags/action.yml @@ -0,0 +1,115 @@ +# 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. +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 `/` 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 + shell: bash + env: + TAGS: ${{ inputs.tags }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_REPOSITORY: ${{ inputs.repository }} + GH_TOKEN: ${{ inputs.token }} + run: | + REPO="${INPUT_REPOSITORY:-$GITHUB_REPOSITORY}" + SHA="${INPUT_SHA:-$GITHUB_SHA}" + + # Word splitting is intended: TAGS is a list, separated by spaces or newlines. + # shellcheck disable=SC2086,SC2153 + set -- ${TAGS} + + if [ "$#" -eq 0 ]; then + echo "No tags to move." + { + echo "created=" + echo "moved=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + CREATED=() + MOVED=() + + for TAG in "$@"; do + # Each name goes into an API path, so reject anything that is not a plain tag name. git + # forbids `..` in a ref name anyway, and refusing it here keeps a name from walking up out + # of the tags namespace. + if [[ ! "${TAG}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || [[ "${TAG}" == *..* ]]; then + echo "::error::Refusing to write '${TAG}': it is not a plain tag name." + exit 1 + fi + + # A full ref would otherwise be written as a tag literally named refs/tags/v1. + if [[ "${TAG}" == refs/* ]]; then + echo "::error::Refusing to write '${TAG}': pass the tag name, e.g. '${TAG##*/}'." + exit 1 + fi + + # Ask whether the ref exists rather than letting a failed PATCH fall through to a POST. A + # PATCH also fails for a token without permission or a protected tag, and a fallback that + # swallowed the error would report those as success. + # + # git/ref (singular) is the endpoint that 404s on a missing ref. git/refs (plural) matches + # by prefix, so it answers 200 for `tags/v1` whenever 1.0.0 exists, with no v1 to update. + if gh api "repos/${REPO}/git/ref/tags/${TAG}" --silent 2>/dev/null; then + # The API is used rather than `git push --force` so that the tag name never disappears, + # which is what keeps a Release attached to it. + gh api "repos/${REPO}/git/refs/tags/${TAG}" \ + --method PATCH \ + --raw-field sha="${SHA}" \ + --field force=true \ + --silent + echo "Moved ${TAG} to ${SHA}." + MOVED+=( "${TAG}" ) + else + gh api "repos/${REPO}/git/refs" \ + --method POST \ + --raw-field ref="refs/tags/${TAG}" \ + --raw-field sha="${SHA}" \ + --silent + echo "Created ${TAG} at ${SHA}." + CREATED+=( "${TAG}" ) + fi + done + + { + echo "created=${CREATED[*]-}" + echo "moved=${MOVED[*]-}" + } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/resolve-floating-tags/README.md b/.github/actions/resolve-floating-tags/README.md new file mode 100644 index 0000000..6ae2e3e --- /dev/null +++ b/.github/actions/resolve-floating-tags/README.md @@ -0,0 +1,99 @@ +# resolve-floating-tags + +Works out which floating version tags a released version should own. `1.2.3` owns `v1` and `v1.2`. + +It writes nothing. Pair it with [move-git-tags](../move-git-tags/), which takes its `tags` output +as input: + +```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 }} + is-prerelease: ${{ github.event.release.prerelease }} + +- name: Move them onto the released commit + uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 + with: + tags: ${{ steps.floating.outputs.tags }} +``` + +The two are separate so that either half can be used alone: a repo with its own tag-writing step +can read `tags` from here, and a repo that wants to move a `stable` or `latest` tag can call +move-git-tags without this action. + +## Inputs + +| Input | Required | Default | What it does | +|---|---|---|---| +| `tag` | yes | | The release tag, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3` | +| `levels` | no | `'major minor'` | Which tags to resolve: `major` for `v1`, `minor` for `v1.2` | +| `is-prerelease` | no | `'false'` | Resolve nothing when `true`. See [Prereleases](#prereleases) | +| `require-newest` | no | `'true'` | Hold a tag back when a newer release owns it. See [Why a tag stays put](#why-a-tag-stays-put) | +| `repository` | no | current repo | The `/` whose releases to compare against | +| `token` | no | `github.token` | Reads the release list. Read access is enough, and it is unused when `require-newest` is off | + +## Outputs + +| Output | Example | What it is | +|---|---|---| +| `tags` | `v1 v1.2` | The floating tags this version should own. Empty when it should own none | +| `skipped` | `v1` | Tags a newer release already owns | +| `version` | `1.2.3` | The version without its leading `v`. Empty when the tag was rejected | +| `major` | `1` | The major number | +| `minor` | `2` | The minor number | + +## Why a tag stays put + +`v1` means "the newest 1.x", so it has to move when 1.3.0 ships and stay where it is when a patch +lands on an older line. With `require-newest` on, each tag moves only when the release being +published is the newest one that tag covers. + +Publishing 1.2.4 when 1.3.0 is already out resolves to `v1.2` alone. `v1` is reported in `skipped` +and keeps pointing at 1.3.0. + +Each tag is compared only against releases in its own line, so a newer major does not hold a lower +one back. With 2.1.0 released, publishing 1.2.4 as the newest 1.x still resolves both `v1` and +`v1.2`. That is deliberate: someone pinned to `@v1` asked for the 1.x line and should get a +backported patch on it. + +Turn `require-newest` off to move the tags on every release regardless. That is only correct on a +repo that never patches an older line. + +## Prereleases + +Two things have to be true for a prerelease to be skipped, because either one can happen alone. + +A tag with a suffix, `1.3.0-rc.1`, is not three dot-separated numbers, so the pattern rejects it on +its own. + +A tag that is a plain `1.3.0` but marked as a prerelease on GitHub looks like any other version, so +pass `is-prerelease: ${{ github.event.release.prerelease }}` and it resolves nothing. + +## Tag prefixes + +A release tag is accepted either way, `1.2.3` or `v1.2.3`, so a repo does not have to settle its +convention before this works. Both resolve to `v1` and `v1.2`: the floating tags always carry the +`v`, because that is what a `uses:` line resolves. + +Mixed conventions in one repo's history are also fine. The comparison runs on the version with the +`v` stripped, so `1.2.4` and `v1.2.10` sort against each other correctly. + +## Repos with no Releases + +`require-newest` reads GitHub Releases through `gh release list`, not git tags, because that is +where the prerelease and draft flags live. + +A repo that pushes tags without publishing Releases has nothing for the check to read. The release +being published is then absent from the list, and rather than resolving nothing, the action resolves +the tag anyway. Set `require-newest: 'false'` on such a repo to say so outright and skip the API +call. + +The read is capped at the 200 most recent releases. They come back newest first, so the newest +release of an older line is never the entry that falls off the end. + +## Version numbers with more than three parts + +Only `X.Y.Z` is accepted. A two-part `1.2` or a four-part `1.2.3.4` resolves nothing and says so +with a notice, rather than guessing which numbers are the major and the minor. diff --git a/.github/actions/resolve-floating-tags/action.yml b/.github/actions/resolve-floating-tags/action.yml new file mode 100644 index 0000000..0cf4ef2 --- /dev/null +++ b/.github/actions/resolve-floating-tags/action.yml @@ -0,0 +1,181 @@ +# Works out which floating tags a released version should own: v1 and v1.2 for 1.2.3. Writing them +# is a separate action: see .github/actions/move-git-tags. +name: Resolve floating version tags +description: Works out which v and v. tags a released version should own + +inputs: + tag: + description: >- + The release tag to resolve, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3`. A tag + that is not three dot-separated numbers resolves to no tags, which is what keeps a prerelease + such as `1.2.3-rc.1` from capturing a floating tag. + required: true + levels: + description: >- + Which floating tags to resolve, space separated: `major` for v1, `minor` for v1.2. Drop + `minor` on a repo whose consumers only ever pin a major. + required: false + default: 'major minor' + is-prerelease: + description: >- + Resolve no tags when `true`. Wire this to the release event's `prerelease` flag so that a + release marked as a prerelease on GitHub is skipped even when its tag is a plain `X.Y.Z`. The + tag pattern alone does not catch that case. + required: false + default: 'false' + require-newest: + description: >- + Only resolve a floating tag when this release is the newest one that tag covers, so that + publishing a patch on an older line does not drag the tag backwards. Reads the repository's + GitHub Releases; see the action's README. Set to `false` on a repo that publishes no Releases, + since the check has nothing to read there, and this action then calls no API at all. + required: false + default: 'true' + repository: + description: 'The `/` whose releases to compare against. Defaults to the current one.' + required: false + default: '' + token: + description: >- + Token used to read the release list when `require-newest` is on. It needs no more than read + access to the repository's contents. Unused when `require-newest` is `false`. + required: false + default: ${{ github.token }} + +outputs: + tags: + description: >- + The floating tags this version should own, space separated, e.g. `v1 v1.2`. Pass it straight + to move-git-tags. Empty when the version was rejected or every tag was held back. + value: ${{ steps.resolve.outputs.tags }} + skipped: + description: >- + The floating tags a newer release already owns, space separated. Empty when the version itself + was rejected; read `version` to tell the two apart. + value: ${{ steps.resolve.outputs.skipped }} + version: + description: >- + The released version without its leading `v`, e.g. `1.2.3`. Empty when the tag was not an + X.Y.Z version or the release was a prerelease. + value: ${{ steps.version.outputs.version }} + major: + description: 'The major number, e.g. `1`. Empty when the version was rejected.' + value: ${{ steps.version.outputs.major }} + minor: + description: 'The minor number, e.g. `2`. Empty when the version was rejected.' + value: ${{ steps.version.outputs.minor }} + +runs: + using: 'composite' + steps: + - name: Read the version out of the release tag + id: version + shell: bash + env: + TAG: ${{ inputs.tag }} + IS_PRERELEASE: ${{ inputs.is-prerelease }} + run: | + if [ "${IS_PRERELEASE}" == "true" ]; then + echo "::notice::Resolved no floating tags: ${TAG} is marked as a prerelease." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # A release tag may be written either way, 1.2.3 or v1.2.3, so the comparisons below all run + # on the version without the v. The floating tags always carry the v, because that is what a + # `uses:` line resolves. + VERSION="${TAG#v}" + + if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then + echo "::notice::Resolved no floating tags: ${TAG} is not an X.Y.Z version." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "proceed=true" + echo "version=${VERSION}" + echo "major=${BASH_REMATCH[1]}" + echo "minor=${BASH_REMATCH[2]}" + } >> "$GITHUB_OUTPUT" + + - name: Resolve the floating tags for this version + id: resolve + if: steps.version.outputs.proceed == 'true' + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + MAJOR: ${{ steps.version.outputs.major }} + MINOR: ${{ steps.version.outputs.minor }} + LEVELS: ${{ inputs.levels }} + REQUIRE_NEWEST: ${{ inputs.require-newest }} + INPUT_REPOSITORY: ${{ inputs.repository }} + GH_TOKEN: ${{ inputs.token }} + run: | + REPO="${INPUT_REPOSITORY:-$GITHUB_REPOSITORY}" + + # Each entry pairs a floating tag with the pattern for the releases it covers. The patterns + # anchor at both ends so a longer number cannot match a shorter one: 122.3.4 is not a 1.2 + # release. + ENTRIES=() + # Word splitting is intended: LEVELS is a list. + # shellcheck disable=SC2086,SC2153 + set -- ${LEVELS} + for LEVEL in "$@"; do + case "${LEVEL}" in + major) ENTRIES+=( "v${MAJOR}|^${MAJOR}\.[0-9]+\.[0-9]+$" ) ;; + minor) ENTRIES+=( "v${MAJOR}.${MINOR}|^${MAJOR}\.${MINOR}\.[0-9]+$" ) ;; + *) + echo "::error::Unknown level '${LEVEL}' in levels. Use 'major', 'minor' or both." + exit 1 + ;; + esac + done + + if [ "${#ENTRIES[@]}" -eq 0 ]; then + echo "::error::levels named no floating tags to resolve." + exit 1 + fi + + # The highest version matching $1 among published, non-prerelease releases. sort -V orders + # 1.10.0 above 1.9.0, which sort alone does not. The limit is well past the number of + # releases in one version line; releases come back newest first, so an older line's newest + # release is never the entry that falls off the end. + newest_version_matching() { + gh release list --repo "${REPO}" --limit 200 \ + --json tagName,isPrerelease,isDraft \ + --jq '.[] | select(.isPrerelease == false and .isDraft == false) | .tagName' \ + | sed 's/^v//' \ + | grep -E "$1" \ + | sort -V \ + | tail -n 1 + } + + TAGS=() + SKIPPED=() + + for ENTRY in "${ENTRIES[@]}"; do + FLOATING="${ENTRY%%|*}" + PATTERN="${ENTRY#*|}" + + if [ "${REQUIRE_NEWEST}" == "true" ]; then + NEWEST="$(newest_version_matching "${PATTERN}")" + # An empty result means this release is not listed yet, which happens when a repo tags + # without publishing Releases. Falling back to resolving the tag keeps that case working + # rather than silently moving nothing. + if [ -n "${NEWEST}" ] && [ "${NEWEST}" != "${VERSION}" ]; then + echo "::notice::${FLOATING} stays where it is: ${NEWEST} is newer than ${VERSION}." + SKIPPED+=( "${FLOATING}" ) + continue + fi + fi + + TAGS+=( "${FLOATING}" ) + done + + echo "Resolved for ${VERSION}: ${TAGS[*]-none}" + + { + echo "tags=${TAGS[*]-}" + echo "skipped=${SKIPPED[*]-}" + } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml new file mode 100644 index 0000000..cf62947 --- /dev/null +++ b/.github/workflows/release-tags.yml @@ -0,0 +1,45 @@ +name: Move floating version tags + +# This repo's own release automation, and the example other repos copy. +# +# Consuming repos pin a floating tag ("uses: stellarwp/plugin-toolbox/.github/actions/setup-slic@v1"), +# so every published release has to move v and v. onto the released commit. +# +# The work is split into two actions so that either half can be used on its own: +# resolve-floating-tags works out which tags the version should own, move-git-tags writes them. To +# run this in another repo, copy this file and replace the local `uses:` paths with +# `stellarwp/plugin-toolbox/.github/actions/@v1`. +# +# Run it locally: you cannot, it only makes sense against real refs. Check a release afterwards +# with `gh api repos/stellarwp/plugin-toolbox/git/ref/tags/v1 --jq .object.sha`. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + move-floating-tags: + runs-on: ubuntu-latest + permissions: + contents: write # move-git-tags writes the tag refs + steps: + # Only so that the two `uses: ./` paths below resolve. Neither action reads the working tree, + # so a repo calling them by their `@v1` path needs no checkout at all. + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Resolve the floating tags for this release + id: floating + uses: ./.github/actions/resolve-floating-tags + with: + tag: ${{ github.event.release.tag_name }} + is-prerelease: ${{ github.event.release.prerelease }} + + - name: Move them onto the released commit + uses: ./.github/actions/move-git-tags + with: + tags: ${{ steps.floating.outputs.tags }} From 4a8a676463de9d494555e06d461c9864c43664ec Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 30 Sep 2026 16:09:10 -0400 Subject: [PATCH 02/40] Decide each floating tag on its own line, and infer prereleases Four changes to how a release resolves its floating tags. The require-newest input is gone and the check it governed always runs. It was never optional in practice: v1 means the newest 1.x, so pointing it at an older release is wrong whether or not a caller asked for the check. Publishing 1.1.1 while v1 points at 1.2.0 now moves v1.1 and leaves v1 alone. Dropping the flag also drops the caveat about repos that publish no Releases, which the existing fallback already covers. The is-prerelease input is gone too. The release list this action already reads carries isPrerelease, so the flag comes from there rather than from the caller, and it no longer depends on what triggered the workflow. allow-prereleases replaces it, off by default. It lets a prerelease own a floating tag whose line has had no stable release yet, and nothing more. Publishing 1.4.0-rc.1 while 1.3.0 is the newest stable release resolves v1.4 and leaves v1 on 1.3.0, so @v1.4 is how someone opts into testing the line before it ships while @v1 keeps handing everyone else a released version. Each rc replaces the one before it on v1.4, and 1.4.0 then takes the tag off the last rc. Once 1.4.0 has shipped, no later 1.4 prerelease can take either tag. Only stable releases are counted when finding the newest release of a line. That is what lets one rc replace another, and it keeps an outstanding rc from holding a stable release back. Prereleases of one version are not ordered against each other, so republishing an earlier rc moves the minor tag back to it; ordering them would mean comparing prerelease identifiers by semver precedence, which sort -V does not do. Build metadata is no longer read as a prerelease. Only a `-` suffix is one, so 1.4.0+build.7 is a stable 1.4.0 and owns what a stable 1.4.0 owns. The release list is normalised the same way the released tag is, so a release carrying metadata or a suffix compares as its own version instead of being invisible to the comparison and losing to an older release. A fourth version part is read as another release of the same minor line rather than a line of its own, which is what a hotfix is. Publishing 1.2.3.1 moves v1.2 because it is the newest release of the 1.2 line, and re-publishing 1.2.3 afterwards moves nothing. A repo that adds a fourth part only when a hotfix exists needs no configuration for it, and there is no third floating tag: a v1.2.3 tag would collide with the release tag of the same name on a v-prefixed repo. --- .github/actions/move-git-tags/README.md | 1 - .../actions/resolve-floating-tags/README.md | 149 ++++++++--- .../actions/resolve-floating-tags/action.yml | 250 +++++++++++------- .github/workflows/release-tags.yml | 1 - 4 files changed, 259 insertions(+), 142 deletions(-) diff --git a/.github/actions/move-git-tags/README.md b/.github/actions/move-git-tags/README.md index 61e664d..a5ca72a 100644 --- a/.github/actions/move-git-tags/README.md +++ b/.github/actions/move-git-tags/README.md @@ -12,7 +12,6 @@ floating version tags a release should own and this action writes them: uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1 with: tag: ${{ github.event.release.tag_name }} - is-prerelease: ${{ github.event.release.prerelease }} - name: Move them onto the released commit uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 diff --git a/.github/actions/resolve-floating-tags/README.md b/.github/actions/resolve-floating-tags/README.md index 6ae2e3e..e6f446d 100644 --- a/.github/actions/resolve-floating-tags/README.md +++ b/.github/actions/resolve-floating-tags/README.md @@ -11,7 +11,6 @@ as input: uses: stellarwp/plugin-toolbox/.github/actions/resolve-floating-tags@v1 with: tag: ${{ github.event.release.tag_name }} - is-prerelease: ${{ github.event.release.prerelease }} - name: Move them onto the released commit uses: stellarwp/plugin-toolbox/.github/actions/move-git-tags@v1 @@ -29,71 +28,137 @@ move-git-tags without this action. |---|---|---|---| | `tag` | yes | | The release tag, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3` | | `levels` | no | `'major minor'` | Which tags to resolve: `major` for `v1`, `minor` for `v1.2` | -| `is-prerelease` | no | `'false'` | Resolve nothing when `true`. See [Prereleases](#prereleases) | -| `require-newest` | no | `'true'` | Hold a tag back when a newer release owns it. See [Why a tag stays put](#why-a-tag-stays-put) | -| `repository` | no | current repo | The `/` whose releases to compare against | -| `token` | no | `github.token` | Reads the release list. Read access is enough, and it is unused when `require-newest` is off | +| `allow-prereleases` | no | `'false'` | Let a prerelease own a tag whose line has had no stable release. See [Prereleases](#prereleases) | +| `repository` | no | current repo | The `/` whose releases to read | +| `token` | no | `github.token` | Reads the release list. Read access is enough | ## Outputs | Output | Example | What it is | |---|---|---| -| `tags` | `v1 v1.2` | The floating tags this version should own. Empty when it should own none | -| `skipped` | `v1` | Tags a newer release already owns | +| `tags` | `v1 v1.2` | The floating tags this version should own, shortest first. Empty when it should own none | +| `skipped` | `v1` | Tags a newer release of the same line already owns | | `version` | `1.2.3` | The version without its leading `v`. Empty when the tag was rejected | -| `major` | `1` | The major number | -| `minor` | `2` | The minor number | -## Why a tag stays put +## Each tag is resolved on its own -`v1` means "the newest 1.x", so it has to move when 1.3.0 ships and stay where it is when a patch -lands on an older line. With `require-newest` on, each tag moves only when the release being -published is the newest one that tag covers. +A tag is resolved only when no stable release of **its own line** is newer than the version being +released. The two tags are decided separately, so a release often takes one and not the other. -Publishing 1.2.4 when 1.3.0 is already out resolves to `v1.2` alone. `v1` is reported in `skipped` -and keeps pointing at 1.3.0. +Releases 1.2.4 and 1.3.0 exist, so `v1` points at 1.3.0. Publishing 1.2.5, a patch on the older +line: -Each tag is compared only against releases in its own line, so a newer major does not hold a lower -one back. With 2.1.0 released, publishing 1.2.4 as the newest 1.x still resolves both `v1` and -`v1.2`. That is deliberate: someone pinned to `@v1` asked for the 1.x line and should get a -backported patch on it. +| Tag | Line it covers | Newest in that line | Result | +|---|---|---|---| +| `v1` | every 1.x | 1.3.0 | stays at 1.3.0 | +| `v1.2` | every 1.2.x | 1.2.5, the release being published | moves to 1.2.5 | -Turn `require-newest` off to move the tags on every release regardless. That is only correct on a -repo that never patches an older line. +So consumers pinned to `@v1.2` get the patch, and consumers pinned to `@v1` keep 1.3.0 rather than +being moved back to older code. `v1` appears in `skipped` and `tags` holds `v1.2` alone. -## Prereleases +This is also what makes publishing out of order safe. Cutting 1.1.1 while `v1` points at 1.2.0 moves +`v1.1` and leaves `v1` alone. + +A newer **major** never holds a lower one back, because it is not in the same line. With 2.1.0 +released, publishing 1.2.4 as the newest 1.x still resolves both `v1` and `v1.2`. Someone pinned to +`@v1` asked for the 1.x line, so a backport on it is theirs. + +## Hotfix versions with a fourth part + +A fourth part is read as another release of the same minor line, not a line of its own. A repo that +adds one only when a hotfix exists needs no configuration for it: + +| Releases | `v1.2` points at | `v1` points at | +|---|---|---| +| 1.2.3 | 1.2.3 | 1.2.3 | +| 1.2.3, 1.2.3.1 | 1.2.3.1 | 1.2.3.1 | +| 1.2.3, 1.2.3.1, 1.2.4 | 1.2.4 | 1.2.4 | -Two things have to be true for a prerelease to be skipped, because either one can happen alone. +Publishing the hotfix 1.2.3.1 moves `v1.2` to it, because it is the newest release of the 1.2 line. +Re-publishing 1.2.3 afterwards moves nothing, because the hotfix is newer. -A tag with a suffix, `1.3.0-rc.1`, is not three dot-separated numbers, so the pattern rejects it on -its own. +There is no `patch` level and no `v1.2.3` tag. A hotfix is the newest patch of its minor, which the +line comparison already handles. -A tag that is a plain `1.3.0` but marked as a prerelease on GitHub looks like any other version, so -pass `is-prerelease: ${{ github.event.release.prerelease }}` and it resolves nothing. +## Version numbers this accepts -## Tag prefixes +At least three dot-separated numbers, with or without a leading `v`. Three or four parts are both +ordinary; `1.2.10.0` sorts above `1.2.9.9` as it should. + +Three is the floor so that the deepest floating tag is always a shorter name than the release tag +itself. A release tagged `1.2` would want a `v1.2` floating tag, which on a `v`-prefixed repo is the +release tag. A two-part tag resolves nothing and says so with a notice. A release tag is accepted either way, `1.2.3` or `v1.2.3`, so a repo does not have to settle its convention before this works. Both resolve to `v1` and `v1.2`: the floating tags always carry the -`v`, because that is what a `uses:` line resolves. +`v`, because that is what a `uses:` line resolves. Mixed conventions across a repo's history are +fine, since every version is compared with the `v` stripped. + +## Prereleases + +A prerelease is skipped by default, and it is detected without the caller passing anything. + +Two signals are read, because either can occur alone. A tag with a suffix, `1.3.0-rc.1`, is a +prerelease on its own. A tag that is a plain `1.3.0` but marked as a prerelease on GitHub is not +visibly different, so the flag is read from the release list, which this action fetches anyway. + +Build metadata is not a prerelease. `1.4.0+build.7` is a stable 1.4.0 and owns the tags a stable +1.4.0 would. The metadata is not part of a tag name, so it resolves `v1` and `v1.4`. + +`allow-prereleases: 'true'` lets a prerelease own a floating tag, but only one whose line has had no +stable release yet. A prerelease never takes a tag away from a stable release. -Mixed conventions in one repo's history are also fine. The comparison runs on the version with the -`v` stripped, so `1.2.4` and `v1.2.10` sort against each other correctly. +That is what makes the flag useful rather than dangerous. Publishing 1.4.0-rc.1 while the newest +stable release is 1.3.0: + +| Tag | Line it covers | Stable release in that line | Result | +|---|---|---|---| +| `v1` | every 1.x | 1.3.0 | stays at 1.3.0 | +| `v1.4` | every 1.4.x | none yet | moves to 1.4.0-rc.1 | + +So `@v1.4` is how someone opts into testing the 1.4 line before it ships, and `@v1` keeps handing +everyone else the newest stable release. + +Each rc replaces the one before it on `v1.4`, and the stable release then takes the tag off the last +rc. Only stable releases are counted when finding the newest in a line, so rc.1 never stands in +rc.2's way, and neither of them holds 1.4.0 back: + +| Publishing | Stable releases so far | `v1` | `v1.4` | +|---|---|---|---| +| 1.4.0-rc.1 | 1.3.0 | stays at 1.3.0 | created, at rc.1 | +| 1.4.0-rc.2 | 1.3.0 | stays at 1.3.0 | moves to rc.2, replacing rc.1 | +| 1.4.0 | 1.3.0, 1.4.0 | moves to 1.4.0 | moves to 1.4.0, replacing rc.2 | +| 1.4.1-rc.1 | 1.3.0, 1.4.0 | stays at 1.4.0 | stays at 1.4.0 | + +The last row is the rule at work in the other direction. A line that has never had a stable release +is the whole test, not which version is higher, so once 1.4.0 has shipped the 1.4 line has a stable +release in it and no later 1.4 prerelease can take either tag. Publishing an rc out of order behaves +the same way: + +| Publishing | Stable releases | `v1` | `v1.4` | +|---|---|---|---| +| 1.4.0-rc.1 | 1.5.0 | stays at 1.5.0 | created, at rc.1 | +| 1.4.0-rc.1 | 1.4.0, already shipped | stays at 1.4.0 | stays at 1.4.0 | + +Prereleases of one version are not ordered against each other, so republishing 1.4.0-rc.1 after +rc.2 moves `v1.4` back to rc.1. Ordering them would mean comparing prerelease identifiers by semver +precedence, which `sort -V` does not do. Stable releases are ordered, and are never moved backwards. + +The same rule gives a brand new major line to its own prerelease. Publishing 2.0.0-rc.1 with no +stable 2.x released resolves `v2` and `v2.0`, since nothing stable is behind either tag. + +A prerelease never blocks a stable release either. Only stable releases are considered when working +out the newest release of a line, so an outstanding 1.9.0-rc.1 does not hold `v1` back from a stable +1.3.1. ## Repos with no Releases -`require-newest` reads GitHub Releases through `gh release list`, not git tags, because that is -where the prerelease and draft flags live. +The comparison reads GitHub Releases through `gh release list`, not git tags, because that is where +the prerelease and draft flags live. Drafts are left out, since their tag does not exist yet. -A repo that pushes tags without publishing Releases has nothing for the check to read. The release -being published is then absent from the list, and rather than resolving nothing, the action resolves -the tag anyway. Set `require-newest: 'false'` on such a repo to say so outright and skip the API -call. +A repo that pushes tags without publishing Releases has nothing for the comparison to read. The +release being published is then absent from the list, so nothing can be newer than it and the tags +resolve on the tag name alone. The same path runs when the read fails outright, with a warning. The read is capped at the 200 most recent releases. They come back newest first, so the newest release of an older line is never the entry that falls off the end. - -## Version numbers with more than three parts - -Only `X.Y.Z` is accepted. A two-part `1.2` or a four-part `1.2.3.4` resolves nothing and says so -with a notice, rather than guessing which numbers are the major and the minor. diff --git a/.github/actions/resolve-floating-tags/action.yml b/.github/actions/resolve-floating-tags/action.yml index 0cf4ef2..4b39e07 100644 --- a/.github/actions/resolve-floating-tags/action.yml +++ b/.github/actions/resolve-floating-tags/action.yml @@ -6,125 +6,117 @@ description: Works out which v and v. tags a released versi inputs: tag: description: >- - The release tag to resolve, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3`. A tag - that is not three dot-separated numbers resolves to no tags, which is what keeps a prerelease - such as `1.2.3-rc.1` from capturing a floating tag. + The release tag to resolve, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3`. It needs + at least three dot-separated numbers. A fourth is read as part of the same line, so a hotfix + 1.2.3.1 counts as the newest release of 1.2; see the action's README. A prerelease suffix and + build metadata are read but are not part of a floating tag name. required: true levels: description: >- - Which floating tags to resolve, space separated: `major` for v1, `minor` for v1.2. Drop - `minor` on a repo whose consumers only ever pin a major. + Which floating tags to resolve, space separated. `major` resolves v1, `minor` resolves v1.2. + Order does not matter, and naming one twice resolves one tag. Drop `minor` on a repo whose + consumers only ever pin a major. required: false default: 'major minor' - is-prerelease: + allow-prereleases: description: >- - Resolve no tags when `true`. Wire this to the release event's `prerelease` flag so that a - release marked as a prerelease on GitHub is skipped even when its tag is a plain `X.Y.Z`. The - tag pattern alone does not catch that case. + Let a prerelease own a floating tag whose line has had no stable release yet. Off by default, + so a release marked as a prerelease on GitHub, or tagged with a suffix such as `1.3.0-rc.1`, + resolves no tags at all. Turning it on never takes a tag from a stable release: a 1.4.0-rc.1 + resolves v1.4 and leaves v1 on the newest stable 1.x. See the action's README. required: false default: 'false' - require-newest: - description: >- - Only resolve a floating tag when this release is the newest one that tag covers, so that - publishing a patch on an older line does not drag the tag backwards. Reads the repository's - GitHub Releases; see the action's README. Set to `false` on a repo that publishes no Releases, - since the check has nothing to read there, and this action then calls no API at all. - required: false - default: 'true' repository: - description: 'The `/` whose releases to compare against. Defaults to the current one.' + description: 'The `/` whose releases to read. Defaults to the current one.' required: false default: '' token: description: >- - Token used to read the release list when `require-newest` is on. It needs no more than read - access to the repository's contents. Unused when `require-newest` is `false`. + Token used to read the release list, which is where the prerelease flag and the other releases + of this version line are read from. Read access to the repository's contents is enough. required: false default: ${{ github.token }} outputs: tags: description: >- - The floating tags this version should own, space separated, e.g. `v1 v1.2`. Pass it straight - to move-git-tags. Empty when the version was rejected or every tag was held back. + The floating tags this version should own, space separated, e.g. `v1 v1.2`, ordered from + shortest to longest. Pass it straight to move-git-tags. Empty when the version should own + none. value: ${{ steps.resolve.outputs.tags }} skipped: description: >- - The floating tags a newer release already owns, space separated. Empty when the version itself - was rejected; read `version` to tell the two apart. + The floating tags a newer release of the same line already owns, space separated. Empty when + the version itself was rejected; read `version` to tell the two apart. value: ${{ steps.resolve.outputs.skipped }} version: description: >- - The released version without its leading `v`, e.g. `1.2.3`. Empty when the tag was not an - X.Y.Z version or the release was a prerelease. - value: ${{ steps.version.outputs.version }} - major: - description: 'The major number, e.g. `1`. Empty when the version was rejected.' - value: ${{ steps.version.outputs.major }} - minor: - description: 'The minor number, e.g. `2`. Empty when the version was rejected.' - value: ${{ steps.version.outputs.minor }} + The released version without its leading `v`, e.g. `1.2.3`. Empty when the tag was not a + numeric version of at least three parts. + value: ${{ steps.resolve.outputs.version }} runs: using: 'composite' steps: - - name: Read the version out of the release tag - id: version + - name: Resolve the floating tags for this release + id: resolve shell: bash env: TAG: ${{ inputs.tag }} - IS_PRERELEASE: ${{ inputs.is-prerelease }} + LEVELS: ${{ inputs.levels }} + ALLOW_PRERELEASES: ${{ inputs.allow-prereleases }} + INPUT_REPOSITORY: ${{ inputs.repository }} + GH_TOKEN: ${{ inputs.token }} run: | - if [ "${IS_PRERELEASE}" == "true" ]; then - echo "::notice::Resolved no floating tags: ${TAG} is marked as a prerelease." - echo "proceed=false" >> "$GITHUB_OUTPUT" + REPO="${INPUT_REPOSITORY:-$GITHUB_REPOSITORY}" + + nothing_to_do() { + echo "::notice::Resolved no floating tags: $1" + { + echo "tags=" + echo "skipped=" + echo "version=" + } >> "$GITHUB_OUTPUT" exit 0 - fi + } - # A release tag may be written either way, 1.2.3 or v1.2.3, so the comparisons below all run - # on the version without the v. The floating tags always carry the v, because that is what a + # A release tag may be written either way, 1.2.3 or v1.2.3, so everything below runs on the + # version without the v. The floating tags always carry the v, because that is what a # `uses:` line resolves. + # shellcheck disable=SC2153 # TAG is the input, not a typo for TAGS. VERSION="${TAG#v}" - if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+$ ]]; then - echo "::notice::Resolved no floating tags: ${TAG} is not an X.Y.Z version." - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 + # Neither a prerelease suffix nor build metadata is part of a floating tag name: 1.3.0-rc.1 + # would own v1 and v1.3, never v1-rc. They are stripped in two steps because only the `-` + # suffix means prerelease. Build metadata does not: 1.4.0+build.7 is a stable 1.4.0. + RELEASE_VERSION="${VERSION%%+*}" + CORE="${RELEASE_VERSION%%-*}" + + if [[ ! "${CORE}" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then + nothing_to_do "${TAG} is not a numeric version." fi - { - echo "proceed=true" - echo "version=${VERSION}" - echo "major=${BASH_REMATCH[1]}" - echo "minor=${BASH_REMATCH[2]}" - } >> "$GITHUB_OUTPUT" + IFS='.' read -r -a PARTS <<< "${CORE}" - - name: Resolve the floating tags for this version - id: resolve - if: steps.version.outputs.proceed == 'true' - shell: bash - env: - VERSION: ${{ steps.version.outputs.version }} - MAJOR: ${{ steps.version.outputs.major }} - MINOR: ${{ steps.version.outputs.minor }} - LEVELS: ${{ inputs.levels }} - REQUIRE_NEWEST: ${{ inputs.require-newest }} - INPUT_REPOSITORY: ${{ inputs.repository }} - GH_TOKEN: ${{ inputs.token }} - run: | - REPO="${INPUT_REPOSITORY:-$GITHUB_REPOSITORY}" + # Three parts is the floor so that the deepest floating tag, v., is always a + # shorter name than the release tag itself. A release tagged 1.2 would collide with v1.2. + # There is no upper limit: a fourth part is a hotfix, and the patterns below read it as + # another release of the same minor line rather than a line of its own. + if [ "${#PARTS[@]}" -lt 3 ]; then + nothing_to_do "${TAG} has ${#PARTS[@]} parts; at least three are needed." + fi - # Each entry pairs a floating tag with the pattern for the releases it covers. The patterns - # anchor at both ends so a longer number cannot match a shorter one: 122.3.4 is not a 1.2 - # release. - ENTRIES=() + # Validate every requested level before resolving any, so a typo fails the job rather than + # quietly writing a shorter set of tags than the caller asked for. + WANT_MAJOR='false' + WANT_MINOR='false' # Word splitting is intended: LEVELS is a list. # shellcheck disable=SC2086,SC2153 set -- ${LEVELS} for LEVEL in "$@"; do case "${LEVEL}" in - major) ENTRIES+=( "v${MAJOR}|^${MAJOR}\.[0-9]+\.[0-9]+$" ) ;; - minor) ENTRIES+=( "v${MAJOR}.${MINOR}|^${MAJOR}\.${MINOR}\.[0-9]+$" ) ;; + major) WANT_MAJOR='true' ;; + minor) WANT_MINOR='true' ;; *) echo "::error::Unknown level '${LEVEL}' in levels. Use 'major', 'minor' or both." exit 1 @@ -132,20 +124,54 @@ runs: esac done - if [ "${#ENTRIES[@]}" -eq 0 ]; then + if [ "${WANT_MAJOR}" != "true" ] && [ "${WANT_MINOR}" != "true" ]; then echo "::error::levels named no floating tags to resolve." exit 1 fi - # The highest version matching $1 among published, non-prerelease releases. sort -V orders - # 1.10.0 above 1.9.0, which sort alone does not. The limit is well past the number of - # releases in one version line; releases come back newest first, so an older line's newest - # release is never the entry that falls off the end. - newest_version_matching() { - gh release list --repo "${REPO}" --limit 200 \ - --json tagName,isPrerelease,isDraft \ - --jq '.[] | select(.isPrerelease == false and .isDraft == false) | .tagName' \ - | sed 's/^v//' \ + # One read serves both questions below: whether this release is a prerelease, and which + # release is the newest of each version line. Drafts are left out because their tag does not + # exist yet. The limit is well past the number of releases in one line, and releases come + # back newest first, so an older line's newest release never falls off the end. + # Every row is normalised the same way this tag was, so that a release carrying build + # metadata or a suffix still compares as its own version. Column 1 is the bare version, + # column 2 whether it is a prerelease, column 3 the tag as written. + RELEASES="${RUNNER_TEMP:-/tmp}/floating-tag-releases.tsv" + # shellcheck disable=SC2016 # The $ names below are jq bindings, not shell expansions. + if ! gh release list --repo "${REPO}" --limit 200 \ + --json tagName,isPrerelease,isDraft \ + --jq '.[] | select(.isDraft == false) + | (.tagName | sub("^v"; "")) as $raw + | ($raw | sub("\\+.*$"; "")) as $rel + | ($rel | sub("-.*$"; "")) as $core + | [$core, (if .isPrerelease or ($core != $rel) then "pre" else "stable" end), $raw] + | @tsv' > "${RELEASES}" + then + # Without the list, the suffix is still a signal and the backwards check has nothing to + # compare against, so the tags resolve on the tag name alone. + echo "::warning::Could not read ${REPO}'s releases. Resolving from the tag name alone." + : > "${RELEASES}" + fi + + # A suffix says prerelease on its own. A plain 1.3.0 marked as a prerelease on GitHub does + # not look any different, so the flag is read from the release list as well. + LISTED_AS="$(awk -F'\t' -v t="${VERSION}" '$3 == t { print $2; exit }' "${RELEASES}")" + + IS_PRERELEASE='false' + if [ "${CORE}" != "${RELEASE_VERSION}" ] || [ "${LISTED_AS}" == "pre" ]; then + IS_PRERELEASE='true' + fi + + if [ "${IS_PRERELEASE}" == "true" ] && [ "${ALLOW_PRERELEASES}" != "true" ]; then + nothing_to_do "${TAG} is a prerelease. Set allow-prereleases to change that." + fi + + # The newest stable version matching $1. sort -V orders 1.10.0 above 1.9.0, and 1.2.3.1 + # above 1.2.3, which sort alone does neither. Prereleases are left out: a floating tag + # position belongs to a stable release unless the caller asked otherwise, and the patterns + # exclude suffixed tags anyway. + newest_stable_matching() { + awk -F'\t' '$2 == "stable" { print $1 }' "${RELEASES}" \ | grep -E "$1" \ | sort -V \ | tail -n 1 @@ -154,28 +180,56 @@ runs: TAGS=() SKIPPED=() - for ENTRY in "${ENTRIES[@]}"; do - FLOATING="${ENTRY%%|*}" - PATTERN="${ENTRY#*|}" - - if [ "${REQUIRE_NEWEST}" == "true" ]; then - NEWEST="$(newest_version_matching "${PATTERN}")" - # An empty result means this release is not listed yet, which happens when a repo tags - # without publishing Releases. Falling back to resolving the tag keeps that case working - # rather than silently moving nothing. - if [ -n "${NEWEST}" ] && [ "${NEWEST}" != "${VERSION}" ]; then - echo "::notice::${FLOATING} stays where it is: ${NEWEST} is newer than ${VERSION}." - SKIPPED+=( "${FLOATING}" ) - continue - fi + # Fixed order, so the output reads shortest first whatever order levels were written in. + # Each pattern is the tag's own numbers followed by at least one more part, which is what + # makes a 1.2.3.1 hotfix a member of the 1.2 line and of the 1.x line, competing with 1.2.3 + # for both tags. Anchoring both ends keeps 122.3.4 from counting as a 1.2 release. + add_level() { + local floating="$1" pattern="$2" newest higher + + newest="$(newest_stable_matching "${pattern}")" + + # No stable release in this line: the first release of a new line, or a repo that tags + # without publishing Releases. Nothing can be newer, so the tag resolves either way. + if [ -z "${newest}" ]; then + TAGS+=( "${floating}" ) + return fi - TAGS+=( "${FLOATING}" ) - done + # A prerelease never takes a tag away from a stable release. Anyone pinned to a line that + # has already had a stable release expects a released version from it, so only a line that + # has never had one may point at a prerelease. That is what gives a 1.4.0-rc.1 the v1.4 + # tag, which has no stable release behind it, while leaving v1 on 1.3.0. + if [ "${IS_PRERELEASE}" == "true" ]; then + echo "::notice::${floating} stays on stable ${newest}: ${VERSION} is a prerelease." + SKIPPED+=( "${floating}" ) + return + fi + + # Both sides are bare numbers here, so sort -V is reliable. Comparing the tags as written + # would not be: sort -V puts 1.4.0-rc.1 above 1.4.0, the opposite of semver. + higher="$(printf '%s\n%s\n' "${newest}" "${CORE}" | sort -V | tail -n 1)" + if [ "${higher}" != "${CORE}" ]; then + echo "::notice::${floating} stays where it is: ${newest} is newer than ${VERSION}." + SKIPPED+=( "${floating}" ) + return + fi + + TAGS+=( "${floating}" ) + } + + if [ "${WANT_MAJOR}" == "true" ]; then + add_level "v${PARTS[0]}" "^${PARTS[0]}(\\.[0-9]+)+$" + fi + + if [ "${WANT_MINOR}" == "true" ]; then + add_level "v${PARTS[0]}.${PARTS[1]}" "^${PARTS[0]}\\.${PARTS[1]}(\\.[0-9]+)+$" + fi echo "Resolved for ${VERSION}: ${TAGS[*]-none}" { echo "tags=${TAGS[*]-}" echo "skipped=${SKIPPED[*]-}" + echo "version=${VERSION}" } >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index cf62947..52d1880 100644 --- a/.github/workflows/release-tags.yml +++ b/.github/workflows/release-tags.yml @@ -37,7 +37,6 @@ jobs: uses: ./.github/actions/resolve-floating-tags with: tag: ${{ github.event.release.tag_name }} - is-prerelease: ${{ github.event.release.prerelease }} - name: Move them onto the released commit uses: ./.github/actions/move-git-tags From b5bd8a28e6b588ea66ec6fa12a83c2778ec1ace5 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 30 Sep 2026 16:11:40 -0400 Subject: [PATCH 03/40] Cut evaluative filler from the tag action docs Sentences telling the reader how to regard a fact, rather than stating it: "That is the case to know about", "That is what makes the flag useful rather than dangerous", "This is also what makes publishing out of order safe", "is the whole test". Also two idioms, "swallowed the error" and "takes the tag off". The no-Releases section keeps its own heading. The release list decides the line comparison for stable releases too, not only prereleases, and a repo without it loses the comparison outright: a floating tag there follows whatever was published most recently rather than whatever is newest. --- .github/actions/move-git-tags/README.md | 4 +- .github/actions/move-git-tags/action.yml | 2 +- .../actions/resolve-floating-tags/README.md | 37 ++++++++++--------- .../actions/resolve-floating-tags/action.yml | 4 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/.github/actions/move-git-tags/README.md b/.github/actions/move-git-tags/README.md index a5ca72a..dfc01ec 100644 --- a/.github/actions/move-git-tags/README.md +++ b/.github/actions/move-git-tags/README.md @@ -99,7 +99,7 @@ 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. That is what a floating tag should be: it is a moving pointer, and an -annotated tag object would be rewritten on every release anyway. +message of their own. A floating tag is repointed on every release, 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. diff --git a/.github/actions/move-git-tags/action.yml b/.github/actions/move-git-tags/action.yml index a6780de..0e874c5 100644 --- a/.github/actions/move-git-tags/action.yml +++ b/.github/actions/move-git-tags/action.yml @@ -84,7 +84,7 @@ runs: # Ask whether the ref exists rather than letting a failed PATCH fall through to a POST. A # PATCH also fails for a token without permission or a protected tag, and a fallback that - # swallowed the error would report those as success. + # ignored the error would report those as success. # # git/ref (singular) is the endpoint that 404s on a missing ref. git/refs (plural) matches # by prefix, so it answers 200 for `tags/v1` whenever 1.0.0 exists, with no v1 to update. diff --git a/.github/actions/resolve-floating-tags/README.md b/.github/actions/resolve-floating-tags/README.md index e6f446d..bad41ea 100644 --- a/.github/actions/resolve-floating-tags/README.md +++ b/.github/actions/resolve-floating-tags/README.md @@ -56,12 +56,12 @@ line: So consumers pinned to `@v1.2` get the patch, and consumers pinned to `@v1` keep 1.3.0 rather than being moved back to older code. `v1` appears in `skipped` and `tags` holds `v1.2` alone. -This is also what makes publishing out of order safe. Cutting 1.1.1 while `v1` points at 1.2.0 moves -`v1.1` and leaves `v1` alone. +Publishing out of order follows the same rule. Cutting 1.1.1 while `v1` points at 1.2.0 moves `v1.1` +and leaves `v1` alone. A newer **major** never holds a lower one back, because it is not in the same line. With 2.1.0 released, publishing 1.2.4 as the newest 1.x still resolves both `v1` and `v1.2`. Someone pinned to -`@v1` asked for the 1.x line, so a backport on it is theirs. +`@v1` asked for the 1.x line, so a backport on it goes to them. ## Hotfix versions with a fourth part @@ -108,8 +108,7 @@ Build metadata is not a prerelease. `1.4.0+build.7` is a stable 1.4.0 and owns t `allow-prereleases: 'true'` lets a prerelease own a floating tag, but only one whose line has had no stable release yet. A prerelease never takes a tag away from a stable release. -That is what makes the flag useful rather than dangerous. Publishing 1.4.0-rc.1 while the newest -stable release is 1.3.0: +Publishing 1.4.0-rc.1 while the newest stable release is 1.3.0: | Tag | Line it covers | Stable release in that line | Result | |---|---|---|---| @@ -119,9 +118,9 @@ stable release is 1.3.0: So `@v1.4` is how someone opts into testing the 1.4 line before it ships, and `@v1` keeps handing everyone else the newest stable release. -Each rc replaces the one before it on `v1.4`, and the stable release then takes the tag off the last -rc. Only stable releases are counted when finding the newest in a line, so rc.1 never stands in -rc.2's way, and neither of them holds 1.4.0 back: +Each rc replaces the one before it on `v1.4`, and 1.4.0 then replaces the last rc. Only stable +releases are counted when finding the newest in a line, so rc.1 does not block rc.2, and neither rc +blocks 1.4.0: | Publishing | Stable releases so far | `v1` | `v1.4` | |---|---|---|---| @@ -130,10 +129,9 @@ rc.2's way, and neither of them holds 1.4.0 back: | 1.4.0 | 1.3.0, 1.4.0 | moves to 1.4.0 | moves to 1.4.0, replacing rc.2 | | 1.4.1-rc.1 | 1.3.0, 1.4.0 | stays at 1.4.0 | stays at 1.4.0 | -The last row is the rule at work in the other direction. A line that has never had a stable release -is the whole test, not which version is higher, so once 1.4.0 has shipped the 1.4 line has a stable -release in it and no later 1.4 prerelease can take either tag. Publishing an rc out of order behaves -the same way: +The last row follows from the same rule. The test is whether the line has ever had a stable release, +not which version is higher, so once 1.4.0 has shipped the 1.4 line has a stable release in it and +no later 1.4 prerelease can take either tag. Publishing an rc out of order behaves the same way: | Publishing | Stable releases | `v1` | `v1.4` | |---|---|---|---| @@ -153,12 +151,17 @@ out the newest release of a line, so an outstanding 1.9.0-rc.1 does not hold `v1 ## Repos with no Releases -The comparison reads GitHub Releases through `gh release list`, not git tags, because that is where -the prerelease and draft flags live. Drafts are left out, since their tag does not exist yet. +Everything above, for stable and prereleases alike, is decided from the repository's GitHub +Releases, read once through `gh release list`. Git tags are not used, because the prerelease and +draft flags are not in them. Drafts are left out, since their tag does not exist yet. -A repo that pushes tags without publishing Releases has nothing for the comparison to read. The -release being published is then absent from the list, so nothing can be newer than it and the tags -resolve on the tag name alone. The same path runs when the read fails outright, with a warning. +A repo that pushes tags without publishing Releases has nothing for any of it to read. The release +being published is itself absent from the list, so nothing can be newer than it and every requested +tag resolves on the tag name alone. The same path runs when the read fails outright, with a warning +rather than a failed job. + +No release is held back on such a repo, so the tags follow whatever was published most recently +rather than whatever is newest. Publishing 1.2.5 after 1.3.0 moves `v1` to 1.2.5 there. The read is capped at the 200 most recent releases. They come back newest first, so the newest release of an older line is never the entry that falls off the end. diff --git a/.github/actions/resolve-floating-tags/action.yml b/.github/actions/resolve-floating-tags/action.yml index 4b39e07..4bf73ac 100644 --- a/.github/actions/resolve-floating-tags/action.yml +++ b/.github/actions/resolve-floating-tags/action.yml @@ -198,8 +198,8 @@ runs: # A prerelease never takes a tag away from a stable release. Anyone pinned to a line that # has already had a stable release expects a released version from it, so only a line that - # has never had one may point at a prerelease. That is what gives a 1.4.0-rc.1 the v1.4 - # tag, which has no stable release behind it, while leaving v1 on 1.3.0. + # has never had one may point at a prerelease. A 1.4.0-rc.1 therefore takes v1.4, which + # has no stable release behind it, and leaves v1 on 1.3.0. if [ "${IS_PRERELEASE}" == "true" ]; then echo "::notice::${floating} stays on stable ${newest}: ${VERSION} is a prerelease." SKIPPED+=( "${floating}" ) From 79c5e3fd9181e42daf0b1a99c91c6759ee72ad14 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 30 Sep 2026 16:22:46 -0400 Subject: [PATCH 04/40] Read the version lines from tags, not the release list `gh release list` is ordered by publication date and capped by --limit. On a repo with more releases than the cap, a high version published long ago sits past the end of the list. The comparison then found nothing newer than the release being published and moved the tag backwards onto it: with 300 releases and a 1.5.0 old enough to fall past a 200 cap, publishing 1.2.5 moved v1 from 1.5.0 to 1.2.5, and said nothing about it. Raising the limit moves the cliff rather than removing it, so the version lines now come from the repository's tags through matching-refs with --paginate, which has no cap. Tags also exclude drafts without asking, since a draft release has no tag, and each name is normalised the way the tag input is so a tag carrying build metadata compares as its own version. The Releases API is still needed for one thing, whether the release being published is marked as a prerelease, and that is now asked with `gh release view` on that one tag instead of by listing releases. A consequence: a tag whose name is a plain version counts as a released version even when the release behind it is marked as a prerelease, since tag names do not carry the flag. A suffixed tag never counts. This decides only which version is the newest of a line, and it errs toward leaving a floating tag where it is. The release being published is exempt, because its own tag would otherwise be the released version that blocks it: a 1.3.0 marked as a prerelease resolves v1.3 and leaves v1, rather than resolving nothing. Also name the `tag` input where the docs said "this release", which did not say which release was meant. --- .../actions/resolve-floating-tags/README.md | 43 +++++---- .../actions/resolve-floating-tags/action.yml | 96 ++++++++++--------- 2 files changed, 75 insertions(+), 64 deletions(-) diff --git a/.github/actions/resolve-floating-tags/README.md b/.github/actions/resolve-floating-tags/README.md index bad41ea..19d356e 100644 --- a/.github/actions/resolve-floating-tags/README.md +++ b/.github/actions/resolve-floating-tags/README.md @@ -29,8 +29,8 @@ move-git-tags without this action. | `tag` | yes | | The release tag, with or without a leading `v`, e.g. `1.2.3` or `v1.2.3` | | `levels` | no | `'major minor'` | Which tags to resolve: `major` for `v1`, `minor` for `v1.2` | | `allow-prereleases` | no | `'false'` | Let a prerelease own a tag whose line has had no stable release. See [Prereleases](#prereleases) | -| `repository` | no | current repo | The `/` whose releases to read | -| `token` | no | `github.token` | Reads the release list. Read access is enough | +| `repository` | no | current repo | The `/` whose tags and releases to read | +| `token` | no | `github.token` | Reads the repo's tags, and the prerelease flag of the tag passed in. Read access is enough | ## Outputs @@ -100,7 +100,8 @@ A prerelease is skipped by default, and it is detected without the caller passin Two signals are read, because either can occur alone. A tag with a suffix, `1.3.0-rc.1`, is a prerelease on its own. A tag that is a plain `1.3.0` but marked as a prerelease on GitHub is not -visibly different, so the flag is read from the release list, which this action fetches anyway. +visibly different, so the release behind the `tag` input is asked about directly with +`gh release view`. Build metadata is not a prerelease. `1.4.0+build.7` is a stable 1.4.0 and owns the tags a stable 1.4.0 would. The metadata is not part of a tag name, so it resolves `v1` and `v1.4`. @@ -145,23 +146,29 @@ precedence, which `sort -V` does not do. Stable releases are ordered, and are ne The same rule gives a brand new major line to its own prerelease. Publishing 2.0.0-rc.1 with no stable 2.x released resolves `v2` and `v2.0`, since nothing stable is behind either tag. -A prerelease never blocks a stable release either. Only stable releases are considered when working -out the newest release of a line, so an outstanding 1.9.0-rc.1 does not hold `v1` back from a stable -1.3.1. +A prerelease never blocks a released version either. A suffixed tag is not counted when working out +the newest version of a line, so an outstanding 1.9.0-rc.1 does not hold `v1` back from 1.3.1. -## Repos with no Releases +## Where the versions come from -Everything above, for stable and prereleases alike, is decided from the repository's GitHub -Releases, read once through `gh release list`. Git tags are not used, because the prerelease and -draft flags are not in them. Drafts are left out, since their tag does not exist yet. +The newest version of a line is read from the repository's git tags, through `matching-refs` with +`--paginate`. Every tag is read, however many there are. -A repo that pushes tags without publishing Releases has nothing for any of it to read. The release -being published is itself absent from the list, so nothing can be newer than it and every requested -tag resolves on the tag name alone. The same path runs when the read fails outright, with a warning -rather than a failed job. +`gh release list` is not used for it. That list is ordered by publication date and capped by a +`--limit`, so on a repo with more releases than the cap a high version published long ago sits past +the end. The comparison would then find nothing newer than the release being published and move +`v1` backwards onto it. Publishing 1.2.5 on a repo whose 1.5.0 is old enough to fall past the cap is +exactly the case that breaks, and it breaks silently. Tags have no such cap, and a draft release is +excluded for free, since a draft has no tag. -No release is held back on such a repo, so the tags follow whatever was published most recently -rather than whatever is newest. Publishing 1.2.5 after 1.3.0 moves `v1` to 1.2.5 there. +The Releases API is needed for one thing: whether the release behind the `tag` input is marked as a +prerelease. That is asked with `gh release view` on that tag alone, not by listing releases. -The read is capped at the 200 most recent releases. They come back newest first, so the newest -release of an older line is never the entry that falls off the end. +A tag whose name is a plain version therefore counts as a released version, even if the release +behind it is marked as a prerelease on GitHub. A suffixed tag never counts, whatever it is marked +as. This affects only which version is treated as the newest of a line, and it errs toward holding a +floating tag where it is. + +If the tags cannot be read at all, the job warns rather than failing and every requested tag +resolves on the `tag` input alone. Nothing is then held back, so the tags follow whatever was +published most recently rather than whatever is newest. diff --git a/.github/actions/resolve-floating-tags/action.yml b/.github/actions/resolve-floating-tags/action.yml index 4bf73ac..5355ea6 100644 --- a/.github/actions/resolve-floating-tags/action.yml +++ b/.github/actions/resolve-floating-tags/action.yml @@ -27,13 +27,13 @@ inputs: required: false default: 'false' repository: - description: 'The `/` whose releases to read. Defaults to the current one.' + description: 'The `/` whose tags and releases to read. Defaults to the current one.' required: false default: '' token: description: >- - Token used to read the release list, which is where the prerelease flag and the other releases - of this version line are read from. Read access to the repository's contents is enough. + Token used to read the repository's tags, and the prerelease flag of the release named by + `tag`. Read access to the repository's contents is enough. required: false default: ${{ github.token }} @@ -129,36 +129,36 @@ runs: exit 1 fi - # One read serves both questions below: whether this release is a prerelease, and which - # release is the newest of each version line. Drafts are left out because their tag does not - # exist yet. The limit is well past the number of releases in one line, and releases come - # back newest first, so an older line's newest release never falls off the end. - # Every row is normalised the same way this tag was, so that a release carrying build - # metadata or a suffix still compares as its own version. Column 1 is the bare version, - # column 2 whether it is a prerelease, column 3 the tag as written. - RELEASES="${RUNNER_TEMP:-/tmp}/floating-tag-releases.tsv" - # shellcheck disable=SC2016 # The $ names below are jq bindings, not shell expansions. - if ! gh release list --repo "${REPO}" --limit 200 \ - --json tagName,isPrerelease,isDraft \ - --jq '.[] | select(.isDraft == false) - | (.tagName | sub("^v"; "")) as $raw - | ($raw | sub("\\+.*$"; "")) as $rel - | ($rel | sub("-.*$"; "")) as $core - | [$core, (if .isPrerelease or ($core != $rel) then "pre" else "stable" end), $raw] - | @tsv' > "${RELEASES}" - then - # Without the list, the suffix is still a signal and the backwards check has nothing to - # compare against, so the tags resolve on the tag name alone. - echo "::warning::Could not read ${REPO}'s releases. Resolving from the tag name alone." - : > "${RELEASES}" + # Every tag in the repository, as a bare version, one per line. + # + # Tags rather than `gh release list`, because that list is ordered by publication date and + # capped by --limit. A high version published long ago sits past the cap on a repo with + # enough releases, and the comparison below would then see nothing newer and move a tag + # backwards onto the release being published. matching-refs with --paginate has no cap. + # Using tags also excludes drafts on its own, since a draft release has no tag yet. + # + # Each name is normalised the way the `tag` input was, so a tag carrying build metadata + # compares as its own version. Suffixed tags need no filtering here: a prerelease cannot + # match the anchored numeric patterns below. + REFS="${RUNNER_TEMP:-/tmp}/floating-tag-refs.txt" + VERSIONS="${RUNNER_TEMP:-/tmp}/floating-tag-versions.txt" + if gh api --paginate "repos/${REPO}/git/matching-refs/tags" --jq '.[].ref' > "${REFS}"; then + sed -e 's|^refs/tags/||' -e 's/^v//' -e 's/+.*$//' "${REFS}" > "${VERSIONS}" + else + # Without the tags the comparison has nothing to read, so the floating tags resolve on the + # `tag` input alone. Its suffix is still a signal for the prerelease check below. + echo "::warning::Could not read ${REPO}'s tags. Resolving from the tag name alone." + : > "${VERSIONS}" fi # A suffix says prerelease on its own. A plain 1.3.0 marked as a prerelease on GitHub does - # not look any different, so the flag is read from the release list as well. - LISTED_AS="$(awk -F'\t' -v t="${VERSION}" '$3 == t { print $2; exit }' "${RELEASES}")" - + # not look any different, so the release behind `tag` is asked about directly. A tag with no + # release behind it answers nothing, which counts as not a prerelease. IS_PRERELEASE='false' - if [ "${CORE}" != "${RELEASE_VERSION}" ] || [ "${LISTED_AS}" == "pre" ]; then + if [ "${CORE}" != "${RELEASE_VERSION}" ]; then + IS_PRERELEASE='true' + elif [ "$(gh release view "${TAG}" --repo "${REPO}" \ + --json isPrerelease --jq '.isPrerelease' 2>/dev/null)" == "true" ]; then IS_PRERELEASE='true' fi @@ -166,15 +166,19 @@ runs: nothing_to_do "${TAG} is a prerelease. Set allow-prereleases to change that." fi - # The newest stable version matching $1. sort -V orders 1.10.0 above 1.9.0, and 1.2.3.1 - # above 1.2.3, which sort alone does neither. Prereleases are left out: a floating tag - # position belongs to a stable release unless the caller asked otherwise, and the patterns - # exclude suffixed tags anyway. - newest_stable_matching() { - awk -F'\t' '$2 == "stable" { print $1 }' "${RELEASES}" \ - | grep -E "$1" \ - | sort -V \ - | tail -n 1 + # A prerelease tagged 1.3.0, marked as one only by GitHub's flag, is indistinguishable from + # a released version in a list of tag names. Drop this version from the comparison set so + # that such a release does not count as the released version blocking itself. A suffixed tag + # never matched the patterns to begin with, so this changes nothing for one. + if [ "${IS_PRERELEASE}" == "true" ]; then + grep -vxF "${CORE}" "${VERSIONS}" > "${VERSIONS}.cmp" || true + mv "${VERSIONS}.cmp" "${VERSIONS}" + fi + + # The highest version matching $1. sort -V orders 1.10.0 above 1.9.0, and 1.2.3.1 above + # 1.2.3, which sort alone does neither. + highest_version_matching() { + grep -E "$1" "${VERSIONS}" | sort -V | tail -n 1 } TAGS=() @@ -187,21 +191,21 @@ runs: add_level() { local floating="$1" pattern="$2" newest higher - newest="$(newest_stable_matching "${pattern}")" + newest="$(highest_version_matching "${pattern}")" - # No stable release in this line: the first release of a new line, or a repo that tags - # without publishing Releases. Nothing can be newer, so the tag resolves either way. + # No released version in this line: the first release of a new line, or a repo whose tags + # could not be read. Nothing can be newer, so the tag resolves either way. if [ -z "${newest}" ]; then TAGS+=( "${floating}" ) return fi - # A prerelease never takes a tag away from a stable release. Anyone pinned to a line that - # has already had a stable release expects a released version from it, so only a line that - # has never had one may point at a prerelease. A 1.4.0-rc.1 therefore takes v1.4, which - # has no stable release behind it, and leaves v1 on 1.3.0. + # A prerelease never takes a tag away from a released version. Anyone pinned to a line + # that has already had one expects a released version from it, so only a line that has + # never had one may point at a prerelease. A 1.4.0-rc.1 therefore takes v1.4, which has + # nothing released behind it, and leaves v1 on 1.3.0. if [ "${IS_PRERELEASE}" == "true" ]; then - echo "::notice::${floating} stays on stable ${newest}: ${VERSION} is a prerelease." + echo "::notice::${floating} stays on ${newest}: ${VERSION} is a prerelease." SKIPPED+=( "${floating}" ) return fi From 49864cb1cdc18b936d4db68536804fe253ec710e Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 30 Sep 2026 16:43:22 -0400 Subject: [PATCH 05/40] Move the tag logic into JavaScript run by github-script The two actions were one long bash script each, nested inside YAML. Reading them meant reading through sed, awk and grep pipelines, and testing them meant extracting the script out of the manifest and putting a fake `gh` on PATH. actions/github-script supplies a Node runtime and an Octokit already authenticated with the token input, so resolve.js and move.js run as they are. Nothing is installed and nothing is built: nodeNN actions need a committed dist/, github-script does not. The scripts are loaded by path rather than inlined, because github-script's require resolves against the workspace of the repository being built rather than the action's own directory; the path comes from github.action_path. Inputs arrive through process.env rather than being interpolated into the script, so a tag name is data and cannot become part of the program. This fixes a bug the bash carried. Publishing 1.4.0-rc.1 after a released 1.4.0 resolved v1.4 and moved it off the release. The exclusion that keeps a release from blocking itself matched on the core version, so it removed the released 1.4.0 rather than only the tag being published. It now matches the published tag's own name, and the released 1.4.0 blocks the rc as it should. Two more things fall out of Octokit. A failed write is told apart by status, so only a 404 from the existence check means "does not exist" and a 403 from a protected tag fails the job instead of being retried as a creation. And the version line test is now a part-by-part comparison rather than an anchored regex, which is what kept 122.3.4 out of the 1.2 line. Both scripts export their pure functions, so tests/ covers every rule in the two READMEs with no API and no shell: 28 cases over the line comparison, hotfix versions, prereleases, build metadata, level handling, rejected tags, tag name validation and the degraded paths. tests/ mirrors the action layout, so tests/actions//