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
58 changes: 43 additions & 15 deletions docs/npm-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <unzipped-dir> --dry-run # inspect first
bash scripts/bootstrap-npm.sh <unzipped-dir>
```

`--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
Expand Down
216 changes: 216 additions & 0 deletions scripts/bootstrap-npm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# One-time npm bootstrap: publish pre-packed tarballs by hand.
#
# bash scripts/bootstrap-npm.sh <dir-with-tarballs> [--dry-run] [--tag <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: <the version you intend to ship>, 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 <unzipped-dir>
# 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 <dir-with-tarballs> [--dry-run] [--tag <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
;;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*)
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 "<name>\t<version>" 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
21 changes: 21 additions & 0 deletions scripts/build-npm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// --registry <url> publish/lookup against a non-default registry
// --repository owner/name rewrite repository/homepage/bugs URLs
// --access public|restricted
// --tag <dist-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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <pkg>` 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.
Expand Down Expand Up @@ -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),
Expand Down
Loading