Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@
---
---
114 changes: 114 additions & 0 deletions .github/workflows/release-recovery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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:
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');
}
114 changes: 32 additions & 82 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,24 @@
# 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
Comment thread
jacekradko marked this conversation as resolved.

- name: Setup
id: config
uses: ./.github/actions/init
Expand Down Expand Up @@ -117,87 +137,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 +172,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