diff --git a/components/BootstrapNpmPackage.md b/components/BootstrapNpmPackage.md index 16fe4d4a9..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 @@ -140,10 +141,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 +159,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"; @@ -163,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)) { @@ -271,17 +314,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,26 +332,25 @@ 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}`); ``` - {packageName} is already reserved at {bootstrapVersion} — the publish will be skipped + {packageName} already carries {bootstrapVersion} — the publish below will say so @@ -323,24 +360,8 @@ 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} - - - - - - - ## Previewing the artifact The placeholder declares no dependencies at all, and that is deliberate: a @@ -418,15 +439,17 @@ const packDisplay = (() => { {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,18 +470,81 @@ 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 -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} @@ -488,117 +574,137 @@ 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. - +`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 silent exec +```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 publication = classifyPublish(publishResult, bootstrapVersion); +const publishDetail = indent(publication.detail); +``` - + -## 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. Nothing was configured. ```ts eval -const trustNow = classifyTrust(trustAfter, expectedTrust, false); +throw new Error(`${packageName}@${bootstrapVersion} was not published: ${publication.detail}`); ``` - + -`{packageName}` gained a trusted publisher this document did not set up: + - {trustNow.summary} + Reserved {packageName}@{bootstrapVersion} under the bootstrap dist-tag -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} + -```ts eval -throw new Error(`${packageName} gained a trusted publisher during the prompt; refusing to replace it`); -``` + {packageName}@{bootstrapVersion} was already reserved — npm refused a second copy, so the reservation stands - +## Granting this repository publish rights + +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 ## 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. -```bash exec as="finalVersions" -npm view {packageName} versions --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="finalDistTags" -npm view {packageName} dist-tags --json --registry {registry} +```bash exec as="finalTrustList" +npm_config_otp={otp.code} npm trust list {packageName} --json --registry {registry} ``` -```bash exec as="finalTrustList" -npm trust list {packageName} --json --registry {registry} +```ts eval +const finalTrust = classifyTrust(finalTrustList, expectedTrust, false); +const finalTrustDetail = indent(finalTrust.detail); ``` + + +npm does not report the trusted publisher this run left behind: + +{finalTrustDetail} + ```ts eval -const finalReservation = classifyReservation( - finalVersions, - finalDistTags, - bootstrapVersion, +throw new Error( + `${packageName} does not carry the expected trusted publisher: ${ + finalTrust.detail || finalTrust.state + }`, ); -if (finalReservation.state !== "reserved") { - throw new Error( - `${packageName} is not reserved at ${bootstrapVersion} under the bootstrap dist-tag: ${ - finalReservation.detail || finalReservation.state - }`, - ); -} -const finalTrust = classifyTrust(finalTrustList, expectedTrust, false); -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 @@ -607,6 +713,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..b89d12803 100644 --- a/scripts/tests/bootstrap-npm-package.test.ts +++ b/scripts/tests/bootstrap-npm-package.test.ts @@ -101,7 +101,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 @@ -131,11 +131,21 @@ 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) 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 @@ -241,6 +251,10 @@ 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 the provider behaves: answer correctly, fail, or break its schema. */ elicit?: "answer" | "throw" | "invalid"; /** Overrides the `package` prop, for the input-validation cases. */ @@ -330,6 +344,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" : "", }, }); }, @@ -476,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); }); @@ -507,14 +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"); - // Nothing to write, so nothing is asked for and no artifact is built. - expect(result.requests.length).toBe(0); - 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 asks: npm answers `trust list` only with a code, so a run + // cannot establish that there is nothing to do without one. + expect(result.requests.length).toBe(1); + // It still reports the end state it read back. + expect(result.output).toContain("is reserved under the bootstrap dist-tag"); }); }); @@ -594,7 +611,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 +625,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" }); @@ -684,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", @@ -693,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); }); }); @@ -724,21 +770,87 @@ 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("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); + expect(result.output).toContain( + "Reserved @executablemd/fixture@0.0.0-bootstrap.0 under the bootstrap dist-tag", + ); + }); + + it("takes npm's refusal over an existing version as the reservation", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { state: "bootstrap", trust: "" }); + + // 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(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("confirms a publish it made without reading the package back", function* () { + const fixture = yield* useFixture(); + const result = yield* run(fixture, { + state: "missing", + trust: "", + 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); + // 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); + }); + }); + 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..084eb0b12 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -308,24 +308,57 @@ 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. + +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. 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. @@ -336,11 +369,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