Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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/<pkg>` 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-<major>.<minor>; never `latest`)'
required: false
type: string
default: 'hotfix'
default: ''

sdk_better_auth:
description: '@thunderid/better-auth'
Expand Down Expand Up @@ -98,7 +98,7 @@ permissions:
id-token: write

concurrency:
group: hotfix-release-${{ inputs.branch }}
group: maintenance-release-${{ inputs.branch }}
cancel-in-progress: false

env:
Expand All @@ -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

Expand All @@ -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

Expand All @@ -175,11 +179,59 @@ 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)};
};
// 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++) {
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(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
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
Expand All @@ -192,8 +244,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
Expand All @@ -214,9 +267,9 @@ jobs:
env:
PACKAGES: ${{ steps.validate.outputs.packages }}
run: |
# `workspace:^` would publish as `^<version>`, 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 `^<version>`, 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');

Expand All @@ -242,17 +295,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
Expand All @@ -271,7 +328,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
Expand All @@ -282,12 +339,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" ;;
Expand All @@ -302,14 +358,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"
Loading