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..c339144dbe9 --- /dev/null +++ b/.github/workflows/release-recovery.yml @@ -0,0 +1,117 @@ +name: Release Recovery (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. +# +# 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 }} + 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: + # Always read versions from main, regardless of which ref a + # manual workflow_dispatch was triggered on. + ref: main + 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..28d274cd10c 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" @@ -49,9 +54,33 @@ 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). 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::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 + - name: Setup id: config uses: ./.github/actions/init @@ -97,7 +126,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 } }, @@ -117,87 +146,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' @@ -233,6 +181,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' }}