diff --git a/.github/workflows/recover-release.yml b/.github/workflows/recover-release.yml new file mode 100644 index 0000000..12fd011 --- /dev/null +++ b/.github/workflows/recover-release.yml @@ -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" diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs index 1058bf7..bcc4bf7 100644 --- a/scripts/release/publish.mjs +++ b/scripts/release/publish.mjs @@ -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 }); @@ -85,8 +155,12 @@ 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) { @@ -94,6 +168,10 @@ async function main() { 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 }); @@ -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;