Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/clean-deer-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
117 changes: 117 additions & 0 deletions .github/workflows/release-recovery.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- 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');
}
125 changes: 42 additions & 83 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
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"
Expand All @@ -49,9 +54,33 @@
# 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
Comment thread
jacekradko marked this conversation as resolved.

- name: Setup
id: config
uses: ./.github/actions/init
Expand Down Expand Up @@ -97,7 +126,7 @@
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 } },
Expand All @@ -117,87 +146,6 @@
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'
Expand Down Expand Up @@ -233,7 +181,18 @@
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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {}
name: Canary release
if: ${{ github.event_name == 'push' && github.repository == 'clerk/javascript' }}
runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }}
Expand Down
Loading