diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1878c17..f0ba86c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,10 @@ name: Release # - pending changesets on main -> opens/updates the "Version Packages" PR # (bumps package.json, rewrites CHANGELOG.md, syncs src/version.ts) # - no pending changesets (a Version Packages PR was just merged) -# -> builds, publishes to npm with provenance (OIDC trusted publishing, -# no stored token), tags vX.Y.Z, creates the GitHub release from the -# CHANGELOG.md section. +# -> node ./scripts/release.mjs: builds, publishes to npm with provenance +# (OIDC trusted publishing, no stored token), tags vX.Y.Z and creates the +# GitHub release from the CHANGELOG.md section — idempotently, so retries +# and stranded announcements heal on the next run. on: push: branches: [main] @@ -37,7 +38,6 @@ jobs: - run: npm ci - uses: changesets/action@v1 - id: changesets with: # Single command: the action whitespace-splits this input and spawns # it as args (no shell), so chaining belongs in the package script. @@ -45,9 +45,3 @@ jobs: publish: node ./scripts/release.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Tag and create GitHub release - if: steps.changesets.outputs.published == 'true' - run: node ./scripts/github-release.mjs - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs deleted file mode 100644 index c43f61d..0000000 --- a/scripts/github-release.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { execSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -/** - * Release-announcement step of the Release workflow. Runs after a successful - * npm publish and: - * - * 1. tags the release commit as vX.Y.Z (repo convention matches the existing - * v0.0.2 tag) and pushes the tag; - * 2. extracts this version's section from CHANGELOG.md and creates the GitHub - * release with it as the body — release notes and changelog share one - * source of truth. - */ -const { version } = JSON.parse(readFileSync('package.json', 'utf8')); -const tag = `v${version}`; - -execSync(`git tag ${tag}`, { stdio: 'inherit' }); -execSync(`git push origin ${tag}`, { stdio: 'inherit' }); - -const changelog = readFileSync('CHANGELOG.md', 'utf8'); -const section = changelog - .split(/^## /m) - .find((s) => s.startsWith(`${version} `) || s.startsWith(`v${version} `)); - -if (!section) { - throw new Error(`No CHANGELOG.md section found for ${version}`); -} - -const notesFile = join(mkdtempSync(join(tmpdir(), 'release-notes-')), 'notes.md'); -writeFileSync(notesFile, `${section.trimEnd()}\n`); - -execSync(`gh release create ${tag} --title "${tag}" --notes-file "${notesFile}"`, { - stdio: 'inherit', -}); -console.log(`Created GitHub release ${tag}`); diff --git a/scripts/release.mjs b/scripts/release.mjs index 4951ece..b3a5438 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,35 +1,87 @@ import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; /** - * Publish step of the Release workflow (changesets action's `publish` input). + * Publish + announcement step of the Release workflow (changesets action's + * `publish` input). Only runs on the publish path — pushes to main with + * pending changesets never reach this script. * * - Builds dist/ fresh before publishing. - * - Unsets GITHUB_TOKEN from the environment: with it present, npm refuses to - * fall through to OIDC trusted publishing. - * - Publishes with --provenance (requires npm >= 11.5 and the Trusted Publisher + * - Unsets GITHUB_TOKEN from the npm environment: with it present, npm refuses + * to fall through to OIDC trusted publishing. + * - Publishes with --provenance (npm >= 11.5 and the Trusted Publisher * registered on npmjs.com for this repo + workflow). - * - Idempotent: skips if the exact version is already on the registry (safe on - * workflow retries). + * - Retries are safe: publish is skipped if the exact version is already + * registered. + * - Announces idempotently: tags vX.Y.Z at HEAD and creates the GitHub release + * from this version's CHANGELOG.md section, skipping whichever already + * exists. Runs on every publish-path run, so a stranded announcement heals + * on the next run — necessary because the registry's post-upload validation + * window can keep a freshly accepted version invisible to registry checks. */ const { name, version } = JSON.parse(readFileSync('package.json', 'utf8')); +const tag = `v${version}`; const env = { ...process.env }; delete env.GITHUB_TOKEN; execSync('npm run build', { stdio: 'inherit' }); -let exists = false; +let registered = false; try { execSync(`npm view ${name}@${version} version`, { env, stdio: 'ignore' }); - exists = true; + registered = true; } catch { - exists = false; + registered = false; } -if (exists) { - console.log(`${name}@${version} is already published — nothing to do.`); +if (registered) { + console.log(`${name}@${version} is already registered — skipping publish.`); } else { - execSync(`npm publish --provenance --access public`, { env, stdio: 'inherit' }); + execSync('npm publish --provenance --access public', { env, stdio: 'inherit' }); console.log(`Published ${name}@${version}`); } + +const section = readFileSync('CHANGELOG.md', 'utf8') + .split(/^## /m) + .find((s) => { + const heading = s.split('\n', 1)[0].trim(); + return heading === version || heading === `v${version}`; + }); + +if (!section) { + if (registered) { + console.warn(`No CHANGELOG.md section for ${version} — skipping announcement.`); + process.exit(0); + } + throw new Error(`No CHANGELOG.md section found for ${version}`); +} + +const tagRemote = execSync(`git ls-remote origin refs/tags/${tag}`, { encoding: 'utf8' }); +if (tagRemote.includes(`refs/tags/${tag}`)) { + console.log(`Tag ${tag} already exists — skipping.`); +} else { + execSync(`git tag ${tag} && git push origin ${tag}`, { stdio: 'inherit' }); + console.log(`Tagged ${tag}`); +} + +let releaseExists = false; +try { + execSync(`gh release view ${tag}`, { stdio: 'ignore' }); + releaseExists = true; +} catch { + releaseExists = false; +} + +if (releaseExists) { + console.log(`GitHub release ${tag} already exists — skipping.`); +} else { + const notesFile = join(mkdtempSync(join(tmpdir(), 'release-notes-')), 'notes.md'); + writeFileSync(notesFile, `${section.trimEnd()}\n`); + execSync(`gh release create ${tag} --title ${tag} --notes-file ${notesFile}`, { + stdio: 'inherit', + }); + console.log(`Created GitHub release ${tag}`); +}