From f6bfe2bd6254e1822be44b3771b109170bab2f8f Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 18 May 2026 15:16:46 -0500 Subject: [PATCH 1/4] ci(repo): guard release workflow against stale-SHA runs --- .changeset/clean-deer-march.md | 2 + .github/workflows/release-recovery.yml | 110 +++++++++++++++++++++++++ .github/workflows/release.yml | 17 +++- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 .changeset/clean-deer-march.md create mode 100644 .github/workflows/release-recovery.yml diff --git a/.changeset/clean-deer-march.md b/.changeset/clean-deer-march.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/clean-deer-march.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml new file mode 100644 index 00000000000..18885981351 --- /dev/null +++ b/.github/workflows/release-recovery.yml @@ -0,0 +1,110 @@ +name: Release Recovery (manual) + +# Manual recovery handle for downstream notifications. Use when a release +# run published to npm but failed to dispatch one or more downstream +# workflows (sdk-infra-workers, dashboard, clerk-docs). Reads versions +# from main's package.json, verifies what is actually on npm, then +# re-dispatches the same workflows that the production release job +# would have dispatched. +# +# Trigger via: gh workflow run release-recovery.yml -R clerk/javascript +# Or from the Actions UI ("Run workflow"). + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + recover: + name: Recover downstream notifications + if: ${{ github.repository == 'clerk/javascript' }} + runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }} + timeout-minutes: 5 + permissions: + contents: read + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 1 + show-progress: false + + - name: Dispatch downstream workflows + uses: actions/github-script@v7 + with: + result-encoding: string + retries: 3 + retry-exempt-status-codes: 400,401 + github-token: ${{ secrets.CLERK_COOKIE_PAT }} + script: | + const { execSync } = require('child_process'); + + const clerkjsVersion = require('./packages/clerk-js/package.json').version; + const clerkUiVersion = require('./packages/ui/package.json').version; + + // Only recover stable releases + const preReleases = [ + clerkjsVersion.includes('-') && `@clerk/clerk-js@${clerkjsVersion}`, + clerkUiVersion.includes('-') && `@clerk/ui@${clerkUiVersion}`, + ].filter(Boolean); + if (preReleases.length > 0) { + console.log(`Skipping recovery: ${preReleases.join(', ')} is a pre-release`); + return; + } + + const preMode = require("fs").existsSync("./.changeset/pre.json"); + if (preMode) { + core.warning("Changeset in pre-mode, skipping recovery dispatch"); + return; + } + + // Check if either version was actually published to npm + function isPublished(name, version) { + try { + return execSync(`npm view ${name}@${version} version`, { encoding: 'utf8' }).trim() === version; + } catch (e) { + console.log(`npm view ${name}@${version} failed: ${e.message}`); + return false; + } + } + + const clerkjsPublished = isPublished('@clerk/clerk-js', clerkjsVersion); + const clerkUiPublished = isPublished('@clerk/ui', clerkUiVersion); + + if (!clerkjsPublished && !clerkUiPublished) { + console.log('Neither @clerk/clerk-js nor @clerk/ui were published to npm, no recovery needed'); + return; + } + + const published = [ + clerkjsPublished && `@clerk/clerk-js@${clerkjsVersion}`, + clerkUiPublished && `@clerk/ui@${clerkUiVersion}`, + ].filter(Boolean).join(', '); + core.warning(`Recovery: ${published} published to npm but downstream repos were not notified. Dispatching now.`); + + const nextjsVersion = require('./packages/nextjs/package.json').version; + + // NOTE: Keep in sync with the `targets` array in release.yml's + // "Trigger workflows on related repos" and "Recover downstream + // notifications" steps. + const targets = [ + { repo: 'sdk-infra-workers', workflow_id: 'update-pkg-versions.yml', inputs: { clerkjsVersion, clerkUiVersion } }, + { repo: 'dashboard', workflow_id: 'prepare-nextjs-sdk-update.yml', inputs: { version: nextjsVersion } }, + { repo: 'clerk-docs', workflow_id: 'typedoc.yml' }, + ]; + const results = await Promise.allSettled( + targets.map(t => github.rest.actions.createWorkflowDispatch({ owner: 'clerk', ref: 'main', ...t })) + ); + const failures = results + .map((r, i) => r.status === 'rejected' ? { target: targets[i], reason: r.reason } : null) + .filter(Boolean); + if (failures.length) { + failures.forEach(f => core.error(`Recovery dispatch to ${f.target.repo}/${f.target.workflow_id} failed: ${f.reason?.message ?? f.reason}`)); + core.setFailed(`${failures.length} recovery dispatch(es) failed`); + } else { + core.notice('Recovery dispatch completed successfully'); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc19631b937..2a1d214ac8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,9 +49,24 @@ jobs: # Use the PAT so pushes from `changesets/action` are authored by # clerk-cookie. With the default GITHUB_TOKEN, GitHub suppresses # the resulting `synchronize` events and CI never runs on Release - # PR updates — forcing a manual close/reopen to re-trigger. + # PR updates, forcing a manual close/reopen to re-trigger. token: ${{ secrets.CLERK_COOKIE_PAT }} + # Guard against races where this run is on a SHA that has been + # superseded by a newer commit on main (e.g. two Version-packages + # PRs merging back-to-back, or a manual re-run of an older release + # after main has moved). Bail before build/publish so we don't + # waste CI or trigger spurious failure alerts. + - name: Ensure HEAD is origin/main + run: | + git fetch origin main --quiet + local_sha=$(git rev-parse HEAD) + remote_sha=$(git rev-parse origin/main) + if [ "$local_sha" != "$remote_sha" ]; then + echo "::notice::Skipping release: HEAD ($local_sha) has been superseded by origin/main ($remote_sha). The newer push will handle this release." + exit 1 + fi + - name: Setup id: config uses: ./.github/actions/init From 60c6a23c7ca5153d87c3d6ef5e452e184910131c Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 18 May 2026 15:23:27 -0500 Subject: [PATCH 2/4] ci(repo): extract release recovery to reusable workflow --- .github/workflows/release-recovery.yml | 10 ++- .github/workflows/release.yml | 97 +++++--------------------- 2 files changed, 23 insertions(+), 84 deletions(-) diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml index 18885981351..40e12da373f 100644 --- a/.github/workflows/release-recovery.yml +++ b/.github/workflows/release-recovery.yml @@ -1,17 +1,21 @@ name: Release Recovery (manual) -# Manual recovery handle for downstream notifications. Use when a release +# Recovery handle for downstream notifications. Use when a release # run published to npm but failed to dispatch one or more downstream # workflows (sdk-infra-workers, dashboard, clerk-docs). Reads versions # from main's package.json, verifies what is actually on npm, then # re-dispatches the same workflows that the production release job # would have dispatched. # -# Trigger via: gh workflow run release-recovery.yml -R clerk/javascript -# Or from the Actions UI ("Run workflow"). +# Invoked two ways: +# - Automatically as a dependent job from release.yml when the +# changesets step fails (workflow_call). +# - Manually via `gh workflow run release-recovery.yml -R clerk/javascript` +# or the Actions UI (workflow_dispatch). on: workflow_dispatch: + workflow_call: concurrency: group: ${{ github.workflow }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a1d214ac8e..235339dc09d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,11 @@ jobs: statuses: write checks: write + outputs: + # Exposed so the dependent `recover` job can gate on the same + # condition the old inline recovery step used. + changesets_conclusion: ${{ steps.changesets.conclusion }} + steps: - name: Echo github context run: echo "$GITHUB_CONTEXT" @@ -132,87 +137,6 @@ jobs: core.warning("Changeset in pre-mode should not prepare a ClerkJS production release") } - # Recovery: if the changesets action published to npm but then failed - # (e.g. git push --follow-tags error), the `published` output is never - # set and downstream repos are not notified. This step detects that - # scenario by checking npm for the local package version and dispatches - # if the packages are already live. - - name: Recover downstream notifications - if: always() && steps.changesets.conclusion == 'failure' - continue-on-error: true - uses: actions/github-script@v7 - with: - result-encoding: string - retries: 3 - retry-exempt-status-codes: 400,401 - github-token: ${{ secrets.CLERK_COOKIE_PAT }} - script: | - const { execSync } = require('child_process'); - - const clerkjsVersion = require('./packages/clerk-js/package.json').version; - const clerkUiVersion = require('./packages/ui/package.json').version; - - // Only recover stable releases - const preReleases = [ - clerkjsVersion.includes('-') && `@clerk/clerk-js@${clerkjsVersion}`, - clerkUiVersion.includes('-') && `@clerk/ui@${clerkUiVersion}`, - ].filter(Boolean); - if (preReleases.length > 0) { - console.log(`Skipping recovery: ${preReleases.join(', ')} is a pre-release`); - return; - } - - const preMode = require("fs").existsSync("./.changeset/pre.json"); - if (preMode) { - core.warning("Changeset in pre-mode, skipping recovery dispatch"); - return; - } - - // Check if either version was actually published to npm - function isPublished(name, version) { - try { - return execSync(`npm view ${name}@${version} version`, { encoding: 'utf8' }).trim() === version; - } catch (e) { - console.log(`npm view ${name}@${version} failed: ${e.message}`); - return false; - } - } - - const clerkjsPublished = isPublished('@clerk/clerk-js', clerkjsVersion); - const clerkUiPublished = isPublished('@clerk/ui', clerkUiVersion); - - if (!clerkjsPublished && !clerkUiPublished) { - console.log('Neither @clerk/clerk-js nor @clerk/ui were published to npm, no recovery needed'); - return; - } - - const published = [ - clerkjsPublished && `@clerk/clerk-js@${clerkjsVersion}`, - clerkUiPublished && `@clerk/ui@${clerkUiVersion}`, - ].filter(Boolean).join(', '); - core.warning(`Recovery: ${published} published to npm but downstream repos were not notified. Dispatching now.`); - - const nextjsVersion = require('./packages/nextjs/package.json').version; - - // NOTE: Keep in sync with the `targets` array in the "Trigger workflows on related repos" step above. - const targets = [ - { repo: 'sdk-infra-workers', workflow_id: 'update-pkg-versions.yml', inputs: { clerkjsVersion, clerkUiVersion } }, - { repo: 'dashboard', workflow_id: 'prepare-nextjs-sdk-update.yml', inputs: { version: nextjsVersion } }, - { repo: 'clerk-docs', workflow_id: 'typedoc.yml' }, - ]; - const results = await Promise.allSettled( - targets.map(t => github.rest.actions.createWorkflowDispatch({ owner: 'clerk', ref: 'main', ...t })) - ); - const failures = results - .map((r, i) => r.status === 'rejected' ? { target: targets[i], reason: r.reason } : null) - .filter(Boolean); - if (failures.length) { - failures.forEach(f => core.error(`Recovery dispatch to ${f.target.repo}/${f.target.workflow_id} failed: ${f.reason?.message ?? f.reason}`)); - core.setFailed(`${failures.length} recovery dispatch(es) failed`); - } else { - core.notice('Recovery dispatch completed successfully'); - } - - name: Generate notification payload id: notification if: steps.changesets.outputs.published == 'true' @@ -248,6 +172,17 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SDK_SLACKER_WEBHOOK_URL }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + # If the changesets step in `release` failed, re-fire the downstream + # dispatches via the shared reusable workflow. The workflow's own + # script is idempotent (checks npm before dispatching) and is also + # the manual entry point via workflow_dispatch. + recover: + name: Recover downstream notifications + needs: release + if: ${{ always() && needs.release.outputs.changesets_conclusion == 'failure' }} + uses: ./.github/workflows/release-recovery.yml + secrets: inherit + canary-release: name: Canary release if: ${{ github.event_name == 'push' && github.repository == 'clerk/javascript' }} From bb8ad8d7ca74d9f3106b47772a972085759a1fbf Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 18 May 2026 15:31:10 -0500 Subject: [PATCH 3/4] ci(repo): pin recovery checkout to main, fix stale comment --- .github/workflows/release-recovery.yml | 3 +++ .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml index 40e12da373f..c339144dbe9 100644 --- a/.github/workflows/release-recovery.yml +++ b/.github/workflows/release-recovery.yml @@ -34,6 +34,9 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: + # Always read versions from main, regardless of which ref a + # manual workflow_dispatch was triggered on. + ref: main fetch-depth: 1 show-progress: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 235339dc09d..0aa1d3e3079 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,7 +117,7 @@ jobs: const clerkUiVersion = require('./packages/ui/package.json').version; const nextjsVersion = require('./packages/nextjs/package.json').version; - // NOTE: Keep in sync with the `targets` array in the "Recover downstream notifications" step below. + // NOTE: Keep in sync with the `targets` array in `release-recovery.yml`. const targets = [ { repo: 'sdk-infra-workers', workflow_id: 'update-pkg-versions.yml', inputs: { clerkjsVersion, clerkUiVersion } }, { repo: 'dashboard', workflow_id: 'prepare-nextjs-sdk-update.yml', inputs: { version: nextjsVersion } }, From c9ccf7240a5dd1381675dad4fa745ac83e9de854 Mon Sep 17 00:00:00 2001 From: Jacek Date: Mon, 18 May 2026 15:33:53 -0500 Subject: [PATCH 4/4] ci(repo): cancel stale release run instead of failing it --- .github/workflows/release.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0aa1d3e3079..28d274cd10c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,15 +60,24 @@ jobs: # Guard against races where this run is on a SHA that has been # superseded by a newer commit on main (e.g. two Version-packages # PRs merging back-to-back, or a manual re-run of an older release - # after main has moved). Bail before build/publish so we don't - # waste CI or trigger spurious failure alerts. + # after main has moved). Cancel the run before build/publish so we + # don't waste CI or trigger spurious failure alerts. Cancelling + # (rather than failing) matches the status that + # `concurrency: cancel-in-progress` would produce. - name: Ensure HEAD is origin/main + env: + GH_TOKEN: ${{ secrets.CLERK_COOKIE_PAT }} run: | git fetch origin main --quiet local_sha=$(git rev-parse HEAD) remote_sha=$(git rev-parse origin/main) if [ "$local_sha" != "$remote_sha" ]; then - echo "::notice::Skipping release: HEAD ($local_sha) has been superseded by origin/main ($remote_sha). The newer push will handle this release." + echo "::notice::Cancelling release: HEAD ($local_sha) has been superseded by origin/main ($remote_sha). The newer push will handle this release." + gh run cancel ${{ github.run_id }} --repo ${{ github.repository }} + # Cancellation is async; sleep so the runner is interrupted + # before subsequent steps execute. exit 1 as fallback if + # cancellation does not take effect within the window. + sleep 30 exit 1 fi