diff --git a/docs/npm-publishing.md b/docs/npm-publishing.md index 6548e04..ab10eea 100644 --- a/docs/npm-publishing.md +++ b/docs/npm-publishing.md @@ -45,38 +45,66 @@ after an unpublish. Publish a new version to correct a defect. ## One-time bootstrap (do this before the first release) npm Trusted Publishing can only be configured on a package that already exists. -Therefore a person must create the seven names once by hand. CI publishes every -version after that. +CI publishes **through** Trusted Publishing. Therefore the first publish of each +of the seven names must come from a maintainer's machine. CI publishes every +version after that, and CI never holds a token. + +Publish real tarballs, not placeholders. CI builds all six binaries for you, so +you do not need a Mac and you create no junk versions. 1. Create the `planmonster` organization on npmjs.org. Enable 2FA on your account. -2. Create a granular access token with write access, or publish interactively - and answer the 2FA prompt. -3. From a clean checkout, stamp and publish placeholder packages: +2. In the Actions tab, start **Publish (npm, @planmonster)** with the version + you intend to ship and `dry_run: true`. The run packs seven tarballs and + uploads them as the `npm-tarballs` artifact. +3. Download and unzip the artifact. +4. Authenticate, then publish the tarballs in the required order: ```sh - node scripts/build-npm.mjs 0.0.1-bootstrap.0 \ - --scope @planmonster \ - --registry https://registry.npmjs.org \ - --repository PlanMonster/olkcli \ - --publish --skip-binary-check - git checkout -- npm/olk/package.json npm/olk-*/package.json + npm login # answer the 2FA prompt + bash scripts/bootstrap-npm.sh --dry-run # inspect first + bash scripts/bootstrap-npm.sh ``` - `--skip-binary-check` permits a publish with no binary. Use it here only. -4. For each of the seven packages, open Settings on npmjs.com and add a Trusted + The script publishes the six binary packages first and the launcher last, and + it refuses to run unless all seven tarballs are present. +5. For each of the seven packages, open Settings on npmjs.com and add a Trusted Publisher: - Organization or user: `PlanMonster` - Repository: `olkcli` - Workflow: `publish-npm.yml` -5. Leave "Publishing access" at the default. Do **not** select "Require +6. Leave "Publishing access" at the default. Do **not** select "Require two-factor authentication and disallow tokens": that turns on staged publishing, which makes a person approve every CI publish before consumers can install it. +7. Revoke the token, or run `npm logout`. -After step 5, CI holds no secret. Each publish mints a short-lived credential +After step 7, CI holds no secret. Each publish mints a short-lived credential from the GitHub OIDC identity. +The next tagged release must use a new version, because registry versions are +immutable. A CI re-run of a version that already exists is safe: it is skipped, +and the job still succeeds. + +### Alternative: reserve the names now, ship later + +Use this only to claim the seven names before the code is ready to ship. It +publishes 373-byte packages that contain `package.json` and an empty `bin/` +directory, and no binary: + +```sh +node scripts/build-npm.mjs 0.0.1-bootstrap.0 \ + --scope @planmonster \ + --registry https://registry.npmjs.org \ + --repository PlanMonster/olkcli \ + --publish --skip-binary-check --tag bootstrap +git checkout -- npm/olk/package.json npm/olk-*/package.json +``` + +`--skip-binary-check` permits a publish with no binary. `--tag bootstrap` keeps +the placeholder off the `latest` tag, so `npm i @planmonster/olkcli` cannot +install a launcher that has no binary. Never use either option in CI. + ## Release procedure ```sh diff --git a/scripts/bootstrap-npm.sh b/scripts/bootstrap-npm.sh new file mode 100644 index 0000000..336e242 --- /dev/null +++ b/scripts/bootstrap-npm.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# One-time npm bootstrap: publish pre-packed tarballs by hand. +# +# bash scripts/bootstrap-npm.sh [--dry-run] [--tag ] +# +# npm Trusted Publishing can only be configured on a package that already +# exists, so the first publish of each of the seven names cannot come from CI. +# This script does that first publish from a maintainer's machine, in the +# correct order, using tarballs built by CI. +# +# Get the tarballs without building anything locally (no Mac needed): +# 1. Actions -> "Publish (npm, @planmonster)" -> Run workflow +# version: , dry_run: true +# 2. Download the "npm-tarballs" artifact and unzip it. +# 3. npm login (answer the 2FA prompt) +# 4. bash scripts/bootstrap-npm.sh +# 5. Add a Trusted Publisher to each of the seven packages on npmjs.com: +# PlanMonster / olkcli / publish-npm.yml +# 6. Revoke the token or log out. CI never needs one again. +# +# Ordering is enforced: the six binary packages go first and the launcher goes +# last, so no consumer can ever resolve a launcher whose optional dependencies +# are absent. +set -euo pipefail + +dir="${1:-}" +if [ -z "$dir" ] || [ ! -d "$dir" ]; then + echo "usage: bash scripts/bootstrap-npm.sh [--dry-run] [--tag ]" >&2 + exit 2 +fi +shift + +dry_run=0 +tag="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=1 ;; + --tag) + # Reject a missing or option-like value so `--tag --dry-run` cannot + # silently consume `--dry-run` as the dist-tag and proceed with a real + # publish. + if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then + echo "--tag requires a value" >&2 + exit 2 + fi + tag="$2" + shift + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +# The six platform packages that must be published before the launcher. Bare +# names; the launcher's npm scope (e.g. "@planmonster/") is prepended below so +# a scoped and an unscoped build are both validated exactly. +expected_platforms=( + olk-darwin-arm64 + olk-darwin-x64 + olk-linux-arm64 + olk-linux-x64 + olk-win32-arm64 + olk-win32-x64 +) + +# Read "\t" from a tarball's package/package.json. npm (hence +# node) is a hard dependency of this script, so parsing JSON with node is safe. +tarball_id() { + tar -xzOf "$1" package/package.json 2>/dev/null | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)); + process.stdin.on("end", () => { + try { + const j = JSON.parse(s); + if (!j.name || !j.version) process.exit(1); + process.stdout.write(j.name + "\t" + j.version); + } catch { + process.exit(1); + } + }); + ' +} + +shopt -s nullglob +tarballs=() +for f in "$dir"/*.tgz; do tarballs+=("$f"); done +if [ "${#tarballs[@]}" -eq 0 ]; then + echo "no .tgz tarballs found in $dir" >&2 + exit 1 +fi + +# Identify every tarball by the name/version inside its package.json rather than +# by filename, so an unrelated or mislabelled archive can never stand in for a +# required package. +names=() +vers=() +files=() +for f in "${tarballs[@]}"; do + if ! pkgid="$(tarball_id "$f")"; then + echo "cannot read package/package.json from ${f##*/}" >&2 + exit 1 + fi + names+=("${pkgid%%$'\t'*}") + vers+=("${pkgid##*$'\t'}") + files+=("$f") +done + +# Every tarball must carry the same version. +shared_version="${vers[0]}" +for i in "${!vers[@]}"; do + if [ "${vers[$i]}" != "$shared_version" ]; then + echo "tarballs disagree on version: '${names[0]}' is $shared_version but '${names[$i]}' is ${vers[$i]}" >&2 + exit 1 + fi +done + +# Exactly one launcher (bare name "olkcli"); its scope fixes the scope we expect +# on the six platform packages. +launcher_tgz="" +launcher_name="" +for i in "${!names[@]}"; do + if [ "${names[$i]##*/}" = "olkcli" ]; then + if [ -n "$launcher_tgz" ]; then + echo "found more than one launcher (olkcli) tarball" >&2 + exit 1 + fi + launcher_tgz="${files[$i]}" + launcher_name="${names[$i]}" + fi +done +if [ -z "$launcher_tgz" ]; then + echo "no launcher (olkcli) tarball found in $dir" >&2 + exit 1 +fi +case "$launcher_name" in + */*) scope_prefix="${launcher_name%/*}/" ;; + *) scope_prefix="" ;; +esac + +# Each of the six expected platform packages must be present exactly once, at +# the launcher's scope. +platform_tgz=() +platform_names=() +for p in "${expected_platforms[@]}"; do + want="${scope_prefix}${p}" + match="" + for i in "${!names[@]}"; do + if [ "${names[$i]}" = "$want" ]; then + if [ -n "$match" ]; then + echo "found more than one tarball named $want" >&2 + exit 1 + fi + match="${files[$i]}" + fi + done + if [ -z "$match" ]; then + echo "missing expected platform package: $want" >&2 + exit 1 + fi + platform_tgz+=("$match") + platform_names+=("$want") +done + +# Launcher + six platforms = seven; any other count means an unexpected extra. +if [ "${#tarballs[@]}" -ne 7 ]; then + echo "expected exactly 7 tarballs (1 launcher + 6 platform) in $dir, found ${#tarballs[@]}:" >&2 + printf ' %s\n' "${names[@]}" >&2 + exit 1 +fi + +echo "Version: $shared_version" +echo "Binary packages (published first):" +printf ' %s\n' "${platform_names[@]}" +echo "Launcher (published last):" +echo " $launcher_name" +echo + +# A published version is immutable, so re-running after a partial bootstrap must +# skip names already at $shared_version instead of aborting on the first one. +already_published() { + local existing + existing="$(npm view "$1@$shared_version" version 2>/dev/null || true)" + [ "$existing" = "$shared_version" ] +} + +publish() { + local f="$1" name="$2" + if already_published "$name"; then + echo "skip $name@$shared_version (already published)" + return 0 + fi + local args=(publish "$f" --access public) + [ -n "$tag" ] && args+=(--tag "$tag") + if [ "$dry_run" -eq 1 ]; then + echo "DRY RUN: npm ${args[*]}" + return 0 + fi + echo "==> npm ${args[*]}" + npm "${args[@]}" +} + +for i in "${!platform_tgz[@]}"; do + publish "${platform_tgz[$i]}" "${platform_names[$i]}" +done +publish "$launcher_tgz" "$launcher_name" + +echo +if [ "$dry_run" -eq 1 ]; then + echo "Dry run complete. Nothing was published." +else + echo "Bootstrap complete. Now add a Trusted Publisher to each of the seven" + echo "packages (PlanMonster / olkcli / publish-npm.yml), then revoke the token." +fi diff --git a/scripts/build-npm.mjs b/scripts/build-npm.mjs index 4792ffa..7b1fa3f 100644 --- a/scripts/build-npm.mjs +++ b/scripts/build-npm.mjs @@ -8,6 +8,7 @@ // --registry publish/lookup against a non-default registry // --repository owner/name rewrite repository/homepage/bugs URLs // --access public|restricted +// --tag publish under a dist-tag other than "latest" // --skip-binary-check publish binary packages with no binary // (one-time bootstrap only — see // docs/npm-publishing.md) @@ -40,6 +41,19 @@ function flag(name) { const i = argv.indexOf(name); return i === -1 ? undefined : argv[i + 1]; } +// Like flag(), but when the flag is present it must be followed by a real +// value (not the end of argv, not another option). Returns undefined when the +// flag is absent. +function requireValue(name) { + const i = argv.indexOf(name); + if (i === -1) return undefined; + const val = argv[i + 1]; + if (val === undefined || val.startsWith("-")) { + console.error(`${name} requires a value`); + process.exit(2); + } + return val; +} const rawVersion = argv.find((a) => !a.startsWith("-")) || process.env.VERSION || process.env.GITHUB_REF_NAME; if (!rawVersion) { @@ -51,6 +65,12 @@ const publish = argv.includes("--publish"); const registry = flag("--registry") || process.env.NPM_REGISTRY; const repository = flag("--repository") || process.env.NPM_REPOSITORY; const access = flag("--access") || process.env.NPM_ACCESS || "public"; +// A placeholder or pre-release publish must not claim the "latest" tag, or a +// consumer running `npm i ` receives it. An explicit `--tag` must carry a +// real value: reject a missing or option-like one rather than silently falling +// back to NPM_TAG or forwarding the next flag (e.g. `--tag --publish`) as a +// dist-tag. +const distTag = requireValue("--tag") || process.env.NPM_TAG || ""; // npm Trusted Publishing can only be configured on a package that already // exists, so the very first publish of each name has to happen by hand and may // carry no binary. Never set this in the release workflow. @@ -139,6 +159,7 @@ function npmPublish(pkgDir) { } console.log(`publishing ${name}@${version} ...`); const args = ["publish", "--access", access]; + if (distTag) args.push("--tag", distTag); if (registry) args.push("--registry", registry); execFileSync("npm", args, { cwd: path.join(npmDir, pkgDir),