Skip to content
208 changes: 205 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -14,10 +16,24 @@
- 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 is derived from convention latest-<short>-v<major>.'
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:
Expand Down Expand Up @@ -508,6 +524,192 @@
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: Validate packages
env:
PACKAGES: ${{ inputs.packages }}
run: |
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_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/<kebab-case>. 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 <major>.<minor>.<patch>. Got: $invalid_versions"
exit 1
fi

Comment thread
nikosdouvlis marked this conversation as resolved.
- 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
env:
NPM_CONFIG_ENGINE_STRICT: "false"
run: |
if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then
pnpm install --frozen-lockfile
else
# 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
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

- name: Build

Check failure

Code scanning / CodeQL

Untrusted Checkout TOCTOU Critical

Insufficient protection against execution of untrusted code on a privileged workflow (
issue_comment
).
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment on lines +606 to +626
run: |
if [ "${{ steps.pm.outputs.manager }}" = "pnpm" ]; then
pnpm build
else
npm run build
fi

- name: Upgrade npm for trusted publishing

Check failure

Code scanning / CodeQL

Untrusted Checkout TOCTOU Critical

Insufficient protection against execution of untrusted code on a privileged workflow (
issue_comment
).
Comment on lines +626 to +634
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] | @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
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::"
Comment on lines +666 to +683
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error suppression in pnpm pack masks root cause failures.

The || true on line 651 suppresses pnpm pack errors. If pack fails legitimately (e.g., missing build artifacts), the workflow continues to line 661's check and fails with a generic "Failed to resolve tarball" message, obscuring the actual cause.

Suggested improvement
            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
+             if out=$(cd "$dir" && pnpm pack --json 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 "{}")
+               # Fallback: --json may not be supported in older pnpm
+               tarball=$(cd "$dir" && pnpm pack 2>&1 | tail -n1 | xargs -I{} basename "{}") || {
+                 echo "::error::pnpm pack failed in $dir"
+                 exit 1
+               }
              fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml around lines 649 - 666, The workflow is
suppressing pnpm pack errors via the `|| true` which hides failures; update the
`pnpm pack` invocation used when `PACK` is "pnpm" (the block that sets `out` and
`tarball`) to stop swallowing errors: run `pnpm pack --json` without `|| true`,
capture both stdout/stderr into a variable, check the command exit status and if
non-zero emit a clear `::error::` with the captured pnpm output and exit
non-zero so failures are visible (retain the existing fallback that parses
non-JSON output into `tarball` only when the pack succeeded).


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
Comment thread
nikosdouvlis marked this conversation as resolved.

- name: Summary

Check failure

Code scanning / CodeQL

Untrusted Checkout TOCTOU Critical

Insufficient protection against execution of untrusted code on a privileged workflow (
issue_comment
).
Comment on lines +637 to +694
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
Expand Down
120 changes: 120 additions & 0 deletions scripts/legacy-release.sh
Original file line number Diff line number Diff line change
@@ -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 <package> <version> [--publish]
#
# Defaults to dry-run. Pass --publish to actually publish.
# Dist-tag is derived from convention: latest-<short>-v<major>.
#
# 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 <package> <version> [--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/<name>, got: $PKG" >&2
exit 1
fi

if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Version must be semver <major>.<minor>.<patch>, 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" \
'[{name:$n, version:$v}]')

cat <<EOF
Dispatching Release workflow:
package: $PKG
version: $VERSION
dist-tag (derived): $DIST_TAG
branch: $BRANCH
source_ref: $LOCAL_SHA
dry_run: $DRY_RUN

EOF

read -r -p "Continue? [y/N] " yn
case "$yn" in
y|Y) ;;
*) echo "Aborted."; exit 0 ;;
esac

gh workflow run release.yml \
--ref main \
-f source_ref="$LOCAL_SHA" \
-f packages="$PACKAGES" \
-f dry_run="$DRY_RUN"

echo ""
echo "Dispatched. Approve at:"
echo " https://github.com/clerk/javascript/actions/workflows/release.yml"
Loading