Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/recover-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Recover Release

on:
workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to recover, for example v0.10.4'
required: true
type: string

jobs:
recover:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'

- name: Verify recovered tag matches package version
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
node -e "const pkg = require('./package.json'); const expected = 'v' + pkg.version; if (process.env.RELEASE_TAG !== expected) { throw new Error('tag ' + process.env.RELEASE_TAG + ' does not match package version ' + expected); }"

- run: pnpm install --frozen-lockfile

- name: Publish missing registry artifacts
env:
SKIP_NPM_PUBLISH: '1'
run: pnpm release:publish

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
gh release view "$RELEASE_TAG" >/dev/null 2>&1 || \
gh release create "$RELEASE_TAG" --verify-tag --generate-notes --title "$RELEASE_TAG"
92 changes: 91 additions & 1 deletion scripts/release/publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,76 @@ function run(cmd, args, options = {}) {
}
}

function runForOutput(cmd, args, options = {}) {
const result = spawnSync(cmd, args, {
encoding: 'utf8',
...options,
});

return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
error: result.error,
};
}

function isEnabled(name) {
const value = process.env[name];
if (!value) {
return false;
}

return ['1', 'true', 'yes'].includes(value.toLowerCase());
}

function packageExistsOnNpm(pkg) {
const result = runForOutput('npm', ['view', `${pkg.name}@${pkg.version}`, 'version', '--json']);

if (result.status === 0) {
return result.stdout.includes(pkg.version);
}

const output = `${result.stdout}\n${result.stderr}`;
if (output.includes('E404') || output.includes('404 Not Found')) {
return false;
}

if (result.error) {
throw result.error;
}

throw new Error(`Failed to inspect npm package ${pkg.name}@${pkg.version}: ${output}`);
}

function parseJsrPackageName(packageName) {
const match = /^@([^/]+)\/(.+)$/.exec(packageName);
if (!match) {
throw new Error(`Invalid JSR package name: ${packageName}`);
}

return {
scope: match[1],
name: match[2],
};
}

async function packageExistsOnJsr(pkg) {
const { scope, name } = parseJsrPackageName(pkg.name);
const response = await fetch(`https://jsr.io/@${scope}/${name}/meta.json`);

if (response.status === 404) {
return false;
}

if (!response.ok) {
throw new Error(`Failed to inspect JSR package ${pkg.name}: HTTP ${response.status}`);
}

const meta = await response.json();
return Object.prototype.hasOwnProperty.call(meta.versions ?? {}, pkg.version);
}

function runAsync(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: 'inherit', ...options });
Expand Down Expand Up @@ -85,15 +155,23 @@ async function main() {

const npmToken = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN;
const useOidc = process.env.GITHUB_ACTIONS === 'true';
const skipNpmPublish = isEnabled('SKIP_NPM_PUBLISH');
const skipJsrPublish = isEnabled('SKIP_JSR_PUBLISH');

if (!useOidc && !npmToken) {
if (skipNpmPublish) {
console.log('Skipping npm publish (SKIP_NPM_PUBLISH is set).');
} else if (!useOidc && !npmToken) {
console.log('Skipping npm publish (no token and not running in GitHub Actions).');
} else {
for (const dir of packageDirs) {
const pkg = readJson(path.join(dir, 'package.json'));
if (pkg.private) {
continue;
}
if (packageExistsOnNpm(pkg)) {
console.log(`Skipping npm publish for ${pkg.name}@${pkg.version} (already published).`);
continue;
}

if (useOidc) {
run('npm', ['publish', '--access', 'public', '--provenance'], { cwd: dir });
Expand All @@ -104,11 +182,23 @@ async function main() {
}
}

if (skipJsrPublish) {
console.log('Skipping JSR publish (SKIP_JSR_PUBLISH is set).');
console.log('Publish complete.');
return;
}

for (const dir of packageDirs) {
const jsrPath = path.join(dir, 'jsr.json');
if (!fs.existsSync(jsrPath)) {
continue;
}
const pkg = readJson(path.join(dir, 'package.json'));
if (await packageExistsOnJsr(pkg)) {
console.log(`Skipping JSR publish for ${pkg.name}@${pkg.version} (already published).`);
continue;
}

const timeoutMs = Number(process.env.JSR_PUBLISH_TIMEOUT_MS ?? 300_000);
const maxRetries = Number(process.env.JSR_PUBLISH_RETRIES ?? 2);
let attempt = 0;
Expand Down
Loading