From 0cb1c57be67af257fc0ca9e3d6d12e5d3d07c2d6 Mon Sep 17 00:00:00 2001 From: Ajay Bhargava Date: Mon, 27 Jul 2026 16:57:30 +0000 Subject: [PATCH 1/2] feat(npm): bootstrap from real tarballs instead of placeholders 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. The previous instruction was to publish 373-byte placeholder packages, which has two defects: the placeholder claims the "latest" dist-tag, so `npm i @planmonster/olkcli` would install a launcher with no binary, and the junk version stays on the registry forever because versions are immutable. Bootstrap from the tarballs that a dry run already produces instead. A maintainer starts publish-npm.yml with dry_run: true, downloads the npm-tarballs artifact, and publishes it with scripts/bootstrap-npm.sh. No Mac is needed locally, the first published version is real and installable, and the token is used once and then revoked. scripts/bootstrap-npm.sh enforces the publish order (six binary packages first, launcher last) and refuses to run unless all seven tarballs are present. It supports --dry-run and --tag. build-npm.mjs gains --tag so the placeholder path, kept only for reserving names ahead of a first release, can stay off the "latest" tag. --- docs/npm-publishing.md | 58 +++++++++++++++++------- scripts/bootstrap-npm.sh | 97 ++++++++++++++++++++++++++++++++++++++++ scripts/build-npm.mjs | 5 +++ 3 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 scripts/bootstrap-npm.sh 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..a79a0f8 --- /dev/null +++ b/scripts/bootstrap-npm.sh @@ -0,0 +1,97 @@ +#!/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) + shift + tag="${1:-}" + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +shopt -s nullglob +platform_tgz=() +launcher_tgz="" +for f in "$dir"/*.tgz; do + name="$(basename "$f")" + # The launcher package is *olkcli-.tgz; the binary packages are + # *olk---.tgz. + if [[ "$name" == *olkcli-* ]]; then + launcher_tgz="$f" + else + platform_tgz+=("$f") + fi +done + +if [ "${#platform_tgz[@]}" -ne 6 ] || [ -z "$launcher_tgz" ]; then + echo "expected 6 binary tarballs + 1 launcher tarball in $dir, found:" >&2 + printf ' %s\n' "$dir"/*.tgz >&2 + exit 1 +fi + +echo "Binary packages (published first):" +printf ' %s\n' "${platform_tgz[@]##*/}" +echo "Launcher (published last):" +echo " ${launcher_tgz##*/}" +echo + +publish() { + local f="$1" + 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 f in "${platform_tgz[@]}"; do publish "$f"; done +publish "$launcher_tgz" + +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..26ea5e1 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) @@ -51,6 +52,9 @@ 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. +const distTag = flag("--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 +143,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), From 89ac14f900e5845601e98c4da2f98c3bc755128b Mon Sep 17 00:00:00 2001 From: ajay-bhargava Date: Mon, 27 Jul 2026 17:07:51 +0000 Subject: [PATCH 2/2] fix(npm): validate exact tarball set, require --tag value, make bootstrap resumable Co-authored-by: Codesmith --- scripts/bootstrap-npm.sh | 155 ++++++++++++++++++++++++++++++++++----- scripts/build-npm.mjs | 20 ++++- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/scripts/bootstrap-npm.sh b/scripts/bootstrap-npm.sh index a79a0f8..336e242 100644 --- a/scripts/bootstrap-npm.sh +++ b/scripts/bootstrap-npm.sh @@ -36,8 +36,15 @@ 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 - tag="${1:-}" ;; *) echo "unknown argument: $1" >&2 @@ -47,34 +54,144 @@ while [ $# -gt 0 ]; do 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 -platform_tgz=() +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="" -for f in "$dir"/*.tgz; do - name="$(basename "$f")" - # The launcher package is *olkcli-.tgz; the binary packages are - # *olk---.tgz. - if [[ "$name" == *olkcli-* ]]; then - launcher_tgz="$f" - else - platform_tgz+=("$f") +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 -if [ "${#platform_tgz[@]}" -ne 6 ] || [ -z "$launcher_tgz" ]; then - echo "expected 6 binary tarballs + 1 launcher tarball in $dir, found:" >&2 - printf ' %s\n' "$dir"/*.tgz >&2 +# 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_tgz[@]##*/}" +printf ' %s\n' "${platform_names[@]}" echo "Launcher (published last):" -echo " ${launcher_tgz##*/}" +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" + 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 @@ -85,8 +202,10 @@ publish() { npm "${args[@]}" } -for f in "${platform_tgz[@]}"; do publish "$f"; done -publish "$launcher_tgz" +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 diff --git a/scripts/build-npm.mjs b/scripts/build-npm.mjs index 26ea5e1..7b1fa3f 100644 --- a/scripts/build-npm.mjs +++ b/scripts/build-npm.mjs @@ -41,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) { @@ -53,8 +66,11 @@ 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. -const distTag = flag("--tag") || process.env.NPM_TAG || ""; +// 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.