Skip to content
Closed
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
198 changes: 198 additions & 0 deletions scripts/bootstrap-npm-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
---
title: Bootstrap an npm package
---

# Bootstrap an npm package

New npm packages need a package record before npm lets us configure GitHub
Actions as their trusted publisher. This document creates that record when a
package's real implementation cannot yet be built from published siblings.

It publishes an empty `0.0.0-bootstrap.0` package under the `bootstrap`
dist-tag. It never publishes `latest`; the first tagged release publishes the
implementation.

## Run

The code block below is the script source. Until
[#153](https://github.com/taras/executable.md/issues/153) is resolved, `xmd run`
limits a code block to 30 seconds, which is too short for npm's web
authentication. Materialize the block as a temporary script so npm keeps the
terminal it needs for authentication:

```sh
bootstrap_file="$(mktemp)"

awk '
/^```bash exec$/ { run=1; next }
run && /^```$/ { exit }
run { print }
' scripts/bootstrap-npm-package.md > "$bootstrap_file"
```

Publishing requires npm 11.15 or newer for `npm trust`. With Volta, find that
npm and put it on the shell's `PATH`; `volta run --npm … bash` does not pass its
npm selection into `bash`.

```sh
volta install npm@11.18.0
NPM_DIR="$(dirname "$(volta which npm)")"
PATH="$NPM_DIR:$PATH" npm whoami --registry=https://registry.npmjs.org
```

Preview the artifact first:

```sh
PACKAGE_DIR=packages/acp \
PATH="$NPM_DIR:$PATH" \
bash "$bootstrap_file"
```

Publish it and configure trusted publishing:

```sh
PACKAGE_DIR=packages/acp \
PUBLISH=1 \
PATH="$NPM_DIR:$PATH" \
bash "$bootstrap_file"
```

The browser authentication may finish after npm loses its request. Re-run the
same publish command if that happens: an existing, correct bootstrap package
skips publication and continues with trusted-publisher configuration.

Verify the result:

```sh
PATH="$NPM_DIR:$PATH" npm view @executablemd/acp dist-tags \
--json --registry=https://registry.npmjs.org

PATH="$NPM_DIR:$PATH" npm trust list @executablemd/acp \
--registry=https://registry.npmjs.org
```

Remove the temporary script when every package is complete:

```sh
rm "$bootstrap_file"
```

Create the matching package on JSR and link it to this repository before the
next tagged release.

## Bootstrap

```bash exec
set -euo pipefail

: "${PACKAGE_DIR:?set PACKAGE_DIR to a workspace package directory}"
registry="https://registry.npmjs.org"
bootstrap_version="0.0.0-bootstrap.0"

if [ ! -f "$PACKAGE_DIR/deno.json" ] || [ ! -f "$PACKAGE_DIR/package.json" ]; then
echo "PACKAGE_DIR must contain deno.json and package.json: $PACKAGE_DIR" >&2
exit 1
fi

name="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name)' "$PACKAGE_DIR/package.json")"
description="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).description ?? "")' "$PACKAGE_DIR/package.json")"

case "$name" in
@executablemd/*) ;;
*)
echo "PACKAGE_DIR must name an @executablemd package: $name" >&2
exit 1
;;
esac

if [ "${PUBLISH:-}" = "1" ] && ! node -e '
const [major, minor] = process.argv[1].split(".").map(Number);
process.exit(major > 11 || (major === 11 && minor >= 15) ? 0 : 1);
' "$(npm --version)"; then
echo "PUBLISH=1 requires npm 11.15 or newer for npm trust; found $(npm --version)." >&2
exit 1
fi

publish=0
if existing="$(npm view "$name" version --json --registry "$registry" 2>&1)"; then
if ! printf '%s\n' "$existing" | grep -Fq "\"$bootstrap_version\""; then
echo "$name already exists on npm with a version other than $bootstrap_version:" >&2
printf '%s\n' "$existing" >&2
exit 1
fi

tags="$(npm view "$name" dist-tags --json --registry "$registry" 2>&1)"
if ! printf '%s\n' "$tags" | grep -Eq "\"bootstrap\"[[:space:]]*:[[:space:]]*\"$bootstrap_version\""; then
echo "$name does not have the expected bootstrap dist-tag:" >&2
printf '%s\n' "$tags" >&2
exit 1
fi

echo "$name@$bootstrap_version already exists; skipping publication."
elif ! printf '%s\n' "$existing" | grep -q 'E404'; then
echo "could not confirm whether $name exists on npm:" >&2
printf '%s\n' "$existing" >&2
exit 1
else
publish=1
fi

if [ "$publish" = "1" ]; then
bootstrap_dir="$(mktemp -d -t executablemd-bootstrap.XXXXXX)"
trap 'rm -rf "$bootstrap_dir"' EXIT

node -e '
const fs = require("fs");
const [file, name, description] = process.argv.slice(1);
fs.writeFileSync(file, JSON.stringify({
name,
version: "0.0.0-bootstrap.0",
description: `Bootstrap reservation for ${description || name}.`,
license: "MIT",
repository: {
type: "git",
url: "git+https://github.com/taras/executable.md.git",
},
homepage: "https://executable.md",
files: ["README.md"],
}, null, 2) + "\n");
' "$bootstrap_dir/package.json" "$name" "$description"

cat >"$bootstrap_dir/README.md" <<EOF
# $name

This is a bootstrap reservation for the package name. It contains no
implementation. Install a stable release from the \`latest\` dist-tag once
available.
EOF

echo "Bootstrap artifact for $name:"
(cd "$bootstrap_dir" && npm pack --dry-run --json)

if [ "${PUBLISH:-}" != "1" ]; then
echo "Preview complete. Set PUBLISH=1 to publish the bootstrap artifact."
exit 0
fi

(cd "$bootstrap_dir" && npm publish --access public --tag bootstrap --registry "$registry")
elif [ "${PUBLISH:-}" != "1" ]; then
echo "Preview complete. $name already has the bootstrap artifact."
exit 0
fi

npm trust github "$name" \
--file publish-packages.yml \
--repository taras/executable.md \
--environment npm-publish \
--allow-publish \
--registry "$registry" \
--yes

echo "npm dist-tags:"
npm view "$name" dist-tags --json --registry "$registry"

echo "trusted publisher:"
npm trust list "$name" --registry "$registry"

echo "Next: create $name on JSR and link it to taras/executable.md before the tagged release."
```
126 changes: 126 additions & 0 deletions scripts/tests/bootstrap-npm-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it } from "@effectionx/bdd/node";
import { expect } from "@effectionx/bdd/expect";
import { ensure } from "effection";
import type { Operation } from "effection";
import { exec } from "@effectionx/process";
import { readTextFile, rm, writeTextFile } from "@effectionx/fs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = fileURLToPath(new URL("../../", import.meta.url));
const BOOTSTRAP_VERSION = "0.0.0-bootstrap.0";

/**
* The registry states the script distinguishes. `mistagged` is the one that
* needs both halves of the resumability guard: the version is exactly the
* bootstrap version, so only the dist-tag check can reject it.
*/
type NpmState = "missing" | "bootstrap" | "unexpected" | "mistagged";

function* bootstrapSource(): Operation<string> {
const document = yield* readTextFile(path.join(ROOT, "scripts/bootstrap-npm-package.md"));
const start = document.indexOf("```bash exec\n");
const end = document.indexOf("\n```", start);
return document.slice(start + "```bash exec\n".length, end);
}

function* fakeNpm(state: NpmState): Operation<string> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bootstrap-npm-package-"));
yield* ensure(() => rm(dir, { recursive: true, force: true }));

const source = [
"npm() {",
' printf "%s\\n" "$*" >> "$NPM_LOG"',
' case "$1" in',
' --version) echo "11.18.0" ;;',
" view)",
' case "$3" in',
" version)",
' case "$NPM_STATE" in',
` missing) echo "npm error E404" >&2; return 1 ;;`,
` bootstrap|mistagged) echo "\\\"${BOOTSTRAP_VERSION}\\\"" ;;`,
' unexpected) echo "\\\"1.0.0\\\"" ;;',
" esac",
" ;;",
" dist-tags)",
' case "$NPM_STATE" in',
` mistagged) echo '{"latest":"${BOOTSTRAP_VERSION}"}' ;;`,
` *) echo '{"bootstrap":"${BOOTSTRAP_VERSION}"}' ;;`,
" esac",
" ;;",
" esac",
" ;;",
' pack) echo "[]" ;;',
" publish) ;;",
" trust) ;;",
" esac",
"}",
].join("\n");
const file = path.join(dir, "npm.sh");
yield* writeTextFile(file, source);
return file;
}

function* runBootstrap(state: NpmState) {
const envFile = yield* fakeNpm(state);
const log = path.join(path.dirname(envFile), "npm.log");
const result = yield* exec("bash", {
arguments: ["-c", yield* bootstrapSource()],
cwd: ROOT,
env: {
...Deno.env.toObject(),
BASH_ENV: envFile,
NPM_LOG: log,
NPM_STATE: state,
PACKAGE_DIR: "packages/acp",
PUBLISH: "1",
},
}).join();
return { result, log };
}

describe("bootstrap npm package", () => {
it("publishes an absent bootstrap package before configuring trust", function* () {
const { result, log } = yield* runBootstrap("missing");

expect(result.code).toBe(0);
const calls = yield* readTextFile(log);
expect(calls).toContain("publish --access public --tag bootstrap");
expect(calls).toContain("trust github @executablemd/acp");
});

it("resumes an existing bootstrap package by configuring trust", function* () {
const { result, log } = yield* runBootstrap("bootstrap");

expect(result.code).toBe(0);
const calls = yield* readTextFile(log);
expect(calls).not.toContain("publish --access public --tag bootstrap");
expect(calls).toContain("trust github @executablemd/acp");
});

it("rejects a package that is not in the bootstrap state", function* () {
const { result, log } = yield* runBootstrap("unexpected");

expect(result.code).not.toBe(0);
expect(result.stderr).toContain("version other than 0.0.0-bootstrap.0");
const calls = yield* readTextFile(log);
expect(calls).not.toContain("publish --access public --tag bootstrap");
expect(calls).not.toContain("trust github @executablemd/acp");
});

// The version alone cannot establish resumability: a package sitting at
// 0.0.0-bootstrap.0 under some other dist-tag is not the artifact this
// script published, so resuming onto it would configure trust for a record
// it does not own.
it("rejects the bootstrap version when it is not under the bootstrap dist-tag", function* () {
const { result, log } = yield* runBootstrap("mistagged");

expect(result.code).not.toBe(0);
expect(result.stderr).toContain("does not have the expected bootstrap dist-tag");
const calls = yield* readTextFile(log);
expect(calls).not.toContain("publish --access public --tag bootstrap");
expect(calls).not.toContain("trust github @executablemd/acp");
});
});
23 changes: 10 additions & 13 deletions specs/release-process-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,25 +167,22 @@ tokens minted outside the gated environment.
## 6. Adding a new package

npm exposes trusted-publisher settings only on a package that already exists,
and the workflows carry no npm token, so bootstrap a new package by hand once:
and the workflows carry no npm token, so bootstrap a new package once:

1. Create its directory under `packages/` with a `deno.json` (name under
`@executablemd`) and a `package.json` declaring its dependencies
(`workspace:*` for internal siblings). The root `deno.json` covers it through
the `packages/*` workspace glob, so membership needs no edit. Run
`deno task gen:publish-workflow` and commit the regenerated orchestrator.
2. Publish its first version by hand as a logged-in `@executablemd` scope
owner:
```sh
deno run -A scripts/build-npm.ts <package-dir> <version>
( cd <package-dir>/npm && npm publish --access public )
```
This covers a package with no `workspace:*` dependencies. A package that
declares them cannot build its first artifact until those sibling versions
are on npm, because the build resolves siblings from the registry. That
bootstrap is tracked in #152 rather than specified here.
3. Configure its trusted publisher with the table in §4.
4. Create the package on jsr.io under the `@executablemd` scope and link it to
2. Run `scripts/bootstrap-npm-package.md` as a logged-in `@executablemd` scope
owner. It previews a deliberately empty `0.0.0-bootstrap.0` artifact by
default and, after `PUBLISH=1`, publishes it only under the `bootstrap`
dist-tag before configuring the trusted publisher from §4. It does not build
the implementation; the first tagged release publishes that artifact as
`latest`. Follow the document's terminal instructions: npm web
authentication needs an interactive terminal, and the document resumes
trusted-publisher configuration when its bootstrap version already exists.
3. Create the package on jsr.io under the `@executablemd` scope and link it to
this repository, **before** the first tagged release that includes it.
`deno publish` fails for a package that does not exist on JSR, and the JSR
job publishes the workspace as a unit — so one uncreated package fails the
Expand Down
Loading