From 6181b3e46a0f31fff5ac3ce940950c441f60f7da Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Mon, 26 Oct 2026 21:11:31 +0530 Subject: [PATCH 1/2] Generalize the hotfix workflow into a maintenance release workflow Rename release-hotfix.yml to release-maintenance.yml so it can publish regular patch releases from a maintenance branch (e.g. 1.0.9 from 1.0.x), not only prereleases such as 1.0.3-hotfix.1. - Accept release and prerelease versions, and reject versions that are not later than each package's current version or that leave the branch's major.minor line. - Default the dist-tag to release-. so each maintenance line has its own tag; `latest` stays rejected. - Start each GitHub Release's notes from the package's previous version tag, and mark it as a prerelease only for prerelease versions. - Run only build, like release.yml. Older maintenance branches can carry typecheck setups that no longer run (nuxt's vue-tsc on 1.0.x). Signed-off-by: NipuniBhagya --- ...ase-hotfix.yml => release-maintenance.yml} | 107 ++++++++++++------ 1 file changed, 72 insertions(+), 35 deletions(-) rename .github/workflows/{release-hotfix.yml => release-maintenance.yml} (66%) diff --git a/.github/workflows/release-hotfix.yml b/.github/workflows/release-maintenance.yml similarity index 66% rename from .github/workflows/release-hotfix.yml rename to .github/workflows/release-maintenance.yml index 0f769814..adf7f901 100644 --- a/.github/workflows/release-hotfix.yml +++ b/.github/workflows/release-maintenance.yml @@ -1,30 +1,30 @@ -# Hotfix release workflow for maintenance branches. +# Release workflow for maintenance branches. # -# A maintenance branch (e.g. `1.0.3-hotfix`) is cut from an older release, so the regular -# bump-based release would collide with versions already published from main. Instead, this -# publishes an explicit prerelease version (e.g. 1.0.3-hotfix.1) from that branch under a +# A maintenance branch (e.g. `1.0.x`) carries fixes for an older release line, so the regular +# bump-based release on main can't publish from it. This publishes an explicit version from that +# branch, either a patch on the line (e.g. 1.0.9) or a prerelease (e.g. 1.0.3-hotfix.1), under a # non-`latest` dist-tag, so `npm install @thunderid/` keeps resolving to the mainline release. -name: 🩹 Hotfix Release +name: 🛠️ Maintenance Release on: workflow_dispatch: inputs: branch: - description: 'Maintenance branch to release from (e.g. 1.0.3-hotfix)' + description: 'Maintenance branch to release from (e.g. 1.0.x)' required: true type: string version: - description: 'Exact prerelease version to publish (e.g. 1.0.3-hotfix.1)' + description: 'Exact version to publish (e.g. 1.0.9, or a prerelease such as 1.0.3-hotfix.1)' required: true type: string dist_tag: - description: 'npm dist-tag (anything except `latest`)' - required: true + description: 'npm dist-tag (defaults to release-.; never `latest`)' + required: false type: string - default: 'hotfix' + default: '' sdk_better_auth: description: '@thunderid/better-auth' @@ -98,7 +98,7 @@ permissions: id-token: write concurrency: - group: hotfix-release-${{ inputs.branch }} + group: maintenance-release-${{ inputs.branch }} cancel-in-progress: false env: @@ -107,18 +107,18 @@ env: NODE_VERSION: "lts/*" BRANCH: ${{ inputs.branch }} VERSION: ${{ inputs.version }} - DIST_TAG: ${{ inputs.dist_tag }} + DIST_TAG_INPUT: ${{ inputs.dist_tag }} jobs: release: - name: 🩹 Release ${{ inputs.version }} from ${{ inputs.branch }} + name: 🛠️ Release ${{ inputs.version }} from ${{ inputs.branch }} runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: 🛡️ Guard Branch run: | if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "${{ github.event.repository.default_branch }}" ]; then - echo "❌ Hotfix releases must come from a maintenance branch, not the default branch." + echo "❌ Maintenance releases must come from a maintenance branch, not the default branch." exit 1 fi @@ -145,13 +145,17 @@ jobs: run: | set -euo pipefail - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$ ]]; then - echo "❌ '$VERSION' is not a prerelease version (expected e.g. 1.0.3-hotfix.1)." + if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "❌ '$VERSION' is not a valid version (expected e.g. 1.0.9 or 1.0.3-hotfix.1)." exit 1 fi + line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" - if [ "$DIST_TAG" = "latest" ] || ! [[ "$DIST_TAG" =~ ^[a-z][a-z0-9-]*$ ]]; then - echo "❌ Invalid dist-tag '$DIST_TAG'. It must not be 'latest'." + # Each maintenance line gets its own dist-tag, so releases from different lines don't + # move each other's tag. + dist_tag="${DIST_TAG_INPUT:-release-$line}" + if [ "$dist_tag" = "latest" ] || ! [[ "$dist_tag" =~ ^[a-z][a-z0-9.-]*$ ]]; then + echo "❌ Invalid dist-tag '$dist_tag'. It must not be 'latest'." exit 1 fi @@ -175,11 +179,39 @@ jobs: exit 1 fi + : > "$RUNNER_TEMP/previous-versions" for pkg in $packages; do if [ ! -f "packages/$pkg/package.json" ]; then echo "❌ @thunderid/$pkg does not exist on $BRANCH." exit 1 fi + + current=$(node -p "require('./packages/$pkg/package.json').version") + # Reject versions outside the branch's release line (e.g. 1.4.1 on 1.0.x) and versions + # that don't move forward from what the branch last released. + if ! node -e ' + const parse = (v) => { + const dash = v.indexOf("-"); + const core = dash === -1 ? v : v.slice(0, dash); + return {nums: core.split(".").map(Number), pre: dash === -1 ? "" : v.slice(dash + 1)}; + }; + const [current, next] = process.argv.slice(1).map(parse); + if (current.nums[0] !== next.nums[0] || current.nums[1] !== next.nums[1]) process.exit(1); + for (let i = 0; i < 3; i++) { + if (next.nums[i] !== current.nums[i]) process.exit(next.nums[i] > current.nums[i] ? 0 : 1); + } + // Same core version: a release can follow a prerelease of that core, and a prerelease can + // follow the release itself (hotfix-style, e.g. 1.0.3 -> 1.0.3-hotfix.1) or an earlier + // prerelease (1.0.3-hotfix.1 -> 1.0.3-hotfix.2). + if (!next.pre) process.exit(current.pre ? 0 : 1); + if (!current.pre) process.exit(0); + process.exit(next.pre.localeCompare(current.pre, undefined, {numeric: true}) > 0 ? 0 : 1); + ' "$current" "$VERSION"; then + echo "❌ @thunderid/$pkg is at $current on $BRANCH; $VERSION must be a later version on the same $(echo "$current" | cut -d. -f1-2).x line." + exit 1 + fi + echo "$pkg $current" >> "$RUNNER_TEMP/previous-versions" + # Run npm outside the workspace; the root `devEngines` makes npm refuse to run here. published=$(cd "$RUNNER_TEMP" && npm view "@thunderid/$pkg@$VERSION" version 2>/dev/null || true) if [ -n "$published" ]; then @@ -192,8 +224,9 @@ jobs: fi done - echo "Releasing $VERSION from $BRANCH (dist-tag: $DIST_TAG) for: $packages" + echo "Releasing $VERSION from $BRANCH (dist-tag: $dist_tag) for: $packages" echo "packages=$packages" >> "$GITHUB_OUTPUT" + echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" - name: 📦 Set up pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -214,9 +247,9 @@ jobs: env: PACKAGES: ${{ steps.validate.outputs.packages }} run: | - # `workspace:^` would publish as `^`, and a caret range on a prerelease also - # matches the newer mainline releases. Pin to `workspace:*` so each hotfix package - # depends on the exact sibling versions from the maintenance branch. + # `workspace:^` would publish as `^`, which also matches the newer mainline + # releases (e.g. `^1.0.9` matches 1.4.x). Pin to `workspace:*` so each package depends on + # the exact sibling versions from the maintenance branch. node <<'EOF' const fs = require('fs'); @@ -242,17 +275,21 @@ jobs: # Keep the lockfile in sync with the rewritten specifiers so the branch stays installable. pnpm install --lockfile-only - - name: 🔨 Build & Typecheck + - name: 🔨 Build env: PACKAGES: ${{ steps.validate.outputs.packages }} run: | + # Build only, like release.yml. Typechecking belongs to the PR builder, and older + # maintenance branches can carry typecheck setups that no longer run (e.g. nuxt's + # `vue-tsc` on 1.0.x). filters="" for pkg in $PACKAGES; do filters="$filters --filter=@thunderid/$pkg"; done - pnpm turbo run build typecheck $filters + pnpm turbo run build $filters - name: 📦 Publish to npm env: PACKAGES: ${{ steps.validate.outputs.packages }} + DIST_TAG: ${{ steps.validate.outputs.dist_tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail @@ -271,7 +308,7 @@ jobs: for pkg in $PACKAGES; do git add "packages/$pkg/package.json"; done git add pnpm-lock.yaml - git diff --cached --quiet || git commit -m "[Hotfix Release] $VERSION ($PACKAGES)" + git diff --cached --quiet || git commit -m "[Maintenance Release] $VERSION ($PACKAGES)" git push origin "HEAD:refs/heads/$BRANCH" for pkg in $PACKAGES; do @@ -282,12 +319,11 @@ jobs: - name: 🚀 Create GitHub Releases env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} - PACKAGES: ${{ steps.validate.outputs.packages }} run: | set -euo pipefail - base_version="${VERSION%%-*}" - for pkg in $PACKAGES; do + # Read the list on fd 3 so `gh` can't consume it through stdin. + while read -r pkg previous <&3; do case "$pkg" in better-auth) title="Better Auth" ;; javascript) title="JavaScript" ;; @@ -302,14 +338,15 @@ jobs: nuxt) title="Nuxt" ;; esac - notes_args=(--generate-notes) - if git ls-remote --exit-code --tags origin "refs/tags/sdk/$pkg/v$base_version" > /dev/null; then - notes_args+=(--notes-start-tag "sdk/$pkg/v$base_version") + release_args=(--generate-notes --latest=false) + if git ls-remote --exit-code --tags origin "refs/tags/sdk/$pkg/v$previous" > /dev/null; then + release_args+=(--notes-start-tag "sdk/$pkg/v$previous") + fi + if [[ "$VERSION" == *-* ]]; then + release_args+=(--prerelease) fi gh release create "sdk/$pkg/v$VERSION" \ --title "ThunderID $title SDK v$VERSION" \ - --prerelease \ - --latest=false \ - "${notes_args[@]}" - done + "${release_args[@]}" + done 3< "$RUNNER_TEMP/previous-versions" From f5617c77155dc1eeee3de60e8bcff66853d2b073 Mon Sep 17 00:00:00 2001 From: NipuniBhagya Date: Mon, 26 Oct 2026 21:24:15 +0530 Subject: [PATCH 2/2] Compare prerelease versions with SemVer precedence Numeric locale collation ordered prerelease identifiers differently from SemVer (e.g. it accepted 1.0.9-hotfix.10 after 1.0.9-hotfix.2-1, which SemVer orders before it), so a release could move the maintenance dist-tag backward. Compare dot-separated identifiers per SemVer 2.0.0 instead, keeping the exception that allows a hotfix prerelease after its stable version. Signed-off-by: NipuniBhagya --- .github/workflows/release-maintenance.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-maintenance.yml b/.github/workflows/release-maintenance.yml index adf7f901..20f01915 100644 --- a/.github/workflows/release-maintenance.yml +++ b/.github/workflows/release-maintenance.yml @@ -195,6 +195,26 @@ jobs: const core = dash === -1 ? v : v.slice(0, dash); return {nums: core.split(".").map(Number), pre: dash === -1 ? "" : v.slice(dash + 1)}; }; + // SemVer 2.0.0 prerelease precedence: compare dot-separated identifiers left to right; + // numeric ones numerically, alphanumeric ones in ASCII order, numeric before + // alphanumeric, and a shorter set first when all preceding identifiers are equal. + const comparePre = (a, b) => { + const x = a.split("."), y = b.split("."); + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if (x[i] === undefined) return -1; + if (y[i] === undefined) return 1; + const xNum = /^[0-9]+$/.test(x[i]), yNum = /^[0-9]+$/.test(y[i]); + if (xNum && yNum) { + const xi = BigInt(x[i]), yi = BigInt(y[i]); + if (xi !== yi) return xi < yi ? -1 : 1; + } else if (xNum !== yNum) { + return xNum ? -1 : 1; + } else if (x[i] !== y[i]) { + return x[i] < y[i] ? -1 : 1; + } + } + return 0; + }; const [current, next] = process.argv.slice(1).map(parse); if (current.nums[0] !== next.nums[0] || current.nums[1] !== next.nums[1]) process.exit(1); for (let i = 0; i < 3; i++) { @@ -205,7 +225,7 @@ jobs: // prerelease (1.0.3-hotfix.1 -> 1.0.3-hotfix.2). if (!next.pre) process.exit(current.pre ? 0 : 1); if (!current.pre) process.exit(0); - process.exit(next.pre.localeCompare(current.pre, undefined, {numeric: true}) > 0 ? 0 : 1); + process.exit(comparePre(next.pre, current.pre) > 0 ? 0 : 1); ' "$current" "$VERSION"; then echo "❌ @thunderid/$pkg is at $current on $BRANCH; $VERSION must be a later version on the same $(echo "$current" | cut -d. -f1-2).x line." exit 1