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
59 changes: 44 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,19 @@ jobs:

# Changesets action:
# - If changesets are pending → creates/updates "Version Packages" PR
# - If this IS the version commit (no pending changesets) → publishes to npm,
# then tags the release commit and creates the GitHub release.
# - If none are pending → runs `release:publish` (the publish path)
#
# `release:publish` is `changeset publish`, not `pnpm publish`, and that choice
# is load-bearing here:
# - it queries the registry per package and publishes only versions not
# already there, so a master push with nothing to release is a clean no-op
# instead of `pnpm publish` erroring on "cannot publish over 3.0.1";
# - it prints the `New tag:` lines the action greps to learn what shipped, so
# the action tags the actual release commit and cuts the GitHub release —
# `pnpm publish` prints neither, which is why v3.0.0 had to be tagged by hand.
# Provenance is switched on in .npmrc (`provenance=true`), not here: `changeset
# publish` delegates to pnpm 11's native publish, which reads .npmrc but ignores
# the NPM_CONFIG_* env prefix (its own is PNPM_CONFIG_*) — 3.0.2 shipped without
# an attestation because the env var never reached it. id-token: write, granted
# above, is what lets the OIDC-authenticated publish sign the attestation.
# `release:publish` is `scripts/publish.ts`, which publishes with
# `pnpm publish --provenance`. The explicit flag is the whole point: it is the
# only thing that makes pnpm sign the SLSA provenance attestation. Given an
# id-token, pnpm's native publish otherwise takes the OIDC trusted-publishing
# path and produces no attestation, ignoring both `.npmrc provenance=true` and
# NPM_CONFIG_PROVENANCE — which is why 3.0.2 and 3.0.3 shipped unsigned. The
# script first checks the registry and no-ops if the version is already out, so a
# master push with nothing to release doesn't fail on "cannot publish over".
#
# `pnpm publish` prints no "New tag:" line for the action to parse, so it creates
# no tag or release; the step below does that instead.
- name: 📦 Create Release PR or Publish
id: changesets
uses: changesets/action@v1
Expand All @@ -78,3 +75,35 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

# changesets/action tags and releases only packages it detects as published, and
# it detects them from the "New tag:" lines that `pnpm publish` does not print —
# so on the publish path it leaves no tag behind. This fills that gap.
#
# It reads the version from the *committed* HEAD, not the working tree: the
# action's `version` step rewrites package.json in place on the version-PR path,
# so the working tree always looks like the next release. Only the commit that
# merged the version PR actually carries the bumped version, so:
# - HEAD still holds the current version → its tag exists → nothing to do.
# - HEAD holds a new, untagged version → the version PR merged and published,
# so tag that commit. The tag check keeps re-runs idempotent.
- name: 🏷️ Tag and release the published version
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version="$(git show HEAD:package.json | jq -r .version)"
tag="v${version}"

if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "Tag ${tag} already exists — nothing to do (version-PR path or re-run)."
exit 0
fi

awk -v h="## ${version}" '$0==h{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > /tmp/release-notes.md
if [ -s /tmp/release-notes.md ]; then
gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --notes-file /tmp/release-notes.md
else
echo "No CHANGELOG section for ${version}; falling back to generated notes."
gh release create "${tag}" --target "${GITHUB_SHA}" --title "${tag}" --generate-notes
fi
4 changes: 0 additions & 4 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
registry=https://registry.npmjs.org/
# Provenance for the release publish. pnpm 11's native publish reads this from
# .npmrc; it does NOT honour the NPM_CONFIG_PROVENANCE env var (that prefix is npm's,
# pnpm's is PNPM_CONFIG_*), which is why 3.0.2 shipped without an attestation.
provenance=true
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
"changeset:version": "changeset version",
"changeset:status": "changeset status",
"version:dev": "node scripts/devVersion.ts",
"release:publish": "changeset publish",
"release:publish": "node scripts/publish.ts",
"prepare": "husky",
"audit:schemas": "node scripts/schemaAudit.ts"
},
Expand Down
37 changes: 37 additions & 0 deletions scripts/publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Publishes the package — but only if this version is not already on the registry.
*
* changesets/action runs this on every master push with no pending changesets (the publish path). When the version PR
* has just merged, that is a new version to publish; on any other such push the version is already out and a plain
* `pnpm publish` would fail with "cannot publish over the previously published version". Checking the registry first
* turns that case into a clean no-op — the way `changeset publish` does — while keeping `pnpm publish --provenance`.
*
* That command is the point: it is the only one that actually produces the SLSA provenance attestation. pnpm's native
* publish takes the OIDC trusted-publishing path when id-token is available and, on that path, ignores the provenance
* config in `.npmrc` and the NPM_CONFIG_PROVENANCE env var alike — 3.0.2 and 3.0.3 shipped unsigned for exactly that
* reason. Only the explicit `--provenance` flag makes it sign, as 3.0.1 did.
*
* Runs on bare `node` — keep the types erasable, as in scripts/schemaAudit.ts.
*/
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name: string; version: string };

// `npm view pkg@version version` prints the version and exits 0 when it exists, and errors (E404) when it does not.
// A published version is the "nothing to do" case; anything else means this is a new version to ship.
const lookup = spawnSync('npm', ['view', `${pkg.name}@${pkg.version}`, 'version'], { cwd: root, encoding: 'utf8' });

if (lookup.status === 0 && lookup.stdout.trim() !== '') {
console.log(`${pkg.name}@${pkg.version} is already published — nothing to do.`);
process.exit(0);
}

console.log(`Publishing ${pkg.name}@${pkg.version} with provenance…`);

const published = spawnSync('pnpm', ['publish', '--no-git-checks', '--provenance'], { cwd: root, stdio: 'inherit' });

process.exit(published.status ?? 1);