diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e802db8b7d..f460c238b6c 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 is derived from convention latest--v.' + 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,192 @@ 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: 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/. 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 + + - 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 + 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] | @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::" + + 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..22a79fe8c90 --- /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" \ + '[{name:$n, version:$v}]') + +cat <