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
14 changes: 4 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -37,17 +38,10 @@ 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.
version: npm run changeset:version
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 }}
37 changes: 0 additions & 37 deletions scripts/github-release.mjs

This file was deleted.

78 changes: 65 additions & 13 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
Loading