diff --git a/.tangled/workflows/release-npm.yml b/.tangled/workflows/release-npm.yml new file mode 100644 index 0000000..d8e7f55 --- /dev/null +++ b/.tangled/workflows/release-npm.yml @@ -0,0 +1,120 @@ +# Publishes the npm packages on a version tag. The fixed group (core, node, +# deno, cloudflare, and the adapters) and the feature packages are published; +# @pdsjs/account-ui and @pdsjs/start are private and changeset publish skips +# them automatically. +# +# The release flow, end to end: +# 1. locally: `npm run release:cut` — applies changesets, commits, tags +# vX.Y.Z and pushes. (Or do those steps by hand.) +# 2. the tag fires this workflow (npm), release-image.yml (the container +# image) and release-worker.yml (the worker artifact on the site). +# +# A manual run builds and dry-runs the pack, publishing nothing — the same +# smoke check `pnpm -r publish --dry-run` gives locally, without a tag or a +# token. +# +# Repository secret (settings → secrets): +# NPM_TOKEN an npm automation token with publish rights for the @pdsjs scope + +when: + - event: ["push"] + tag: ["v*"] + - event: ["manual"] + +# Same engine and approach as ci.yml: NixOS gives a Node project nothing +# directly, so run inside the official node image proven to work in this repo. +# trixie (glibc 2.41) because better-sqlite3's prebuilt binary needs >= 2.38. +engine: microvm +image: nixos + +virtualisation: + docker: true + +steps: + - name: "Check publish credentials" + command: | + set -eo pipefail + + # Fail here rather than after the build. Guard expansions with + # ${VAR:-}; see release-image.yml for why not `set -u`. + if [ "${TANGLED_REF_TYPE:-}" != "tag" ]; then + echo "manual run — build and pack dry-run only, nothing will be published" + exit 0 + fi + + if [ -z "${NPM_TOKEN:-}" ]; then + echo "NPM_TOKEN unset, so this tag would publish nothing." + echo + echo "Add it in repository settings — an npm automation token with" + echo "publish rights for the @pdsjs scope." + exit 1 + fi + + - name: "Check the tag names the versioned state" + command: | + set -eo pipefail + + if [ "${TANGLED_REF_TYPE:-}" != "tag" ]; then + echo "manual run — skipping tag checks" + exit 0 + fi + + # The tag names the release; the tree says what was versioned. A + # mismatch means version-packages and the tag moved separately, and + # publishing would mislabel every package. + core=$(docker run --rm -v "$PWD":/workspace -w /workspace \ + node:22-trixie node -p 'require("./packages/core/package.json").version') + if [ "v$core" != "$TANGLED_REF_NAME" ]; then + echo "tag is $TANGLED_REF_NAME but @pdsjs/core is $core" + exit 1 + fi + + # A tag on a commit with unapplied changesets is a release cut without + # versioning; whatever they describe would be silently left out. + pending=$(ls .changeset/*.md 2>/dev/null | grep -cv README || true) + if [ "$pending" != "0" ]; then + echo "$pending changeset(s) still pending — run version-packages first" + exit 1 + fi + + - name: "Build and publish to npm" + command: | + set -eo pipefail + + docker run --rm \ + -v "$PWD":/workspace \ + -w /workspace \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e NPM_TOKEN="${NPM_TOKEN:-}" \ + -e TANGLED_REF_TYPE="${TANGLED_REF_TYPE:-}" \ + node:22-trixie \ + bash -c ' + set -eo pipefail + corepack enable + + echo "==> pnpm install" + pnpm install --frozen-lockfile + + echo "==> build (generates the .d.ts files publish ships)" + pnpm run build + + # Manual runs verify the build and dry-run the pack, then stop — no + # tag, and possibly no token. This is the CI smoke test for a release. + if [ "${TANGLED_REF_TYPE:-}" != "tag" ]; then + echo "==> manual run — dry-run only, nothing will be published" + pnpm -r publish --dry-run --no-git-checks + exit 0 + fi + + # Auth for the publish, and disable pnpm git checks: the tag is a + # detached HEAD, which pnpm publish otherwise refuses. --no-git-tag: + # the local tags changeset publish would mint evaporate with this VM + # and the knot refuses their names anyway. + { + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" + echo "git-checks=false" + } > "$HOME/.npmrc" + + echo "==> changeset publish" + pnpm exec changeset publish --no-git-tag + ' diff --git a/package.json b/package.json index 7631d12..fba992d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "changeset": "changeset", "version-packages": "node scripts/version-packages.js", "release": "npm run build && changeset publish", + "release:cut": "node scripts/cut-release.mjs", "build:ui": "npm run build --workspace=@pdsjs/account-ui", "build:worker": "node scripts/build-worker-bundle.mjs", "build:start": "npm run build --workspace=@pdsjs/start" diff --git a/scripts/cut-release.mjs b/scripts/cut-release.mjs new file mode 100644 index 0000000..e3bc583 --- /dev/null +++ b/scripts/cut-release.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +// Cut a release: apply the pending changesets, commit, tag and push. The tag +// fires the three release workflows — npm (release-npm.yml), the container +// image (release-image.yml) and the worker artifact (release-worker.yml) — +// so this command is the whole human side of a release. +// +// npm run release:cut +// +// Refuses to run on a dirty tree, off main, or with nothing to release. + +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** @param {string} cmd @param {string[]} args */ +function run(cmd, args) { + return execFileSync(cmd, args, { cwd: repoRoot, encoding: 'utf8' }).trim(); +} + +/** @param {string} cmd @param {string[]} args */ +function show(cmd, args) { + console.log(`> ${cmd} ${args.join(' ')}`); + execFileSync(cmd, args, { cwd: repoRoot, stdio: 'inherit' }); +} + +const branch = run('git', ['branch', '--show-current']); +if (branch !== 'main') { + console.error(`On ${branch || 'a detached HEAD'}; releases cut from main.`); + process.exit(1); +} +if (run('git', ['status', '--porcelain']) !== '') { + console.error('The tree is dirty; commit or stash first.'); + process.exit(1); +} +show('git', ['pull', '--ff-only']); + +const pending = readdirSync(join(repoRoot, '.changeset')).filter( + (f) => f.endsWith('.md') && f !== 'README.md', +); +if (pending.length === 0) { + console.error('No pending changesets; nothing to release.'); + process.exit(1); +} +console.log(`${pending.length} changeset(s) pending.`); + +show('npm', ['run', 'version-packages']); + +const { version } = JSON.parse( + readFileSync(join(repoRoot, 'packages/core/package.json'), 'utf8'), +); +const tag = `v${version}`; +if (run('git', ['tag', '--list', tag]) !== '') { + console.error(`${tag} already exists; the group version did not move.`); + process.exit(1); +} + +show('git', ['add', '-A']); +show('git', ['commit', '-m', `chore: version packages for ${version}`]); +show('git', ['tag', tag]); +show('git', ['push', 'origin', 'main', tag]); + +console.log(`\n${tag} pushed. The release workflows take it from here:`); +console.log(' release-npm -> npm packages'); +console.log(' release-image -> the container image'); +console.log(' release-worker -> the worker artifact on the artifacts site');