diff --git a/scripts/bootstrap-npm-package.md b/scripts/bootstrap-npm-package.md new file mode 100644 index 00000000..70d4f8a6 --- /dev/null +++ b/scripts/bootstrap-npm-package.md @@ -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" < { + 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 { + 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"); + }); +}); diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 8ec7d080..f71eaf55 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -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 - ( cd /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