From 015d6662562453ab9f332bd9bc5afcc709b547af Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:08:15 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=A6=20Let=20a=20first=20release=20?= =?UTF-8?q?publish=20on=20both=20registries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @executablemd/workflow was the first package in this scope to be published, and neither registry's path survived it. JSR refused it for having no license: the vendored Cloudflare LICENSE satisfies Deno's license-file search, so the workspace-root LICENSE was never pulled into the tarball and JSR, which reads only the root, saw none. workflow and cli now declare the license in deno.json. npm's half is components/BootstrapNpmPackage.md, which could not get past its own pre-flight: `npm trust list` needs a one-time password even to answer, and the document read it before asking for one. The EOTP that came back was classified `unreadable`, which shared a branch with `conflicting`, so every run reported a trusted publisher that was not there. The read now happens after the code, replacing the post-prompt re-read it made redundant, and `unreadable` reports what npm said. A run that published then reported failure, because npm accepts a publish before its package reads report it and the confirming read called that 404 `absent`. The confirming reads now wait for the registry to carry the package, and reads still behind after the wait say so. Both writes moved from `silent exec` to `exec as="…"` with failure branches, so a rejected publish is reported in npm's own words instead of a bare exit code. --- components/BootstrapNpmPackage.md | 265 ++++++++++++++------ packages/cli/deno.json | 1 + packages/workflow/deno.json | 1 + scripts/tests/bootstrap-npm-package.test.ts | 169 ++++++++++++- specs/release-process-spec.md | 59 +++-- 5 files changed, 398 insertions(+), 97 deletions(-) diff --git a/components/BootstrapNpmPackage.md b/components/BootstrapNpmPackage.md index 16fe4d4a9..81a38a977 100644 --- a/components/BootstrapNpmPackage.md +++ b/components/BootstrapNpmPackage.md @@ -140,10 +140,15 @@ trusted publisher are skipped separately when they are already there, so a run interrupted partway finishes what is missing instead of starting over. The same answers stop you from bootstrapping a package that has already been set -up. Real versions published, a `bootstrap` tag pointing at something else, or a -trusted publisher configured differently all mean this is a live package — and -treating a live package as a fresh one could break it for everyone installing -it. Any of those stops the run here, before you are asked for a code and before +up. Real versions published, or a `bootstrap` tag pointing at something else, +mean this is a live package — and treating a live package as a fresh one could +break it for everyone installing it. Either stops the run here, before you are +asked for a code and before anything is written. + +What the package already trusts is the third answer, and it is read further +down, after the code. npm makes the trusted publisher readable only to someone +who could change it, so `npm trust list` needs a one-time password even to +answer. That refusal comes later than these two, but it still comes before anything is written. npm answers a question about a package it does not carry by exiting non-zero, so @@ -153,6 +158,15 @@ and a non-zero exit raises nothing. Here a 404 is an answer rather than a failure, and the comparison below is what decides which. ```ts eval +/** + * npm's own diagnostics are what these detail lines carry, and they run to + * several lines. Indenting every one of them keeps a report readable as the + * block it is. + */ +function indent(text) { + return text.split("\n").map((line) => ` ${line}`).join("\n"); +} + function permissionLabel(permission) { if (permission === "createPackage") { return "publish"; @@ -271,17 +285,12 @@ npm view {packageName} versions --json --registry {registry} npm view {packageName} dist-tags --json --registry {registry} ``` -```bash exec as="trustList" -npm trust list {packageName} --json --registry {registry} -``` - ```ts eval const reservation = classifyReservation(versions, distTags, bootstrapVersion); -const trust = classifyTrust(trustList, expectedTrust, reservation.state === "absent"); -const needsWrite = reservation.state === "absent" || trust.state === "absent"; +const reservationDetail = indent(reservation.detail); ``` - + `{packageName}` is already a live package on npm — {reservation.detail}. @@ -294,19 +303,18 @@ throw new Error(`${packageName} is already published; refusing to bootstrap it`) - - -`{packageName}` already trusts a publisher this document did not set up: + - {trust.summary} +npm did not answer what `{packageName}` has published: -Nothing was changed. npm holds one trusted publisher per package and refuses a -second, so replacing this one means revoking it deliberately first: +{reservationDetail} - npm trust revoke {packageName} --id {trust.id} +Nothing has been published and no code was requested. This is npm failing to +answer rather than an answer this document refuses, so running it again once npm +responds starts from the same place. ```ts eval -throw new Error(`${packageName} already has a trusted publisher; refusing to replace it`); +throw new Error(`${packageName}: ${reservation.detail}`); ``` @@ -323,20 +331,6 @@ throw new Error(`${packageName} already has a trusted publisher; refusing to rep - - - Its trusted publisher already matches — it will be left alone - - - - - - It has no trusted publisher — one will be created for {repository} - - - - - @@ -422,11 +416,15 @@ const packDisplay = (() => { ## Authenticating -Everything up to now has only read. The steps below write to npm, and both of -them need your one-time password. +Everything up to now has only read, and nothing below it has written yet. The +code comes here because the last question this document asks the registry needs +it as much as the two writes do: npm answers `npm trust list` only to someone +who could change what it reports. Enter a fresh code. Codes expire after about thirty seconds, so one generated a -minute ago may not survive both steps. +minute ago may not survive the steps below. A code that expires partway leaves +whatever landed before it in place, and running the document again finishes the +rest. @@ -447,11 +445,76 @@ minute ago may not survive both steps. -Enter a fresh six-digit npm one-time password. It authorizes both the -placeholder publish and the trusted-publisher configuration, so generate it now -rather than reusing one from a moment ago. +Enter a fresh six-digit npm one-time password. It authorizes reading what the +package already trusts, the placeholder publish, and the trusted-publisher +configuration, so generate it now rather than reusing one from a moment ago. +## Reading the trusted publisher + +This is the answer the reads above could not get. npm holds one trusted +publisher per package and refuses a second, so a configuration that is not the +one in this document's table means the package has been set up by someone else — +and that stops the run here, before the placeholder is published. A refusal +leaves npm exactly as it was. + +```bash exec as="trustList" +npm_config_otp={otp.code} npm trust list {packageName} --json --registry {registry} +``` + +```ts eval +const trust = classifyTrust( + trustList, + expectedTrust, + reservation.state === "absent", +); +const trustDetail = indent(trust.detail); +``` + + + +`{packageName}` already trusts a publisher this document did not set up: + + {trust.summary} + +Nothing was published and nothing was changed. npm refuses a second +configuration, so replacing this one means revoking it deliberately first: + + npm trust revoke {packageName} --id {trust.id} + +```ts eval +throw new Error(`${packageName} already has a trusted publisher; refusing to replace it`); +``` + + + + + +npm did not answer what `{packageName}` trusts: + +{trustDetail} + +Nothing was published and nothing was changed. An expired code reads this way +too, so if that is what happened, generate a fresh one and run this again. + +```ts eval +throw new Error(`${packageName}: ${trust.detail}`); +``` + + + + + + Its trusted publisher already matches — it will be left alone + + + + + + It has no trusted publisher — one will be created for {repository} + + + ## Reserving the name @@ -496,64 +559,85 @@ throw new Error(`${packageName} changed during the prompt; refusing to publish o -```bash silent exec +The write is bound the way the reads are. `exec as="…"` keeps a successful +publish quiet without discarding what npm said, so a refusal is reported in +npm's own words rather than as an exit code with the reason thrown away. + +```bash exec as="publishResult" npm_config_otp={otp.code} npm publish --access public --tag bootstrap --registry {registry} ``` - Reserved {packageName}@{bootstrapVersion} under the bootstrap dist-tag - - +```ts eval +const publishDetail = indent( + publishResult.stderr.trim() || publishResult.stdout.trim(), +); +``` - + -## Granting this repository publish rights +npm refused to publish `{packageName}@{bootstrapVersion}`: -npm holds one trusted publisher per package, so this is read again after your -code rather than assumed from earlier. If something was configured while you -were away, the run stops instead of replacing it. +{publishDetail} -```bash exec as="trustAfter" -npm trust list {packageName} --json --registry {registry} -``` +An expired code reads this way, and so does a version npm already carries — its +package reads can lag a publish by minutes, so a reservation this document +reported as missing may have been made moments ago. Nothing was configured. ```ts eval -const trustNow = classifyTrust(trustAfter, expectedTrust, false); +throw new Error(`${packageName}@${bootstrapVersion} was not published: ${publishResult.stderr.trim()}`); ``` - + -`{packageName}` gained a trusted publisher this document did not set up: + Reserved {packageName}@{bootstrapVersion} under the bootstrap dist-tag - {trustNow.summary} + -Nothing was revoked or replaced. The placeholder stands, so revoking this one -and running the document again finishes the remaining half: + - npm trust revoke {packageName} --id {trustNow.id} +## Granting this repository publish rights -```ts eval -throw new Error(`${packageName} gained a trusted publisher during the prompt; refusing to replace it`); -``` +The read above already happened after your code, and the only write since was +the placeholder, so it still describes what npm holds — there is no away-time to +re-read for. If a publisher is configured in the seconds between, npm refuses +the second one and the command below fails: the placeholder stands, and running +the document again reports what it found. - - - + {packageName} already trusts this repository — leaving it alone - + -```bash silent exec +```bash exec as="trustResult" npm_config_otp={otp.code} npm trust github {packageName} --file {workflowFile} --repository {repository} --environment {environment} --allow-publish --registry {registry} --yes ``` - Trusted {repository} via {workflowFile} in {environment}, publish only +```ts eval +const trustResultDetail = indent( + trustResult.stderr.trim() || trustResult.stdout.trim(), +); +``` + + + +npm refused to trust {repository} with `{packageName}`: + +{trustResultDetail} + +A publisher configured in the seconds since the read above reads this way, and +so does an expired code. The placeholder stands, so running the document again +picks up the half that is missing. + +```ts eval +throw new Error(`${packageName} did not gain a trusted publisher: ${trustResult.stderr.trim()}`); +``` - + Trusted {repository} via {workflowFile} in {environment}, publish only @@ -562,6 +646,24 @@ npm_config_otp={otp.code} npm trust github {packageName} --file {workflowFile} - Read back from npm after the writes, so what follows is what the registry says rather than what this document did. +npm accepts a publish before its package reads report it: a name it has just +started carrying answers 404 for a while, and that is the same answer it gives +for a name it does not carry at all. Reading straight away would fail a run that +worked, so the reads wait for the registry to carry the package first. `when` +asks until it does, for up to a minute — a package that was already there is +answered on the first ask and waits for nothing. + +```ts eval +yield* when(function* () { + const readiness = yield* fetch(`${registry}/${packageName}`, { method: "HEAD" }); + if (!readiness.ok) { + throw new Error( + `npm still answers ${readiness.status} for ${packageName}, a minute after the writes this run made were accepted`, + ); + } +}, { timeout: 60_000, interval: 2_000 }); +``` + ```bash exec as="finalVersions" npm view {packageName} versions --json --registry {registry} ``` @@ -571,7 +673,7 @@ npm view {packageName} dist-tags --json --registry {registry} ``` ```bash exec as="finalTrustList" -npm trust list {packageName} --json --registry {registry} +npm_config_otp={otp.code} npm trust list {packageName} --json --registry {registry} ``` ```ts eval @@ -580,6 +682,28 @@ const finalReservation = classifyReservation( finalDistTags, bootstrapVersion, ); +const finalTrust = classifyTrust(finalTrustList, expectedTrust, false); +``` + + + +npm carries `{packageName}` — the wait above ended because the registry said so +— but its package reads still report nothing published. The two answers come +from the same registry at different speeds, and this is the slower one not +having caught up yet. + +Nothing here failed and nothing is half done. Run this document again to read +the end state once npm agrees with itself. + +```ts eval +throw new Error( + `${packageName}: npm's package reads have not caught up with what it carries`, +); +``` + + + +```ts eval if (finalReservation.state !== "reserved") { throw new Error( `${packageName} is not reserved at ${bootstrapVersion} under the bootstrap dist-tag: ${ @@ -587,7 +711,6 @@ if (finalReservation.state !== "reserved") { }`, ); } -const finalTrust = classifyTrust(finalTrustList, expectedTrust, false); if (finalTrust.state !== "expected") { throw new Error( `${packageName} does not carry the expected trusted publisher: ${ @@ -607,6 +730,8 @@ document again: npm trust revoke {packageName} --id {finalTrust.id} + + ## Afterwards Create the matching package on JSR under the `@executablemd` scope and link it diff --git a/packages/cli/deno.json b/packages/cli/deno.json index 8430f981a..903c4c73c 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -1,6 +1,7 @@ { "name": "@executablemd/cli", "version": "0.8.0", + "license": "MIT", "exports": "./src/deno.ts", "imports": { "@standard-schema/spec": "npm:@standard-schema/spec@^1.0.0", diff --git a/packages/workflow/deno.json b/packages/workflow/deno.json index 365ce239b..6d0d113ec 100644 --- a/packages/workflow/deno.json +++ b/packages/workflow/deno.json @@ -1,6 +1,7 @@ { "name": "@executablemd/workflow", "version": "0.8.0", + "license": "MIT", "exports": { ".": "./mod.ts", "./deno": "./deno.ts" diff --git a/scripts/tests/bootstrap-npm-package.test.ts b/scripts/tests/bootstrap-npm-package.test.ts index cdce63c9c..4acb97130 100644 --- a/scripts/tests/bootstrap-npm-package.test.ts +++ b/scripts/tests/bootstrap-npm-package.test.ts @@ -8,7 +8,8 @@ * * Substitution happens only at contextual Api boundaries: `Env.cwd` for the * workspace the document reads, `Elicitation` for the question, `API.Process` - * for the code blocks. The document's own shell runs for real under bash, and + * for the code blocks, and `FetchApi` for the readiness probe the confirming + * reads wait on. The document's own shell runs for real under bash, and * its manifests, artifact, comparisons and branching are the real components * and eval blocks. Only `npm` is replaced, by a shell function on `BASH_ENV`. * Nothing here contacts a registry. @@ -25,6 +26,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { FetchApi } from "@effectionx/fetch"; +import type { FetchResponse } from "@effectionx/fetch"; + import { InMemoryStream } from "@executablemd/durable-streams"; import { API, useHostFiles } from "@executablemd/runtime"; import { Elicitation, execute, useTempFileCompiler } from "@executablemd/core"; @@ -101,7 +105,7 @@ npm() { ;; view) state="$(cat "$NPM_STATE_FILE")" - if [ "$state" = "missing" ]; then + if [ "\${NPM_VIEW_ALWAYS_MISSING-}" = "1" ] || [ "$state" = "missing" ]; then echo "npm error code E404" >&2 return 1 fi @@ -136,6 +140,12 @@ npm() { trust) case "$2" in list) + if [ "\${NPM_TRUST_LIST_FAILS-}" = "1" ]; then + echo '{"error":{"code":"EOTP","summary":"This operation requires a one-time password."}}' + echo "npm error code EOTP" >&2 + echo "npm error This operation requires a one-time password." >&2 + return 1 + fi if [ "$(cat "$NPM_STATE_FILE")" = "missing" ]; then echo "npm error code E404" >&2 return 1 @@ -204,6 +214,39 @@ function useFixture(): Operation { }); } +/** + * What the registry answers the document's readiness probe. + * + * The probe reads a status and nothing else — it never touches the body — so + * the response is built from a real `Response` and carries the fields that + * status is read through. Consuming one of the body operations is a mistake + * this substitute is entitled to make loudly rather than answer. + */ +function readinessResponse(status: number, url: string): FetchResponse { + const raw = new Response(null, { status }); + const noBody = () => { + throw new Error("the readiness probe reads no body"); + }; + return { + raw, + bodyUsed: false, + ok: raw.ok, + status: raw.status, + statusText: raw.statusText, + headers: raw.headers, + url, + redirected: false, + type: raw.type, + json: noBody, + text: noBody, + arrayBuffer: noBody, + blob: noBody, + formData: noBody, + body: noBody, + expect: noBody, + } as unknown as FetchResponse; +} + /** One recorded call to the fake npm. */ interface NpmCall { args: string; @@ -241,6 +284,12 @@ interface RunOptions { packFails?: boolean; publishFails?: boolean; trustFails?: boolean; + /** Makes `npm trust list` fail for a reason that is not a missing package. */ + trustListFails?: boolean; + /** Makes `npm view` report nothing published however the registry answers. */ + viewAlwaysMissing?: boolean; + /** How many readiness probes answer 404 before the registry reports ready. */ + probeMisses?: number; /** How the provider behaves: answer correctly, fail, or break its schema. */ elicit?: "answer" | "throw" | "invalid"; /** Overrides the `package` prop, for the input-validation cases. */ @@ -254,6 +303,8 @@ interface Run { output: string; calls: NpmCall[]; requests: ElicitationRequest[]; + /** Every URL the document's readiness probe asked for. */ + probes: string[]; /** Every command the Process Api was asked to run, interception included. */ execCount: number; } @@ -269,6 +320,7 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { yield* writeTextFile(fixture.logFile, ""); const requests: ElicitationRequest[] = []; + const probes: string[] = []; const counter = { execs: 0 }; // Installed on this scope, not inside a resource: middleware installs on the @@ -313,6 +365,22 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { { at: "min" }, ); + // The document's readiness probe is a real HTTP request, and the fake npm + // is a shell function it cannot reach. Answering it from the same state + // file keeps the two agreeing, and keeps this suite off the network. + yield* FetchApi.around( + { + *fetch([input]) { + const url = String(input); + probes.push(url); + const lagging = probes.length <= (options.probeMisses ?? 0); + const missing = (yield* readTextFile(fixture.stateFile)) === "missing"; + return readinessResponse(lagging || missing ? 404 : 200, url); + }, + }, + { at: "min" }, + ); + yield* API.Process.around({ *exec([execOptions], next) { counter.execs++; @@ -330,6 +398,8 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { NPM_PACK_FAILS: options.packFails ? "1" : "", NPM_PUBLISH_FAILS: options.publishFails ? "1" : "", NPM_TRUST_FAILS: options.trustFails ? "1" : "", + NPM_TRUST_LIST_FAILS: options.trustListFails ? "1" : "", + NPM_VIEW_ALWAYS_MISSING: options.viewAlwaysMissing ? "1" : "", }, }); }, @@ -363,6 +433,7 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { output: chunks.join(""), calls: parseLog(yield* readLog(fixture)), requests, + probes, execCount: counter.execs, }; }); @@ -510,8 +581,11 @@ describe("bootstrap an npm package", () => { expect(called(result, "publish")).toBe(false); expect(called(result, "trust github")).toBe(false); expect(result.output).toContain("Its trusted publisher already matches"); - // Nothing to write, so nothing is asked for and no artifact is built. - expect(result.requests.length).toBe(0); + // It still asks: npm answers `trust list` only with a code, so a run + // cannot establish that there is nothing to do without one. Nothing is + // written on that answer, and no artifact is built for a publish that + // will not happen. + expect(result.requests.length).toBe(1); expect(called(result, "pack")).toBe(false); // It still reads the registry back and reports the end state. expect(result.output).toContain("is the only published version"); @@ -594,7 +668,9 @@ describe("bootstrap an npm package", () => { expect(result.requests.length).toBe(0); expectNoRegistryMutation(result); }); + }); + describe("refusing after the question, before the registry", () => { for (const [description, foreign] of Object.entries(FOREIGN_TRUST)) { it(`refuses a trusted publisher with ${description}, and revokes nothing`, function* () { const fixture = yield* useFixture(); @@ -606,15 +682,40 @@ describe("bootstrap an npm package", () => { expect(result.output).toContain( "npm trust revoke @executablemd/fixture --id a2479f35-0000-4000-8000-000000000000", ); - expect(result.requests.length).toBe(0); + // The code buys the answer this refusal turns on, and buys nothing + // else: it is spent reading, and the run stops before either write. + expect(result.requests.length).toBe(1); expectNoRegistryMutation(result); // Whatever was there is still there. expect(yield* readTextFile(fixture.trustFile)).toBe(foreign); }); } - }); - describe("refusing after the question, before the registry", () => { + it("reports npm's own error when it will not say what the package trusts", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { + state: "bootstrap", + trust: EXPECTED_TRUST, + trustListFails: true, + }); + + expect(result.ok).toBe(false); + expect(result.output).toContain("npm did not answer what `@executablemd/fixture` trusts"); + // npm's diagnostic reaches both the page and the failure, rather than + // being replaced by a guess about what it meant. + expect(result.output).toContain("This operation requires a one-time password"); + expect(result.failure).toContain("EOTP"); + // A read that failed is not a publisher that conflicts. Reporting one + // sends the operator to revoke a configuration by an id nobody read — + // and here, to revoke the very configuration this document installs. + expect(result.output).not.toContain( + "already trusts a publisher this document did not set up", + ); + expect(result.output).not.toContain("trust revoke"); + expect(result.requests.length).toBe(1); + expectNoRegistryMutation(result); + }); + it("stops when the provider cannot reach anyone", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { elicit: "throw" }); @@ -730,15 +831,65 @@ describe("bootstrap an npm package", () => { }); }); + describe("waiting out a registry that has not caught up", () => { + it("asks the registry for this package before reading it back", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { state: "missing", trust: "" }); + + expect(result.ok).toBe(true); + // Named, so a gate that stopped running cannot pass by doing nothing. + expect(result.probes.length).toBeGreaterThan(0); + expect(result.probes[0]).toContain("@executablemd/fixture"); + }); + + it("keeps asking until the registry carries the package it just wrote", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { + state: "missing", + trust: "", + probeMisses: 2, + }); + + // The run that started this: both writes land, the registry has not + // caught up, and the old document called that a failed publish. + expect(result.failure).toBe(""); + expect(result.ok).toBe(true); + expect(result.probes.length).toBe(3); + expect(result.output).toContain("is the only published version"); + }); + + it("says the reads are behind rather than that nothing was published", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { + state: "missing", + trust: "", + viewAlwaysMissing: true, + }); + + expect(result.ok).toBe(false); + // Both writes happened; only the reading half is behind. + expect(called(result, "publish --access public --tag bootstrap")).toBe(true); + expect(called(result, "trust github @executablemd/fixture")).toBe(true); + expect(result.output).toContain("package reads still report nothing published"); + expect(result.output).toContain("Nothing here failed and nothing is half done"); + // The claim the old message made, and the one the operator acted on. + expect(result.output).not.toContain("is not reserved at"); + }); + }); + describe("what the document guarantees about its own shape", () => { - it("carries one code to the two authenticated commands and nowhere else", function* () { + it("carries one code to the authenticated commands and nowhere else", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "missing", trust: "" }); + // Three commands need it, and one answer covers all three: the two + // writes, and the read npm will not answer without it. + expect(callTo(result, "trust list")?.otp).toBe(CODE); expect(callTo(result, "publish")?.otp).toBe(CODE); expect(callTo(result, "trust github")?.otp).toBe(CODE); + const authenticated = ["publish", "trust github", "trust list"]; const unauthenticated = result.calls.filter( - (call) => !call.args.startsWith("publish") && !call.args.startsWith("trust github"), + (call) => !authenticated.some((prefix) => call.args.startsWith(prefix)), ); expect(unauthenticated.length).toBeGreaterThan(0); expect(unauthenticated.every((call) => call.otp === "")).toBe(true); diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 85ef35419..0cc8fc976 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -308,19 +308,34 @@ one-time code is requested. The reservation artifact is written as Markdown rather than assembled by a shell: `` writes the `package.json` and `README.md` into a temporary working directory, and `npm pack --dry-run` previews that exact directory before -anything is published from it. Every registry read is an `exec as="…"` block, so -what the command settled to — exit code and both channels — is bound as a value: -npm reports a package it does not carry by exiting non-zero, and here that is an -answer rather than a failure. The comparison happens in the document and `` -selects what happens next, so what the document decided is readable in what it -rendered. - -The one-time password is requested only when something will be written, and only -after the preview succeeds. A run that refuses asks for nothing, and a re-run -against a package that is already reserved and already trusted asks for nothing -either — it reads the registry, reports the end state, and stops. A package that -needs only one of the two halves is asked for a code but builds no artifact, -since there is nothing to publish for a preview to show. +anything is published from it. Every registry command — both reads and both +writes — is an `exec as="…"` block, so what it settled to, exit code and both +channels, is bound as a value: npm reports a package it does not carry by +exiting non-zero, and here that is an answer rather than a failure. The +comparison happens in the document and `` selects what happens next, so what +the document decided is readable in what it rendered. That covers a refused +write as much as a 404: a publish npm rejects is reported in npm's own words, +where `silent` would have hidden both channels and left an exit code with the +reason discarded. + +The one-time password is requested after the preview succeeds and before the +trusted publisher is read. `npm trust list` needs a code to answer at all: npm +makes a package's trusted publisher readable only to someone who could change +it, so what a package already trusts cannot be established without one. Every +run that gets past the version reads is therefore asked for a code — including a +re-run against a package that is already reserved and already trusted, which +reads, reports the end state, and writes nothing. A package that needs only the +trust half is asked for a code but builds no artifact, since there is nothing to +publish for a preview to show. + +npm accepts a publish before its package reads report it, and while it catches +up a name it has just started carrying gives the same 404 as a name it does not +carry at all. Reading straight back would call a completed run a failed one, so +the confirming reads wait first: `when` asks the registry for the package until +it answers, for up to a minute, and a package that was already there is answered +on the first ask. The two answers come from one registry at different speeds, so +reads still behind after that wait are reported as exactly that — the writes +stand, and a re-run reports the end state. Re-running is safe, and the two halves are skipped independently: @@ -336,11 +351,19 @@ exists, it will result in an error" (`npm help trust`, npm 11.17), so a configuration that differs from §4's table stops the document, which reports what it found and revokes nothing; replacing one is a deliberate `npm trust revoke` by a scope owner, and the document prints that command with -the trust id it read. A version other than `0.0.0-bootstrap.0`, -or a `bootstrap` dist-tag pointing elsewhere, stops it the same way. Both -refusals happen before the code is requested, and the registry state is checked -again afterwards, because the operator is away generating a code while it can -change. +the trust id it read. A version other than `0.0.0-bootstrap.0`, or a `bootstrap` +dist-tag pointing elsewhere, stops it the same way. + +The version refusal happens before the code is requested; the trust refusal +happens after it, because the answer it turns on cannot be read without one. +Neither writes: the trusted publisher is read before the placeholder is +published, so a refusal leaves the registry exactly as it was. The versions are +read a second time after the prompt, because the operator is away generating a +code while they can change. The trusted publisher is not — its one read already +happened after the code, with only this document's own placeholder written +since, so a second read would have no away-time to cover. A publisher configured +in that gap makes `npm trust github` fail, which the run reports rather than +replaces. ## 7. Recovery From ffc1fd7e1257942e9a9a4f9a2697660b550d73aa Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:14:06 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=AF=20Let=20npm=20settle=20the=20r?= =?UTF-8?q?eservation=20instead=20of=20a=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No read decides whether to publish any more. A read is a claim about a moment that has passed, and npm accepts a write before its package reads report it — which is how a completed run reported itself failed, and how the re-run that followed planned a publish for a version that already existed. The placeholder is now always offered and npm's answer settles it. npm 11.17 asks the registry for the package's versions with preferOnline before uploading and refuses over one it already carries, with an uncoded error carrying "You cannot publish over the previously published versions: " (lib/commands/publish.js:175); a registry staler than that check refuses the upload instead, as EPUBLISHCONFLICT. Either is the answer "already reserved", from the party that decides it. That removes the readiness wait, the fetch it probed with, the confirming package reads, and the branch that chose between publishing and skipping. The two reads that remain are guards: a package carrying real versions is not one to offer a placeholder to, and neither read selects what happens next. The trust half stays read-first. npm's trust command adds no mapping for an existing configuration, so its failure shape could not be established without attempting one against a live package — and the read is also the only thing that can tell your configuration from someone else's, which is the refusal this document exists for. Also corrects the OIDC paragraph: it is the package that cannot be created from CI, not the repository. --- components/BootstrapNpmPackage.md | 167 +++++++++----------- scripts/tests/bootstrap-npm-package.test.ts | 163 ++++++++----------- specs/release-process-spec.md | 46 ++++-- 3 files changed, 169 insertions(+), 207 deletions(-) diff --git a/components/BootstrapNpmPackage.md b/components/BootstrapNpmPackage.md index 81a38a977..5ad157b12 100644 --- a/components/BootstrapNpmPackage.md +++ b/components/BootstrapNpmPackage.md @@ -24,11 +24,12 @@ props: Before the CI workflow can publish packages to npm, the package needs to be published manually once. -CI authenticates with OIDC: GitHub Actions mints a short-lived identity token -for the workflow run, and npm accepts it because the package lists this -repository as a trusted publisher. Nothing is stored as a secret anywhere. The -catch is that npm only lets you configure a trusted publisher on a package that -already exists — so the first version can't come from CI. +CI authenticates with OIDC: an npm token is not stored in GitHub Actions because +GitHub Actions generates a short-lived identity token for the workflow run. npm +accepts the generated token because the package lists this repository as a +trusted publisher. A package must exist on npm before a trusted publisher can be +configured. This means that we cannot create the package from CI — instead it +has to be created locally first. Publishing by hand uses the other route: your npm login plus a one-time password (OTP), the six-digit code from your authenticator app. This document asks for @@ -177,6 +178,34 @@ function permissionLabel(permission) { return permission; } +/** + * What the publish settled to, which is what decides whether the name was + * reserved here or was already reserved — no read decides it. + * + * npm 11.17 asks the registry for the package's versions with `preferOnline` + * before it uploads anything, and refuses over one it already carries with an + * uncoded `Error` carrying this sentence (`lib/commands/publish.js`). A + * registry that answered that check staler than itself refuses the upload + * instead, and that one arrives as `EPUBLISHCONFLICT`. Both say the same + * thing: the reservation is there, and this run did not have to make it. + */ +function classifyPublish(publishResult, version) { + if (publishResult.exitCode === 0) { + return { state: "reserved", detail: "" }; + } + const said = `${publishResult.stderr}\n${publishResult.stdout}`; + if ( + said.includes(`You cannot publish over the previously published versions: ${version}`) || + /EPUBLISHCONFLICT/.test(said) + ) { + return { state: "already", detail: "" }; + } + return { + state: "failed", + detail: publishResult.stderr.trim() || publishResult.stdout.trim(), + }; +} + function classifyReservation(versionsResult, distTagsResult, version) { if (versionsResult.exitCode !== 0) { if (/E404|404 Not Found/.test(versionsResult.stderr)) { @@ -321,7 +350,7 @@ throw new Error(`${packageName}: ${reservation.detail}`); - {packageName} is already reserved at {bootstrapVersion} — the publish will be skipped + {packageName} already carries {bootstrapVersion} — the publish below will say so @@ -333,8 +362,6 @@ throw new Error(`${packageName}: ${reservation.detail}`); - - ## Previewing the artifact The placeholder declares no dependencies at all, and that is deliberate: a @@ -412,8 +439,6 @@ const packDisplay = (() => { {packDisplay} - - ## Authenticating Everything up to now has only read, and nothing below it has written yet. The @@ -515,13 +540,11 @@ throw new Error(`${packageName}: ${trust.detail}`); - - ## Reserving the name -The registry is read once more rather than trusting what it said before the -prompt. You have been away with your authenticator, and the package may have -gained a version in the meantime. +The registry is read once more, but only to refuse: you have been away with your +authenticator, and a package that gained real versions in the meantime is not +one to publish a placeholder into. ```bash exec as="versionsAfter" npm view {packageName} versions --json --registry {registry} @@ -551,48 +574,50 @@ throw new Error(`${packageName} changed during the prompt; refusing to publish o - - - Someone else reserved {packageName}@{bootstrapVersion} while you were answering — skipping the publish - - +Nothing above decides whether to publish. A read is a claim about a moment that +has passed, and npm's package reads lag its writes — a reservation this document +found missing may have been made since, and one it found may not be visible yet. +So the publish is attempted either way, and what npm answers is what settles it: +npm asks the registry for the package's versions before it uploads anything, and +refuses over one it already carries. That refusal is the same answer a read +would have given, from the party that decides it. - - -The write is bound the way the reads are. `exec as="…"` keeps a successful -publish quiet without discarding what npm said, so a refusal is reported in -npm's own words rather than as an exit code with the reason thrown away. +`exec as="…"` keeps a successful publish quiet without discarding what npm said, +so each of the three answers is reported in npm's own terms. ```bash exec as="publishResult" npm_config_otp={otp.code} npm publish --access public --tag bootstrap --registry {registry} ``` ```ts eval -const publishDetail = indent( - publishResult.stderr.trim() || publishResult.stdout.trim(), -); +const publication = classifyPublish(publishResult, bootstrapVersion); +const publishDetail = indent(publication.detail); ``` - + npm refused to publish `{packageName}@{bootstrapVersion}`: {publishDetail} -An expired code reads this way, and so does a version npm already carries — its -package reads can lag a publish by minutes, so a reservation this document -reported as missing may have been made moments ago. Nothing was configured. +An expired code reads this way. Nothing was configured. ```ts eval -throw new Error(`${packageName}@${bootstrapVersion} was not published: ${publishResult.stderr.trim()}`); +throw new Error(`${packageName}@${bootstrapVersion} was not published: ${publication.detail}`); ``` + + Reserved {packageName}@{bootstrapVersion} under the bootstrap dist-tag + + + {packageName}@{bootstrapVersion} was already reserved — npm refused a second copy, so the reservation stands + ## Granting this repository publish rights @@ -643,85 +668,43 @@ throw new Error(`${packageName} did not gain a trusted publisher: ${trustResult. ## Confirming -Read back from npm after the writes, so what follows is what the registry says -rather than what this document did. +Both writes have answered, and each answer came from the party that decides it: +npm settled the reservation by accepting the publish or refusing it over a +version it already carries, and settled the trust by creating it. Neither needs +a package read to confirm it, and a package read is the one thing here that can +disagree with what just happened — npm accepts a write before its reads report +it. -npm accepts a publish before its package reads report it: a name it has just -started carrying answers 404 for a while, and that is the same answer it gives -for a name it does not carry at all. Reading straight away would fail a run that -worked, so the reads wait for the registry to carry the package first. `when` -asks until it does, for up to a minute — a package that was already there is -answered on the first ask and waits for nothing. - -```ts eval -yield* when(function* () { - const readiness = yield* fetch(`${registry}/${packageName}`, { method: "HEAD" }); - if (!readiness.ok) { - throw new Error( - `npm still answers ${readiness.status} for ${packageName}, a minute after the writes this run made were accepted`, - ); - } -}, { timeout: 60_000, interval: 2_000 }); -``` - -```bash exec as="finalVersions" -npm view {packageName} versions --json --registry {registry} -``` - -```bash exec as="finalDistTags" -npm view {packageName} dist-tags --json --registry {registry} -``` +So what is read back is the trusted publisher, which is not served from that +cache and answered truthfully throughout: it carries the id that revokes this +configuration, which is worth printing while you are here. ```bash exec as="finalTrustList" npm_config_otp={otp.code} npm trust list {packageName} --json --registry {registry} ``` ```ts eval -const finalReservation = classifyReservation( - finalVersions, - finalDistTags, - bootstrapVersion, -); const finalTrust = classifyTrust(finalTrustList, expectedTrust, false); +const finalTrustDetail = indent(finalTrust.detail); ``` - + -npm carries `{packageName}` — the wait above ended because the registry said so -— but its package reads still report nothing published. The two answers come -from the same registry at different speeds, and this is the slower one not -having caught up yet. +npm does not report the trusted publisher this run left behind: -Nothing here failed and nothing is half done. Run this document again to read -the end state once npm agrees with itself. +{finalTrustDetail} ```ts eval throw new Error( - `${packageName}: npm's package reads have not caught up with what it carries`, + `${packageName} does not carry the expected trusted publisher: ${ + finalTrust.detail || finalTrust.state + }`, ); ``` -```ts eval -if (finalReservation.state !== "reserved") { - throw new Error( - `${packageName} is not reserved at ${bootstrapVersion} under the bootstrap dist-tag: ${ - finalReservation.detail || finalReservation.state - }`, - ); -} -if (finalTrust.state !== "expected") { - throw new Error( - `${packageName} does not carry the expected trusted publisher: ${ - finalTrust.detail || finalTrust.state - }`, - ); -} -``` - - {bootstrapVersion} is the only published version - bootstrap → {bootstrapVersion} is the only dist-tag + {bootstrapVersion} is reserved under the bootstrap dist-tag {finalTrust.summary} If you ever need to undo the trust configuration — the workflow moves, the diff --git a/scripts/tests/bootstrap-npm-package.test.ts b/scripts/tests/bootstrap-npm-package.test.ts index 4acb97130..b89d12803 100644 --- a/scripts/tests/bootstrap-npm-package.test.ts +++ b/scripts/tests/bootstrap-npm-package.test.ts @@ -8,8 +8,7 @@ * * Substitution happens only at contextual Api boundaries: `Env.cwd` for the * workspace the document reads, `Elicitation` for the question, `API.Process` - * for the code blocks, and `FetchApi` for the readiness probe the confirming - * reads wait on. The document's own shell runs for real under bash, and + * for the code blocks. The document's own shell runs for real under bash, and * its manifests, artifact, comparisons and branching are the real components * and eval blocks. Only `npm` is replaced, by a shell function on `BASH_ENV`. * Nothing here contacts a registry. @@ -26,9 +25,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { FetchApi } from "@effectionx/fetch"; -import type { FetchResponse } from "@effectionx/fetch"; - import { InMemoryStream } from "@executablemd/durable-streams"; import { API, useHostFiles } from "@executablemd/runtime"; import { Elicitation, execute, useTempFileCompiler } from "@executablemd/core"; @@ -135,6 +131,10 @@ npm() { publish) echo 'npm notice publishing bootstrap artifact' if [ "\${NPM_PUBLISH_FAILS-}" = "1" ]; then return 1; fi + if [ "$(cat "$NPM_STATE_FILE")" != "missing" ]; then + echo "npm error You cannot publish over the previously published versions: ${BOOTSTRAP_VERSION}." >&2 + return 1 + fi printf 'bootstrap' > "$NPM_STATE_FILE" ;; trust) @@ -214,39 +214,6 @@ function useFixture(): Operation { }); } -/** - * What the registry answers the document's readiness probe. - * - * The probe reads a status and nothing else — it never touches the body — so - * the response is built from a real `Response` and carries the fields that - * status is read through. Consuming one of the body operations is a mistake - * this substitute is entitled to make loudly rather than answer. - */ -function readinessResponse(status: number, url: string): FetchResponse { - const raw = new Response(null, { status }); - const noBody = () => { - throw new Error("the readiness probe reads no body"); - }; - return { - raw, - bodyUsed: false, - ok: raw.ok, - status: raw.status, - statusText: raw.statusText, - headers: raw.headers, - url, - redirected: false, - type: raw.type, - json: noBody, - text: noBody, - arrayBuffer: noBody, - blob: noBody, - formData: noBody, - body: noBody, - expect: noBody, - } as unknown as FetchResponse; -} - /** One recorded call to the fake npm. */ interface NpmCall { args: string; @@ -288,8 +255,6 @@ interface RunOptions { trustListFails?: boolean; /** Makes `npm view` report nothing published however the registry answers. */ viewAlwaysMissing?: boolean; - /** How many readiness probes answer 404 before the registry reports ready. */ - probeMisses?: number; /** How the provider behaves: answer correctly, fail, or break its schema. */ elicit?: "answer" | "throw" | "invalid"; /** Overrides the `package` prop, for the input-validation cases. */ @@ -303,8 +268,6 @@ interface Run { output: string; calls: NpmCall[]; requests: ElicitationRequest[]; - /** Every URL the document's readiness probe asked for. */ - probes: string[]; /** Every command the Process Api was asked to run, interception included. */ execCount: number; } @@ -320,7 +283,6 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { yield* writeTextFile(fixture.logFile, ""); const requests: ElicitationRequest[] = []; - const probes: string[] = []; const counter = { execs: 0 }; // Installed on this scope, not inside a resource: middleware installs on the @@ -365,22 +327,6 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { { at: "min" }, ); - // The document's readiness probe is a real HTTP request, and the fake npm - // is a shell function it cannot reach. Answering it from the same state - // file keeps the two agreeing, and keeps this suite off the network. - yield* FetchApi.around( - { - *fetch([input]) { - const url = String(input); - probes.push(url); - const lagging = probes.length <= (options.probeMisses ?? 0); - const missing = (yield* readTextFile(fixture.stateFile)) === "missing"; - return readinessResponse(lagging || missing ? 404 : 200, url); - }, - }, - { at: "min" }, - ); - yield* API.Process.around({ *exec([execOptions], next) { counter.execs++; @@ -433,7 +379,6 @@ function run(fixture: Fixture, options: RunOptions = {}): Operation { output: chunks.join(""), calls: parseLog(yield* readLog(fixture)), requests, - probes, execCount: counter.execs, }; }); @@ -547,28 +492,27 @@ describe("bootstrap an npm package", () => { expect(trustAt).toBeGreaterThan(publishAt); }); - it("skips the publish for an existing reservation and installs the missing trust", function* () { + it("installs the missing trust on a reservation that is already there", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "bootstrap", trust: "" }); expect(result.ok).toBe(true); - expect(called(result, "publish")).toBe(false); expect(called(result, "trust github @executablemd/fixture")).toBe(true); expect(result.output).toContain( - "@executablemd/fixture is already reserved at 0.0.0-bootstrap.0 — the publish will be skipped", + "@executablemd/fixture already carries 0.0.0-bootstrap.0 — the publish below will say so", ); }); - it("asks for a code but builds no artifact when only the trust is missing", function* () { + it("previews the artifact it will attempt, whatever the read said", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "bootstrap", trust: "" }); expect(result.ok).toBe(true); - // There is something to write, so it asks. expect(result.requests.length).toBe(1); - // But nothing to publish, so it previews no artifact that will not ship. - expect(called(result, "pack")).toBe(false); - expect(called(result, "publish")).toBe(false); + // The publish is attempted either way, so the preview always shows the + // artifact that will be offered — there is no plan for it to disagree with. + expect(called(result, "pack")).toBe(true); + expect(called(result, "publish")).toBe(true); expect(called(result, "trust github @executablemd/fixture")).toBe(true); }); @@ -578,17 +522,16 @@ describe("bootstrap an npm package", () => { expect(result.failure).toBe(""); expect(result.ok).toBe(true); - expect(called(result, "publish")).toBe(false); + // The publish is offered and refused, so nothing is written by it; the + // trust is not offered at all, because the read said it is already ours. + expect(result.output).toContain("was already reserved — npm refused a second copy"); expect(called(result, "trust github")).toBe(false); expect(result.output).toContain("Its trusted publisher already matches"); // It still asks: npm answers `trust list` only with a code, so a run - // cannot establish that there is nothing to do without one. Nothing is - // written on that answer, and no artifact is built for a publish that - // will not happen. + // cannot establish that there is nothing to do without one. expect(result.requests.length).toBe(1); - expect(called(result, "pack")).toBe(false); - // It still reads the registry back and reports the end state. - expect(result.output).toContain("is the only published version"); + // It still reports the end state it read back. + expect(result.output).toContain("is reserved under the bootstrap dist-tag"); }); }); @@ -785,7 +728,7 @@ describe("bootstrap an npm package", () => { expect(yield* readTextFile(fixture.trustFile)).toBe(foreign); }); - it("adopts a reservation somebody else completed, without publishing again", function* () { + it("adopts a reservation somebody else completed while you answered", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "missing", @@ -794,7 +737,9 @@ describe("bootstrap an npm package", () => { }); expect(result.ok).toBe(true); - expect(called(result, "publish")).toBe(false); + // npm refuses the publish it was offered, which is how the document + // learns the reservation arrived while the operator was away. + expect(result.output).toContain("was already reserved — npm refused a second copy"); expect(called(result, "trust github @executablemd/fixture")).toBe(true); }); }); @@ -825,40 +770,39 @@ describe("bootstrap an npm package", () => { expect(again.failure).toBe(""); expect(again.ok).toBe(true); - expect(called(again, "publish")).toBe(false); + expect(again.output).toContain("was already reserved — npm refused a second copy"); expect(called(again, "trust github @executablemd/fixture")).toBe(true); expect(yield* readTextFile(fixture.trustFile)).toBe(EXPECTED_TRUST); }); }); - describe("waiting out a registry that has not caught up", () => { - it("asks the registry for this package before reading it back", function* () { + describe("letting the write settle the reservation", () => { + it("reserves the name when npm accepts the publish", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "missing", trust: "" }); expect(result.ok).toBe(true); - // Named, so a gate that stopped running cannot pass by doing nothing. - expect(result.probes.length).toBeGreaterThan(0); - expect(result.probes[0]).toContain("@executablemd/fixture"); + expect(result.output).toContain( + "Reserved @executablemd/fixture@0.0.0-bootstrap.0 under the bootstrap dist-tag", + ); }); - it("keeps asking until the registry carries the package it just wrote", function* () { + it("takes npm's refusal over an existing version as the reservation", function* () { const fixture = yield* useFixture(); - const result = yield* run(fixture, { - state: "missing", - trust: "", - probeMisses: 2, - }); + const result = yield* run(fixture, { state: "bootstrap", trust: "" }); - // The run that started this: both writes land, the registry has not - // caught up, and the old document called that a failed publish. + // The run that broke: a stale read planned a publish for a version that + // was already there. npm refusing it is the answer, not a failure. expect(result.failure).toBe(""); expect(result.ok).toBe(true); - expect(result.probes.length).toBe(3); - expect(result.output).toContain("is the only published version"); + expect(called(result, "publish --access public --tag bootstrap")).toBe(true); + expect(result.output).toContain( + "@executablemd/fixture@0.0.0-bootstrap.0 was already reserved — npm refused a second copy", + ); + expect(called(result, "trust github @executablemd/fixture")).toBe(true); }); - it("says the reads are behind rather than that nothing was published", function* () { + it("confirms a publish it made without reading the package back", function* () { const fixture = yield* useFixture(); const result = yield* run(fixture, { state: "missing", @@ -866,14 +810,31 @@ describe("bootstrap an npm package", () => { viewAlwaysMissing: true, }); + // The other run that broke: `npm view` never reports the package, which + // is what a registry lagging its own writes looks like. Nothing reads it + // back, so nothing contradicts the publish that succeeded. + expect(result.failure).toBe(""); + expect(result.ok).toBe(true); + + // Structural, not incidental: no package read happens after the write. + const publishAt = result.calls.findIndex((call) => call.args.startsWith("publish")); + expect(publishAt).toBeGreaterThanOrEqual(0); + const readBack = result.calls.findIndex( + (call, index) => index > publishAt && call.args.startsWith("view"), + ); + expect(readBack).toBe(-1); + }); + + it("reports a publish npm refused for some other reason", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { state: "missing", trust: "", publishFails: true }); + expect(result.ok).toBe(false); - // Both writes happened; only the reading half is behind. - expect(called(result, "publish --access public --tag bootstrap")).toBe(true); - expect(called(result, "trust github @executablemd/fixture")).toBe(true); - expect(result.output).toContain("package reads still report nothing published"); - expect(result.output).toContain("Nothing here failed and nothing is half done"); - // The claim the old message made, and the one the operator acted on. - expect(result.output).not.toContain("is not reserved at"); + // A refusal that is not a conflict is a failure, and says so in npm's + // words rather than being read as "already reserved". + expect(result.output).toContain("npm refused to publish"); + expect(result.output).not.toContain("was already reserved"); + expect(called(result, "trust github")).toBe(false); }); }); diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 0cc8fc976..084eb0b12 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -318,29 +318,47 @@ write as much as a 404: a publish npm rejects is reported in npm's own words, where `silent` would have hidden both channels and left an exit code with the reason discarded. +No read decides whether to publish. A read is a claim about a moment that has +passed, and npm accepts a write before its package reads report it — so a +reservation a read finds missing may have been made since, and one it makes may +not be visible yet. Both failure modes were observed bootstrapping +`@executablemd/workflow`: a completed run reported as failed because the read +back still answered 404, and the re-run that followed planning a publish for a +version that already existed. The placeholder is therefore always offered, and +npm's answer settles it. npm 11.17 asks the registry for the package's versions +with `preferOnline` before uploading anything and refuses over one it already +carries with an uncoded error carrying `You cannot publish over the previously +published versions: ` (`lib/commands/publish.js`); a registry that +answered that check staler than itself refuses the upload instead, as +`EPUBLISHCONFLICT`. Either is the answer "already reserved", from the party that +decides it. Any other refusal is a failure and is reported as one. + +The reads that remain are guards, not decisions. A package carrying real +versions is not one to offer a placeholder to — a mistyped `--props-package` is +how that happens — so the versions and dist-tags are read before the code is +requested and again after it, and either read refuses. Neither selects what +happens next. + The one-time password is requested after the preview succeeds and before the trusted publisher is read. `npm trust list` needs a code to answer at all: npm makes a package's trusted publisher readable only to someone who could change it, so what a package already trusts cannot be established without one. Every run that gets past the version reads is therefore asked for a code — including a re-run against a package that is already reserved and already trusted, which -reads, reports the end state, and writes nothing. A package that needs only the -trust half is asked for a code but builds no artifact, since there is nothing to -publish for a preview to show. - -npm accepts a publish before its package reads report it, and while it catches -up a name it has just started carrying gives the same 404 as a name it does not -carry at all. Reading straight back would call a completed run a failed one, so -the confirming reads wait first: `when` asks the registry for the package until -it answers, for up to a minute, and a package that was already there is answered -on the first ask. The two answers come from one registry at different speeds, so -reads still behind after that wait are reported as exactly that — the writes -stand, and a re-run reports the end state. +reads, reports the end state, and writes nothing. The artifact is built on every run, because +every run offers it — the preview shows what will actually be attempted. + +Nothing reads the package back to confirm it. Each write already answered, and +that answer came from the party that decides it, so a package read could only +disagree with what just happened. What is read back is the trusted publisher, +which npm serves from an authenticated path rather than the packument cache and +which answered truthfully while the package reads were still behind — it carries +the id that revokes the configuration, which the document prints. Re-running is safe, and the two halves are skipped independently: -- a package already at `0.0.0-bootstrap.0` under the `bootstrap` dist-tag is not - published again; +- a package already at `0.0.0-bootstrap.0` is offered the placeholder again and + npm refuses it, which is how the run learns the reservation stands; - a trusted publisher already matching §4's table exactly — GitHub Actions, `taras/executable.md`, `publish-packages.yml`, `npm-publish`, and publish as its only permission — is not created again.