From 6c69b106884ee7b903d1eb947760e0ca79f213c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:59:38 +0000 Subject: [PATCH 1/2] chore: harden the release script script/release was the stock actions/typescript-action template, and nothing referenced it - not the README, not an npm script, not a workflow. This ports the version currents-dev/cancel-run-gh-action arrived at, after three rounds of review there, and documents it in the README so it stops being an orphan. What changes: - The version comes from package.json instead of being typed at a prompt and confirmed by eye, so the tag cannot disagree with the manifest. - Pre-flight checks the template leaves to the operator: clean tree, on main, level with the remote, tags fetched, version is X.Y.Z, and a version that is not above the last release is refused rather than force-moving the major tag backwards. - The release tag and the major tag go up in one atomic push. Separately, a rejected major tag left vX.Y.Z published with @vN still on old code. - A run that died after tagging can be repeated: an existing tag on the current HEAD is treated as an unfinished run and the remaining steps resumed, including a releases/vN branch the remote is missing. A tag on any other commit still aborts. - A GitHub release is published with generated notes; this repo has tags but no releases today. - Latest-tag selection uses `git tag --sort=-v:refname`, which keeps working once a minor reaches two digits. The template's glob still handles the v1.0.10 and v1.0.11 already in this repo's history; v1.10.0 is where it would report a stale latest. The releases/vN snapshot the template performs is kept - releases/v1 in this repo came from it - guarded against the first-release case where the template expands an empty tag into `git branch releases/`. Verified against a throwaway remote carrying this repo's tag layout, v1.0.10 and v1.0.11 included: the 2.1.0 -> 2.1.1 patch release tags v2.1.1, moves v2, and leaves releases/v1 untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfXo7y92vCvoKLLaVgj5XM --- README.md | 23 ++++++ script/release | 212 ++++++++++++++++++++++++++----------------------- 2 files changed, 136 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 7f6d1d0..94cdf65 100644 --- a/README.md +++ b/README.md @@ -142,9 +142,32 @@ This action is implemented in TypeScript (`src/index.ts`, `src/post.ts`). After changing sources, run `npm run all` to format, lint, test, and rebuild `dist/` before committing. +## Releases + +The action is consumed by tag, and releases move the major tag (`v2`), so `@v2` +picks up fixes. See [action versioning][action-versioning]. + +Bump `version` in `package.json`, merge that to `main`, then cut the release: + +```bash +script/release +``` + +The script reads the version from `package.json` — it refuses to run on a dirty +tree, off `main`, behind the remote, or with a version that is not above the +last release — then tags `vX.Y.Z`, moves the major tag to it, and publishes the +release notes with `gh`. + +Both tags go up in one atomic push, so a rejected major tag cannot leave a +published `vX.Y.Z` behind. If a release does fail part way, fix the cause and +run `script/release` again on the same commit: it picks up where it stopped. A +new major leaves the previous one on a `releases/v#` branch so it stays +patchable. + +[action-versioning]: https://github.com/actions/toolkit/blob/main/docs/action-versioning.md [docs]: https://docs.currents.dev [gha-rerun]: https://docs.currents.dev/getting-started/ci-setup/github-actions/re-run-failed-only-tests [guide-rerun]: https://docs.currents.dev/guides/ci-optimization/re-run-only-failed-tests diff --git a/script/release b/script/release index 91d7e01..bbe30c3 100755 --- a/script/release +++ b/script/release @@ -1,126 +1,140 @@ -#!/bin/bash +#!/usr/bin/env bash -# Exit early -# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin -set -e - -# About: -# -# This is a helper script to tag and push a new release. GitHub Actions use -# release tags to allow users to select a specific version of the action to use. -# -# See: https://github.com/actions/typescript-action#publishing-a-new-release -# See: https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations +# Tags a release and moves the major tag, so workflows on `@v2` pick it up. # -# This script will do the following: +# package.json holds the version: bump and merge that first, then run this on +# main. Adapted from the actions/typescript-action release script, with the +# version read instead of typed, the pre-flight checks that script leaves to +# the operator, and a GitHub release. # -# 1. Retrieve the latest release tag -# 2. Display the latest release tag -# 3. Prompt the user for a new release tag -# 4. Validate the new release tag -# 5. Remind user to update the version field in package.json -# 6. Tag a new release -# 7. Set 'is_major_release' variable -# 8. Point separate major release tag (e.g. v1, v2) to the new release -# 9. Push the new tags (with commits, if any) to remote -# 10. If this is a major release, create a 'releases/v#' branch and push +# See: https://github.com/actions/toolkit/blob/main/docs/action-versioning.md # -# Usage: -# -# script/release +# Usage: script/release -# Variables -semver_tag_regex='v[0-9]+\.[0-9]+\.[0-9]+$' -semver_tag_glob='v[0-9].[0-9].[0-9]*' -git_remote='origin' -major_semver_tag_regex='\(v[0-9]*\)' +set -euo pipefail -# Terminal colors -OFF='\033[0m' -BOLD_RED='\033[1;31m' -BOLD_GREEN='\033[1;32m' -BOLD_BLUE='\033[1;34m' -BOLD_PURPLE='\033[1;35m' -BOLD_UNDERLINED='\033[1;4m' -BOLD='\033[1m' - -# 1. Retrieve the latest release tag -if ! latest_tag=$(git describe --abbrev=0 --match="$semver_tag_glob"); then - # There are no existing release tags - echo -e "No tags found (yet) - Continue to create and push your first tag" - latest_tag="[unknown]" -fi +cd "$(dirname "$0")/.." -# 2. Display the latest release tag -echo -e "The latest release tag is: ${BOLD_BLUE}${latest_tag}${OFF}" +remote='origin' +release_branch='main' -# 3. Prompt the user for a new release tag -read -r -p 'Enter a new release tag (vX.X.X format): ' new_tag +OFF='\033[0m' +RED='\033[1;31m' +GREEN='\033[1;32m' +BLUE='\033[1;34m' -# 4. Validate the new release tag -if echo "$new_tag" | grep -q -E "$semver_tag_regex"; then - # Release tag is valid - echo -e "Tag: ${BOLD_BLUE}$new_tag${OFF} is valid syntax" -else - # Release tag is not in `vX.X.X` format - echo -e "Tag: ${BOLD_BLUE}$new_tag${OFF} is ${BOLD_RED}not valid${OFF} (must be in ${BOLD}vX.X.X${OFF} format)" +die() { + echo -e "${RED}$1${OFF}" >&2 exit 1 +} + +# The release is whatever main points at, so make sure that is what the +# operator thinks it is before anything is tagged. +[ -z "$(git status --porcelain)" ] || die 'The working tree has uncommitted changes.' + +branch=$(git rev-parse --abbrev-ref HEAD) +[ "$branch" = "$release_branch" ] || die "On $branch - releases are cut from $release_branch." + +# --force because a run that failed mid-push leaves the local major tag moved +# ahead of the remote, and a plain fetch refuses to rewind it. +git fetch --tags --force "$remote" "$release_branch" +[ "$(git rev-parse HEAD)" = "$(git rev-parse FETCH_HEAD)" ] || + die "$release_branch is not level with $remote/$release_branch - pull first." + +version=$(node -p 'require("./package.json").version') +[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "package.json version '$version' is not X.Y.Z." + +new_tag="v$version" +major_tag="v${version%%.*}" + +# --sort=-v:refname orders by version rather than by what git describe can +# reach, and the glob keeps matching once a component reaches two digits. +# new_tag is excluded: on a resume it is already tagged here, and counting it +# as the last release would hide the major it is succeeding. +latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | + grep -vFx "$new_tag" | head -n 1 || true) + +# An existing tag on this very commit is a run that died after tagging - the +# steps below are idempotent, so pick up where it stopped. Anywhere else, the +# version has already shipped. +if git rev-parse -q --verify "refs/tags/$new_tag" >/dev/null; then + [ "$(git rev-parse "$new_tag^{commit}")" = "$(git rev-parse HEAD)" ] || + die "Tag $new_tag already exists on another commit - bump the version in package.json first." + resuming='yes' +else + resuming='no' fi -# 5. Remind user to update the version field in package.json -echo -e -n "Make sure the version field in package.json is ${BOLD_BLUE}$new_tag${OFF}. Yes? [Y/${BOLD_UNDERLINED}n${OFF}] " -read -r YN - -if [[ ! ($YN == "y" || $YN == "Y") ]]; then - # Package.json version field is not up to date - echo -e "Please update the package.json version to ${BOLD_PURPLE}$new_tag${OFF} and commit your changes" - exit 1 +# The major tag is force-moved, so a version that went backwards would drag +# every @vN consumer back to older code. +if [ -n "$latest_tag" ] && + [ "$new_tag" != "$(printf '%s\n%s\n' "$latest_tag" "$new_tag" | sort -V | tail -n 1)" ]; then + die "package.json is at $version, behind the released $latest_tag." fi -# 6. Tag a new release -git tag "$new_tag" --annotate --message "$new_tag Release" -echo -e "Tagged: ${BOLD_GREEN}$new_tag${OFF}" +[ "$resuming" = 'no' ] || + echo -e "${BLUE}${new_tag}${OFF} is already tagged here - resuming a release that did not finish." -# 7. Set 'is_major_release' variable -latest_major_release_tag=$(expr "$latest_tag" : "$major_semver_tag_regex") -new_major_release_tag=$(expr "$new_tag" : "$major_semver_tag_regex") +echo -e "Latest release: ${BLUE}${latest_tag:-none}${OFF}" +echo -e "New release: ${BLUE}${new_tag}${OFF} at $(git log -1 --format='%h %s')" -if ! [[ "$new_major_release_tag" = "$latest_major_release_tag" ]]; then - is_major_release='yes' +if git rev-parse -q --verify "refs/tags/$major_tag" >/dev/null; then + major_exists='yes' + echo -e " moves ${BLUE}${major_tag}${OFF} from $(git log -1 --format='%h %s' "$major_tag")" else - is_major_release='no' + major_exists='no' + echo -e " creates ${BLUE}${major_tag}${OFF}" fi -# 8. Point separate major release tag (e.g. v1, v2) to the new release -if [ $is_major_release = 'yes' ]; then - # Create a new major version tag and point it to this release - git tag "$new_major_release_tag" --annotate --message "$new_major_release_tag Release" - echo -e "New major version tag: ${BOLD_GREEN}$new_major_release_tag${OFF}" +# Moving the major tag ships this to everyone on that major at once. See the +# Releases section of the README. +echo 'Every workflow on the major tag picks this up immediately.' +read -r -p 'Tag and push? [y/N] ' reply || reply='' +[[ $reply =~ ^[Yy]$ ]] || die 'Aborted - nothing was tagged.' + +[ "$resuming" = 'yes' ] || git tag --annotate --message "$new_tag" "$new_tag" +git tag --annotate --force --message "Sync $major_tag with $new_tag" "$major_tag" + +# One atomic push: a rejected major tag (branch protection, a lost race) must +# not leave the release tag published with @vN still on the old code. +git push --atomic "$remote" \ + "refs/tags/$new_tag:refs/tags/$new_tag" \ + "+refs/tags/$major_tag:refs/tags/$major_tag" + +echo -e "Tagged: ${GREEN}${new_tag}${OFF}" +if [ "$major_exists" = 'yes' ]; then + echo -e "Moved: ${GREEN}${major_tag}${OFF} -> ${GREEN}${new_tag}${OFF}" else - # Update the major version tag to point it to this release - git tag "$latest_major_release_tag" --force --annotate --message "Sync $latest_major_release_tag tag with $new_tag" - echo -e "Synced ${BOLD_GREEN}$latest_major_release_tag${OFF} with ${BOLD_GREEN}$new_tag${OFF}" + echo -e "Tagged: ${GREEN}${major_tag}${OFF} -> ${GREEN}${new_tag}${OFF}" fi -# 9. Push the new tags (with commits, if any) to remote -git push --follow-tags +# A new major leaves the old one without a moving tag, so keep its line +# patchable on a branch. +previous_major="" +if [ -n "$latest_tag" ]; then + previous_major="${latest_tag#v}" + previous_major="v${previous_major%%.*}" +fi -if [ $is_major_release = 'yes' ]; then - # New major version tag is pushed with the '--follow-tags' flags - echo -e "Tags: ${BOLD_GREEN}$new_major_release_tag${OFF} and ${BOLD_GREEN}$new_tag${OFF} pushed to remote" -else - # Force push the updated major version tag - git push $git_remote "$latest_major_release_tag" --force - echo -e "Tags: ${BOLD_GREEN}$latest_major_release_tag${OFF} and ${BOLD_GREEN}$new_tag${OFF} pushed to remote" +if [ -n "$previous_major" ] && [ "$previous_major" != "$major_tag" ] && + git rev-parse -q --verify "refs/tags/$previous_major" >/dev/null && + [ -z "$(git ls-remote --heads "$remote" "refs/heads/releases/$previous_major")" ]; then + # The remote decides whether this is published; a local branch left over + # from a failed push is reused rather than treated as done. + git rev-parse -q --verify "refs/heads/releases/$previous_major" >/dev/null || + git branch "releases/$previous_major" "refs/tags/$previous_major" + git push --set-upstream "$remote" "releases/$previous_major" + echo -e "Branch: ${GREEN}releases/${previous_major}${OFF} keeps the ${previous_major} line patchable" fi -# 10. If this is a major release, create a 'releases/v#' branch and push -if [ $is_major_release = 'yes' ]; then - git branch "releases/$latest_major_release_tag" "$latest_major_release_tag" - echo -e "Branch: ${BOLD_BLUE}releases/$latest_major_release_tag${OFF} created from ${BOLD_BLUE}$latest_major_release_tag${OFF} tag" - git push --set-upstream $git_remote "releases/$latest_major_release_tag" - echo -e "Branch: ${BOLD_GREEN}releases/$latest_major_release_tag${OFF} pushed to remote" +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + if gh release view "$new_tag" >/dev/null 2>&1; then + echo "Release $new_tag already published." + else + gh release create "$new_tag" --title "$new_tag" --generate-notes + fi +else + echo "gh unavailable - publish the notes with: gh release create $new_tag --generate-notes" fi -# Completed -echo -e "${BOLD_GREEN}Done!${OFF}" +echo -e "${GREEN}Done.${OFF}" From 998dd3ac0de93725badccdfc76bda84d5617abfe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:14:55 +0000 Subject: [PATCH 2/2] fix: harden the release script against prereleases and races Three findings from review, all verified against a throwaway remote before and after: - A release candidate blocked its own final. sort -V ranks v2.1.1-rc.1 above v2.1.1, and the glob admitted it, so a stray rc left on any branch became latest_tag and the monotonic check refused 2.1.1 as "behind the released v2.1.1-rc.1". Only exact vX.Y.Z counts as a release now. - A release landing while this one sat at the prompt was silently rolled back: the forced major-tag push clobbered it with the older commit fetched before the prompt. The push now carries --force-with-lease against the value read just before the prompt, so it is rejected instead, and --atomic holds the release tag back with it. The major tag's refspec deliberately lost its '+'. A '+' forces unconditionally and silently bypasses the lease - keeping it would have left this guard doing nothing at all. - A local releases/vN branch was pushed as the previous major's maintenance line without checking it was that major. It now has to be at the tag, or the run stops and says so, rather than publishing some unrelated branch that happens to carry the name. The tags are already up by that point, so the fix is to correct the branch and rerun. Not taken: restricting the scan to tags reachable from HEAD. Reachability is what this moved away from, and a release cut from a maintenance branch would then go uncounted - the one case the monotonic check exists to catch. shellcheck clean, as the version before it was. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AfXo7y92vCvoKLLaVgj5XM --- script/release | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/script/release b/script/release index bbe30c3..b8d4a88 100755 --- a/script/release +++ b/script/release @@ -48,10 +48,13 @@ new_tag="v$version" major_tag="v${version%%.*}" # --sort=-v:refname orders by version rather than by what git describe can -# reach, and the glob keeps matching once a component reaches two digits. -# new_tag is excluded: on a resume it is already tagged here, and counting it -# as the last release would hide the major it is succeeding. -latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | +# reach, and keeps working once a component reaches two digits. Only exact +# vX.Y.Z counts: sort -V ranks v2.1.1-rc.1 above v2.1.1, so a release +# candidate left lying around would otherwise look like the latest release +# and block its own final. new_tag is excluded too - on a resume it is +# already tagged here, and counting it would hide the major it succeeds. +latest_tag=$(git tag --list --sort=-v:refname | + grep -Ex 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -vFx "$new_tag" | head -n 1 || true) # An existing tag on this very commit is a run that died after tagging - the @@ -78,6 +81,10 @@ fi echo -e "Latest release: ${BLUE}${latest_tag:-none}${OFF}" echo -e "New release: ${BLUE}${new_tag}${OFF} at $(git log -1 --format='%h %s')" +# What the remote has right now, so the push can refuse to clobber a release +# someone else lands while this one sits at the prompt. Empty when absent. +major_remote=$(git ls-remote "$remote" "refs/tags/$major_tag" | cut -f1) + if git rev-parse -q --verify "refs/tags/$major_tag" >/dev/null; then major_exists='yes' echo -e " moves ${BLUE}${major_tag}${OFF} from $(git log -1 --format='%h %s' "$major_tag")" @@ -97,9 +104,13 @@ git tag --annotate --force --message "Sync $major_tag with $new_tag" "$major_tag # One atomic push: a rejected major tag (branch protection, a lost race) must # not leave the release tag published with @vN still on the old code. -git push --atomic "$remote" \ +# +# The major tag deliberately carries no '+'. --force-with-lease does the +# forcing, and a '+' in the refspec forces unconditionally and silently +# bypasses the lease, which would make the guard below do nothing. +git push --atomic --force-with-lease="refs/tags/$major_tag:$major_remote" "$remote" \ "refs/tags/$new_tag:refs/tags/$new_tag" \ - "+refs/tags/$major_tag:refs/tags/$major_tag" + "refs/tags/$major_tag:refs/tags/$major_tag" echo -e "Tagged: ${GREEN}${new_tag}${OFF}" if [ "$major_exists" = 'yes' ]; then @@ -120,9 +131,16 @@ if [ -n "$previous_major" ] && [ "$previous_major" != "$major_tag" ] && git rev-parse -q --verify "refs/tags/$previous_major" >/dev/null && [ -z "$(git ls-remote --heads "$remote" "refs/heads/releases/$previous_major")" ]; then # The remote decides whether this is published; a local branch left over - # from a failed push is reused rather than treated as done. - git rev-parse -q --verify "refs/heads/releases/$previous_major" >/dev/null || + # from a failed push is reused rather than treated as done. It has to be + # the tag, though - publishing some unrelated local branch of that name as + # the previous major's maintenance line would be worse than not having one. + if git rev-parse -q --verify "refs/heads/releases/$previous_major" >/dev/null; then + [ "$(git rev-parse "refs/heads/releases/$previous_major")" = \ + "$(git rev-parse "refs/tags/$previous_major^{commit}")" ] || + die "Local releases/$previous_major is not at $previous_major - sort it out, then rerun." + else git branch "releases/$previous_major" "refs/tags/$previous_major" + fi git push --set-upstream "$remote" "releases/$previous_major" echo -e "Branch: ${GREEN}releases/${previous_major}${OFF} keeps the ${previous_major} line patchable" fi