From 0e04c89d50c171de1dd76496b214f27d62f55fac Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 00:49:09 +0300 Subject: [PATCH 1/8] ci(repo): add dispatched release path --- .github/workflows/release.yml | 178 +++++++++++++++++++++++++++++++++- scripts/legacy-release.sh | 120 +++++++++++++++++++++++ 2 files changed, 295 insertions(+), 3 deletions(-) create mode 100755 scripts/legacy-release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e802db8b7d..9f31547334f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ name: Release run-name: >- ${{ - github.event_name == 'issue_comment' + github.event_name == 'workflow_dispatch' + && format('[Legacy] Release from {0}', inputs.source_ref) + || github.event_name == 'issue_comment' && format('[Snapshot] Release by {0}', github.actor) || contains(github.event.head_commit.message, 'Version packages') && format('[Production] Release from {0}', github.ref_name) @@ -14,10 +16,24 @@ on: - main issue_comment: types: [created] + workflow_dispatch: + inputs: + source_ref: + description: 'Full git SHA to build from' + type: string + required: true + packages: + description: 'JSON array, e.g. [{"name":"@clerk/nextjs","version":"5.7.6","dist_tag":"latest-nextjs-v5"}]' + type: string + required: true + dry_run: + description: 'Log what would publish without actually publishing' + type: boolean + default: true concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'issue_comment' && format('issue-{0}-{1}', github.event.issue.number, github.actor) || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name == 'issue_comment' && format('issue-{0}-{1}', github.event.issue.number, github.actor) || github.event_name == 'workflow_dispatch' && format('legacy-{0}', inputs.source_ref) || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} jobs: release: @@ -508,6 +524,162 @@ jobs: Tip: Use the snippet copy button below to quickly install the required packages. ${{ steps.package-info.outputs.snippets }} + legacy-release: + name: Legacy Release + if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'clerk/javascript' && github.run_attempt == 1 }} + runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }} + timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 30 }} + + permissions: + contents: read + id-token: write + + steps: + - name: Verify triggering actor is repo admin + uses: actions/github-script@v7 + env: + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + with: + script: | + const username = process.env.TRIGGERING_ACTOR; + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username, + }); + if (data.permission !== 'admin') { + core.setFailed(`Only repo admins can dispatch this workflow. Actor: ${username} (permission: ${data.permission})`); + } + + - name: Validate source_ref is a SHA + env: + SOURCE_REF: ${{ inputs.source_ref }} + run: | + if ! printf '%s' "$SOURCE_REF" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::source_ref must be a full 40-char git SHA" + exit 1 + fi + + - name: Reject "latest" dist_tag + env: + PACKAGES: ${{ inputs.packages }} + run: | + echo "$PACKAGES" | jq -e 'all(.[]; .dist_tag != "latest")' > /dev/null || { + echo "::error::'latest' dist_tag is not allowed on this path"; exit 1; + } + + - name: Checkout source_ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref }} + fetch-depth: 1 + show-progress: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Enable corepack + run: corepack enable + + - name: Detect package manager + id: pm + run: | + if [ -f pnpm-lock.yaml ]; then + echo "manager=pnpm" >> "$GITHUB_OUTPUT" + else + echo "manager=npm" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + run: | + if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then + pnpm install --frozen-lockfile + else + npm ci + fi + + - name: Build + run: | + if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then + pnpm build + else + npm run build + fi + + - name: Upgrade npm for trusted publishing + run: npx npm@11 install -g npm@11 + + - name: Publish or dry-run + env: + NPM_CONFIG_PROVENANCE: true + PACKAGES: ${{ inputs.packages }} + DRY_RUN: ${{ inputs.dry_run }} + PACK: ${{ steps.pm.outputs.manager }} + run: | + echo "$PACKAGES" | jq -r '.[] | [.name, .version, .dist_tag] | @tsv' | while IFS=$'\t' read -r name version tag; do + short="${name#@clerk/}" + dir="packages/$short" + + if [ ! -d "$dir" ]; then + echo "::error::Package directory not found: $dir" + exit 1 + fi + + pkg_version=$(jq -r .version "$dir/package.json") + if [ "$pkg_version" != "$version" ]; then + echo "::error::$dir/package.json has version $pkg_version, expected $version" + exit 1 + fi + + echo "::group::Pack $name@$version" + if [ "$PACK" = "pnpm" ]; then + out=$(cd "$dir" && pnpm pack --json 2>/dev/null || true) + if [ -n "$out" ] && echo "$out" | jq -e . >/dev/null 2>&1; then + tarball=$(echo "$out" | jq -r '.filename') + else + # pnpm pack without --json prints the tarball path on stdout + tarball=$(cd "$dir" && pnpm pack 2>&1 | tail -n1 | xargs -I{} basename "{}") + fi + else + tarball=$(cd "$dir" && npm pack --json | jq -r '.[0].filename') + fi + if [ -z "$tarball" ] || [ ! -f "$dir/$tarball" ]; then + echo "::error::Failed to resolve tarball filename in $dir" + exit 1 + fi + echo "packed: $dir/$tarball" + echo "::endgroup::" + + if [ "$DRY_RUN" = "true" ]; then + echo "::notice::DRY RUN: would publish $name@$version --tag $tag" + else + echo "::group::Publish $name@$version --tag $tag" + (cd "$dir" && npm publish "$tarball" --tag "$tag" --provenance) + echo "::endgroup::" + fi + done + + - name: Summary + if: always() + env: + SOURCE_REF: ${{ inputs.source_ref }} + DRY_RUN: ${{ inputs.dry_run }} + PACKAGES: ${{ inputs.packages }} + run: | + { + echo "### Legacy Release" + echo "" + echo "- source_ref: \`$SOURCE_REF\`" + echo "- dry_run: \`$DRY_RUN\`" + echo "" + echo "#### Packages" + echo '```json' + echo "$PACKAGES" | jq . + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + # We're running the CI workflow (where node v20 modules are cached) in # merge_group and not on main, we need to explicitly cache node_modules here so # that follow-on branches can use the cached version of node_modules rather diff --git a/scripts/legacy-release.sh b/scripts/legacy-release.sh new file mode 100755 index 00000000000..cbe49e5c558 --- /dev/null +++ b/scripts/legacy-release.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Dispatch the Release workflow's legacy-release job for a single package. +# +# Usage: +# scripts/legacy-release.sh [--publish] +# +# Defaults to dry-run. Pass --publish to actually publish. +# Dist-tag is derived from convention: latest--v. +# +# Examples: +# scripts/legacy-release.sh @clerk/nextjs 5.7.6 +# scripts/legacy-release.sh @clerk/nextjs 5.7.6 --publish + +set -euo pipefail + +PKG="${1:-}" +VERSION="${2:-}" +MODE="${3:-}" + +if [[ -z "$PKG" || -z "$VERSION" ]]; then + echo "Usage: $0 [--publish]" >&2 + exit 1 +fi + +if [[ $# -gt 3 ]]; then + echo "Unexpected arguments: ${*:4}" >&2 + exit 1 +fi + +if [[ ! "$PKG" =~ ^@clerk/[a-z0-9][a-z0-9-]*$ ]]; then + echo "Package must be in the form @clerk/, got: $PKG" >&2 + exit 1 +fi + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must be semver .., got: $VERSION" >&2 + exit 1 +fi + +DRY_RUN=true +if [[ "$MODE" == "--publish" ]]; then + DRY_RUN=false +elif [[ -n "$MODE" ]]; then + echo "Unknown mode: $MODE (expected --publish or nothing)" >&2 + exit 1 +fi + +SHORT="${PKG#@clerk/}" +MAJOR="${VERSION%%.*}" +DIST_TAG="latest-${SHORT}-v${MAJOR}" + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI not found. Install: https://cli.github.com/" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq not found. Install: brew install jq" >&2 + exit 1 +fi + +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$BRANCH" == "HEAD" ]]; then + echo "Detached HEAD. Checkout a branch first." >&2 + exit 1 +fi + +if ! git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "Branch '$BRANCH' not pushed to origin. Push first." >&2 + exit 1 +fi + +LOCAL_SHA=$(git rev-parse HEAD) +REMOTE_SHA=$(git rev-parse "origin/$BRANCH") +if [[ "$LOCAL_SHA" != "$REMOTE_SHA" ]]; then + echo "Local '$BRANCH' is out of sync with origin. Push first." >&2 + exit 1 +fi + +PKG_JSON="./packages/$SHORT/package.json" +if [[ ! -f "$PKG_JSON" ]]; then + echo "No package.json at $PKG_JSON." >&2 + exit 1 +fi + +PKG_VERSION=$(jq -r .version "$PKG_JSON") +if [[ "$PKG_VERSION" != "$VERSION" ]]; then + echo "$PKG_JSON has version $PKG_VERSION, expected $VERSION." >&2 + exit 1 +fi + +PACKAGES=$(jq -c -n --arg n "$PKG" --arg v "$VERSION" --arg t "$DIST_TAG" \ + '[{name:$n, version:$v, dist_tag:$t}]') + +cat < Date: Wed, 15 Apr 2026 01:30:21 +0300 Subject: [PATCH 2/8] ci(repo): validate package names in dispatched release --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f31547334f..bebfce9e81f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -560,13 +560,18 @@ jobs: exit 1 fi - - name: Reject "latest" dist_tag + - name: Validate packages env: PACKAGES: ${{ inputs.packages }} run: | echo "$PACKAGES" | jq -e 'all(.[]; .dist_tag != "latest")' > /dev/null || { echo "::error::'latest' dist_tag is not allowed on this path"; exit 1; } + invalid=$(echo "$PACKAGES" | jq -r '.[] | select(.name | test("^@clerk/[a-z0-9][a-z0-9-]*$") | not) | .name') + if [ -n "$invalid" ]; then + echo "::error::Invalid package name(s). Expected @clerk/. Got: $invalid" + exit 1 + fi - name: Checkout source_ref uses: actions/checkout@v4 From 06477c6fc1cb72311480f3210feb600070b1fcd1 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 02:09:48 +0300 Subject: [PATCH 3/8] ci(repo): relax engine check on dispatched install --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bebfce9e81f..317d6432eb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -598,11 +598,13 @@ jobs: fi - name: Install dependencies + env: + NPM_CONFIG_ENGINE_STRICT: "false" run: | if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then pnpm install --frozen-lockfile else - npm ci + npm ci --audit=false --fund=false fi - name: Build From 2c6c965de3b6a1b2353c5a9d861d28528600ed3d Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 02:13:04 +0300 Subject: [PATCH 4/8] ci(repo): use npm install for dispatched legacy releases --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 317d6432eb8..88b606f33f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -604,7 +604,7 @@ jobs: if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then pnpm install --frozen-lockfile else - npm ci --audit=false --fund=false + npm install --audit=false --fund=false --no-save fi - name: Build From 1f50cf15b62407186a0a2fc032c50692e3b2baaf Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 11:21:27 +0300 Subject: [PATCH 5/8] ci(repo): pin npm to source_ref packageManager for ci install --- .github/workflows/release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88b606f33f2..b3e2669989e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -604,7 +604,13 @@ jobs: if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then pnpm install --frozen-lockfile else - npm install --audit=false --fund=false --no-save + # Pin npm to the version declared in the source_ref's packageManager + # so `npm ci` matches the lockfile's origin exactly. + pm=$(jq -r '.packageManager // ""' package.json) + if [[ "$pm" =~ ^npm@([0-9]+\.[0-9]+\.[0-9]+) ]]; then + npm install -g "npm@${BASH_REMATCH[1]}" + fi + npm ci --audit=false --fund=false fi - name: Build From 7eb76a24df3c8e7d65dc8945af72447196132a2c Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 11:24:07 +0300 Subject: [PATCH 6/8] ci(repo): document npm ci lockfile requirement on dispatched release --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3e2669989e..152f6828944 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -612,6 +612,9 @@ jobs: fi npm ci --audit=false --fund=false fi + # NOTE: npm ci requires the fix branch's package-lock.json to match its package.json. + # If porting a fix to an old ref whose lockfile is inconsistent, run `npm install` + # locally first and commit the refreshed lockfile alongside the fix. - name: Build run: | From c42c0ab6282874359f866100eef1582668852448 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 11:59:51 +0300 Subject: [PATCH 7/8] ci(repo): fall back to npm install if npm ci rejects lockfile --- .github/workflows/release.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152f6828944..adcb8a49153 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -610,11 +610,12 @@ jobs: if [[ "$pm" =~ ^npm@([0-9]+\.[0-9]+\.[0-9]+) ]]; then npm install -g "npm@${BASH_REMATCH[1]}" fi - npm ci --audit=false --fund=false + if ! npm ci --audit=false --fund=false; then + echo "::warning::npm ci failed - lockfile inconsistent with package.json at this ref." + echo "::warning::Falling back to npm install. For reproducibility, refresh the lockfile in your fix branch." + npm install --audit=false --fund=false --no-save + fi fi - # NOTE: npm ci requires the fix branch's package-lock.json to match its package.json. - # If porting a fix to an old ref whose lockfile is inconsistent, run `npm install` - # locally first and commit the refreshed lockfile alongside the fix. - name: Build run: | From d550fecce09b39283c686263482c52e22d9a8d2a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 15 Apr 2026 21:05:29 +0300 Subject: [PATCH 8/8] ci(repo): derive dist-tag from package name and version in dispatched release --- .github/workflows/release.yml | 27 ++++++++++++++++++++------- scripts/legacy-release.sh | 16 ++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adcb8a49153..f460c238b6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ on: type: string required: true packages: - description: 'JSON array, e.g. [{"name":"@clerk/nextjs","version":"5.7.6","dist_tag":"latest-nextjs-v5"}]' + description: 'JSON array, e.g. [{"name":"@clerk/nextjs","version":"5.7.6"}]. dist-tag is derived from convention latest--v.' type: string required: true dry_run: @@ -564,12 +564,18 @@ jobs: env: PACKAGES: ${{ inputs.packages }} run: | - echo "$PACKAGES" | jq -e 'all(.[]; .dist_tag != "latest")' > /dev/null || { - echo "::error::'latest' dist_tag is not allowed on this path"; exit 1; + echo "$PACKAGES" | jq -e 'type == "array" and length > 0 and all(.[]; type == "object" and (.name | type == "string") and (.version | type == "string"))' > /dev/null || { + echo "::error::packages must be a non-empty JSON array of {name, version} objects" + exit 1 } - invalid=$(echo "$PACKAGES" | jq -r '.[] | select(.name | test("^@clerk/[a-z0-9][a-z0-9-]*$") | not) | .name') - if [ -n "$invalid" ]; then - echo "::error::Invalid package name(s). Expected @clerk/. Got: $invalid" + invalid_names=$(echo "$PACKAGES" | jq -r '.[] | select(.name | test("^@clerk/[a-z0-9][a-z0-9-]*$") | not) | .name') + if [ -n "$invalid_names" ]; then + echo "::error::Invalid package name(s). Expected @clerk/. Got: $invalid_names" + exit 1 + fi + invalid_versions=$(echo "$PACKAGES" | jq -r '.[] | select(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | .version') + if [ -n "$invalid_versions" ]; then + echo "::error::Invalid version(s). Expected semver ... Got: $invalid_versions" exit 1 fi @@ -635,10 +641,17 @@ jobs: DRY_RUN: ${{ inputs.dry_run }} PACK: ${{ steps.pm.outputs.manager }} run: | - echo "$PACKAGES" | jq -r '.[] | [.name, .version, .dist_tag] | @tsv' | while IFS=$'\t' read -r name version tag; do + echo "$PACKAGES" | jq -r '.[] | [.name, .version] | @tsv' | while IFS=$'\t' read -r name version; do short="${name#@clerk/}" + major="${version%%.*}" + tag="latest-${short}-v${major}" dir="packages/$short" + if [ "$tag" = "latest" ]; then + echo "::error::refuse to publish under 'latest' dist-tag (derived from $name@$version)" + exit 1 + fi + if [ ! -d "$dir" ]; then echo "::error::Package directory not found: $dir" exit 1 diff --git a/scripts/legacy-release.sh b/scripts/legacy-release.sh index cbe49e5c558..22a79fe8c90 100755 --- a/scripts/legacy-release.sh +++ b/scripts/legacy-release.sh @@ -89,17 +89,17 @@ if [[ "$PKG_VERSION" != "$VERSION" ]]; then exit 1 fi -PACKAGES=$(jq -c -n --arg n "$PKG" --arg v "$VERSION" --arg t "$DIST_TAG" \ - '[{name:$n, version:$v, dist_tag:$t}]') +PACKAGES=$(jq -c -n --arg n "$PKG" --arg v "$VERSION" \ + '[{name:$n, version:$v}]') cat <