Skip to content

📦 Let a first release publish on both registries - #473

Merged
taras merged 2 commits into
mainfrom
fix/npm-bootstrap-otp-and-jsr-license
Aug 18, 2026
Merged

taras merged 2 commits into
mainfrom
fix/npm-bootstrap-otp-and-jsr-license

Conversation

@taras

@taras taras commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

Why

The 0.8.0 release failed to publish @executablemd/workflow on both registries
and took @executablemd/cli down with it
(run 32087568416).
workflow was the first package in this scope ever published, and neither
registry's path survived a first publish.

npm was then bootstrapped by hand, which surfaced three more defects in
components/BootstrapNpmPackage.md — one that made it unusable for any package,
and two that made a run that had worked report itself as failed.

What changes

JSR refused @executablemd/workflow for having no license.

No package declares license; they rely on deno publish pulling the
workspace-root LICENSE into each member's tarball, which it does — /LICENSE
is in the published manifests for core, runtime and web. workflow is the
exception: the vendored vendor/cloudflare-computer-dofs/LICENSE already
satisfies Deno's license-file search, so the root file is never pulled in. The
tarball carried a LICENSE at a nested path, JSR reads only the root, and the
publish was rejected — aborting the workspace publish and skipping cli.

Before: deno publish fails the whole workspace on workflow.
After: workflow and cli declare "license": "MIT", which does not depend on
file discovery.

The document asked npm for the trusted publisher before it had the credential
to read it.

Before: npm trust list ran in the pre-flight, above the <Elicit>. npm needs a
one-time password for that read — it makes the trusted publisher readable only to
someone who could change it — so the read exited EOTP, classifyTrust could
not match E404, and unreadable shared an <If> branch with conflicting.
Every run, for every package, ended in a false "already trusts a publisher this
document did not set up"
with an empty summary and an npm trust revoke … --id
carrying no id.

After: the version reads still refuse a live package before any code is asked
for; the trust read moved below the <Elicit> and carries the code, replacing
the post-prompt re-read it made redundant. unreadable has its own branch and
renders npm's own diagnostic.

A read decided whether to publish, and disagreed with reality in both
directions.

npm accepts a write before its package reads report it. So the confirming
npm view still answered 404 for the name just created, classifyReservation
correctly called that absent, and the guard turned it into
is not reserved at 0.0.0-bootstrap.0 … : absent — after both writes had landed.
The re-run then hit the same stale read, planned a publish for a version that
already existed, and npm's rejection was invisible because both writes were
silent exec, which routes stdout and stderr to hidden: bare
Command failed (exit 1).

After: no read decides whether to publish. The placeholder is 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
(lib/commands/publish.js:175), which is the same answer a read would have
given, from the party that decides it. Both writes are exec as="…" with
explicit failure branches, so a refusal is reported in npm's words.

How it works

npm view versions/dist-tags → refuse a live package (guard; decides nothing)
  → <Elicit otp>
  → npm trust list --otp → refuse a foreign publisher (nothing written yet)
  → npm publish → accepted = reserved | refused-over-version = already | else = failure
  → npm trust github
  → npm trust list → report the end state and the revoke id

The publish-conflict signal is npm's uncoded Error carrying
You cannot publish over the previously published versions: <version>, with
EPUBLISHCONFLICT as the registry-side fallback when npm's own check was stale.
Both are handled and the reasoning is recorded next to classifyPublish.

Review guide

Start with: components/BootstrapNpmPackage.md, ## Reserving the name

Then review:

  1. specs/release-process-spec.md §6 — the ordering guarantees that changed
  2. classifyPublish, and the three branches it selects
  3. ## Reading the trusted publisher — the moved read and the unreadable branch
  4. scripts/tests/bootstrap-npm-package.test.ts

Look carefully at:

  • The trust half is deliberately not inverted. See Intentionally unchanged.
  • One code now reaches three npm calls where it previously reached two.

What must stay true

  • A refusal writes nothing — the trust read is sequenced before npm publish,
    checked by the three refuses a trusted publisher with … cases.
  • A placeholder is never offered to a live package — enforced by the version
    reads before and after the prompt, checked by refuses a package already published at another version and refuses a package that gained a foreign version.
  • The code reaches the authenticated commands and nowhere else — checked by
    carries one code to the authenticated commands and nowhere else, which also
    asserts the code never reaches rendered output.
  • npm's own words survive a failure — checked by reports npm's own error when it will not say what the package trusts and reports a publish npm refused for some other reason.

How to verify it

  • takes npm's refusal over an existing version as the reservation proves a
    stale read no longer produces a false failure, and fails if the conflict is
    read as an error.
  • confirms a publish it made without reading the package back rigs npm view
    to never report the package and asserts no view call occurs after the
    publish — it fails if any read-back is reintroduced.
  • reports a publish npm refused for some other reason proves a non-conflict
    refusal is still a failure, and fails if classifyPublish over-matches.
  • Mutation-checked: replacing npm's conflict sentence with one it never prints
    fails 10 of the 43 steps.
deno test -A scripts/tests/bootstrap-npm-package.test.ts   # 43 steps
deno test -A scripts/tests/                                # 72 passed, 395 steps
deno task check:jsr

Scope

Included

  • license on packages/workflow and packages/cli
  • The document's ordering, error reporting, and the reservation inversion
  • specs/release-process-spec.md §6

Intentionally unchanged

  • The trust half stays read-first. npm's trust command adds no mapping for
    an existing configuration (lib/commands/trust/, lib/trust-cmd.js), so its
    failure shape could not be established without attempting a second
    configuration against a live package. The read is also the only thing that
    distinguishes our configuration from a foreign one, which is the refusal the
    document exists for.
  • The other eight members still declare no license and still rely on the root
    LICENSE reaching their tarballs, which works — only a package vendoring its
    own LICENSE is affected.
  • 0.0.0-bootstrap.0, the bootstrap dist-tag, and §4's trusted-publisher values.

Generated or mechanical changes

  • packages/workflow/deno.json and packages/cli/deno.json add one field each.

Risks and limitations

  • One code now has to survive three npm calls rather than two. The document is
    idempotent, so a code expiring partway is recovered by re-running.
  • The version guards can still be stale, so a package that gained real versions
    in a window this document cannot see would be offered a placeholder. npm's own
    preferOnline check catches the exact version but not that case; the guards
    narrow it, they do not close it.
  • Recovery: none needed — every refusal happens before a write, and a partial run
    is finished by re-running.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@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.
@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

PR #473: 📦 Let a first release publish on both registries

5 files, +422 / -159

Scope

🟡 581 lines changed. PRs under 400 receive more thorough review.

🟡 Large PR with no linked issue.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

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: <version>" (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.
@taras
taras enabled auto-merge (squash) August 18, 2026 03:16
@taras
taras merged commit 4c9a068 into main Aug 18, 2026
16 checks passed
@taras
taras deleted the fix/npm-bootstrap-otp-and-jsr-license branch August 18, 2026 03:27
@taras taras mentioned this pull request Aug 18, 2026
4 tasks done
taras added a commit that referenced this pull request Aug 18, 2026
0.8.0's JSR half cannot be recovered by re-running its jobs: tag v0.8.0
points at f3d777d, which predates the license fix in #473, and the jsr job
checks out the tag's own commit. npm carries all ten packages at 0.8.0;
JSR is missing @executablemd/workflow and @executablemd/cli.

Stamps every @executablemd manifest and restamps the matching workspace
entries in bun.lock, which the bump task does not touch (release spec §2).
packages/test-support stays at 0.0.0 — it is private and outside the
lockstep.

Co-authored-by: Taras Mankovski <74687+taras@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant