Skip to content

🐛 Build npm packages from their local release closure - #847

Merged
taras merged 3 commits into
mainfrom
agent/issue-843-local-npm-closure
Sep 24, 2026
Merged

taras merged 3 commits into
mainfrom
agent/issue-843-local-npm-closure

Conversation

@taras

@taras taras commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Closes #843.

Why

A tagged publish waited on npm indexing a sibling the previous job had just
published. scripts/build-npm.ts rewrote every workspace:* dependency to
^<version> before calling dnt, so each downstream package's build needed the
registry to already serve what its upstream job published moments earlier.
publish-one.yml bounded that with four attempts and 15-second sleeps.

It lost three releases running:

release job failure
v0.12.0 core 404 …/runtime-0.12.0.tgz, 38s after the sibling published
v0.12.1 cli ETARGET … test-agent@^0.12.1; the sibling indexed ~1 min after cli gave up
v0.13.1 core, then two tiers after it ETARGET … durable-streams@^0.13.1, four attempts in 53s

0.13.1 reached npm only after four rounds of rerunning failed jobs, and
@executablemd/cli had by then missed two releases outright.

What changes

Before: the builder resolved internal siblings from the public registry, and the
release workflow retried the build to wait that out.

After: the builder constructs the requested package's internal closure from the
same checkout and finalizes every manifest at the end, so no build asks npm for
a package from its own release. publish-one.yml builds once.

How it works

build-npm.ts <package> <version>
  → phase 1: depth-first closure, siblings handed to dnt as file:<dir>/npm
  → phase 2: every manifest rewritten to ^<version> together, then validated
  → publish-one.yml publishes the result

Phase 1 builds internal dependencies first, each at most once so a diamond
shares one artifact, and hands dnt absolute file: ranges naming the artifacts
this same invocation produced. Install and typecheck therefore resolve every
sibling from the working tree.

Phase 2 runs once the last dnt call has returned, replacing each internal
file: range with the sibling's ^<version> from the workspace manifests. All
of them together, not each package as its own build finishes — see What must
stay true
.

The result is publishable or the build fails. Every generated manifest is
inspected before any is written: a dependency range still starting with
workspace: or file:, or any string naming the checkout's path or file:
URL, fails the command. A known internal range is rewritten; anything else local
is an unexpected dependency, and normalizing it away is the one edit that would
make an unpublishable artifact look fine.

DNT_LOCAL_SIBLINGS is gone — the default path is what it used to provide, so
the artifact a developer builds is now the artifact a release publishes.
DNT_SKIP_INSTALL keeps its leaf-only contract unchanged.

Review guide

Start with: specs/release-process-spec.md, the "Building a package" section

Then review:

  1. scripts/build-npm.ts — buildNpmPackage, then finalizeClosure and
    unpublishable
  2. scripts/tests/build-npm.test.ts — N1–N5, especially usePackedLocalDependencies
  3. .github/workflows/publish-one.yml and its W1 assertion
  4. §3 and §6 of the release spec

Look carefully at:

  • unpublishable must refuse an unexpected local range rather than rewrite it.
    A blanket file: replacement would launder an unpublishable dependency.

What must stay true

  • Finalization is closure-wide, after the last dnt call. Rewriting a sibling
    when its own build finishes leaves it describing a dependency only the
    registry could supply; whether the dependent survives then depends on how npm
    installs a local directory and on that sibling's build residue. Enforced by
    finalizeClosure running once from buildNpmPackage, checked by N2 and N4.
  • Publication stays dependency-ordered. publish-packages.yml's needs:
    edges are untouched. They no longer serve the build, but a failed upstream
    publish must still withhold its dependents so whatever npm holds is
    dependency-closed. Checked by W2.
  • Rerun idempotency survives. Removing the retry must not remove the
    already-published guard. Checked by W1.

How to verify it

  • N1 proves dnt is handed the artifacts this invocation built, for the whole
    closure, and fails if only direct siblings are local.
  • N2 proves b still names c locally at a's build start and that no
    build event follows finalization. Finalizing per-package instead makes it fail.
  • N3 proves every finished manifest carries ^<version> and no local
    reference. It passes under per-package finalization, which is why N2 and N4
    exist.
  • N4 is the negative control, under a scoped install-links=true npm
    configuration — npm's packed layout, where a sibling's own manifest is the
    only statement of where its dependencies come from. A positive control runs
    first, then the same closure with b rewritten early: a reaches dnt, its
    install fails with npm install failed with exit code 1, and nothing
    finalizes. Setting install-links=false, or making the observer throw before
    its rewrite, both make it fail.
  • N5 proves an unmapped internal dependency is refused by name with no
    registry fallback, and that an unrelated external file: dependency is
    consumable during dnt but refused at the gate.
  • N6 proves the CLI and adapter journeys pass with no environment switch and
    that the CLI's whole closure is publishable after the built bin consumed it.
  • W1/W2 prove one builder invocation with no retry and a surviving
    already-published guard, and that needs: ordering remains.
  • S1 is the spec, reviewed as prose.
deno task test \
  scripts/tests/build-npm.test.ts \
  scripts/tests/cli-npm-bin.test.ts \
  scripts/tests/adapter-npm-package.test.ts \
  scripts/tests/publish-workflow-membership.test.ts \
  scripts/tests/publish-workflow-generator.test.ts

→ ok | 9 passed (30 steps) | 0 failed, plus deno task check, deno task lint
and git diff --check all exit 0.

Scope

Included

  • The two-phase builder, its publishable-manifest gate, and an importable
    entrypoint so the regression drives the real path.
  • publish-one.yml reduced to one build attempt.
  • Release spec §3, §6 and the build section.

Intentionally unchanged

New abstractions

  • buildNpmPackage + BuildEvent exist because the regression has to drive the
    real release path; the CLI is an adapter over the same operation, guarded by
    import.meta.main.
  • The observation callback is diagnostic only — it chooses no build, no
    resolution and no finalization policy, so a test cannot accidentally become a
    second implementation.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Risks and limitations

  • Every publish job now rebuilds its own closure, so shared dependencies are
    built more than once across the matrix. That is deliberate: each output is
    derived from the tag's checkout, with no artifact transfer or shared mutable
    workspace. Jobs get slower; the release stops depending on the registry clock.
  • A transient npm failure is now fatal to a job rather than retried. That is
    the point — the retry existed for propagation, which no longer happens — and
    spec §7 rerun recovery is unchanged. 🧪 dnt's npm install aborts with exit 134 during esbuild postinstall in cli-npm-bin #830's flake would still need a rerun.
  • Rollback is one revert: ordering and the already-published guard are intact,
    so reverting restores the former build without touching registry identities or
    versions.

Generated or mechanical changes

None. deno task gen:publish-workflow leaves no diff.

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.

A tagged publish waited on npm indexing a sibling the previous job had just
published, because the builder rewrote every `workspace:*` dependency to
`^<version>` before calling dnt. `publish-one.yml` bounded that wait with four
attempts and 15-second sleeps; the observed lag exceeded it on 0.12.0, 0.12.1
and 0.13.1, and 0.13.1 reached npm only after four rounds of rerunning failed
jobs.

`scripts/build-npm.ts` now builds in two phases. Phase 1 builds the requested
package's internal dependencies depth-first from the same checkout, each at most
once, and hands dnt absolute `file:` ranges naming those artifacts, so the
install and the type check never reach the registry for a package from this
release. Phase 2 finalizes every manifest in the closure together, once the last
dnt call has returned, replacing each internal `file:` range with the sibling's
`^<version>`. Together, not per package: in a chain A → B → C, rewriting B when
its own build finishes puts C's registry version back in front of A's install.

Before reporting success the builder refuses any dependency range still starting
with `workspace:` or `file:`, and any string naming the checkout's path — a
release gate, so an unexpected local dependency fails the build instead of being
normalized into something npm would accept. An internal dependency no workspace
member declares is refused by name, with no registry fallback.

`DNT_LOCAL_SIBLINGS` is gone: the default path is what it used to provide, and
the artifact a developer builds is now the artifact a release publishes.
`DNT_SKIP_INSTALL` keeps its leaf-only contract. `publish-one.yml` builds once
and keeps its already-published guard; `publish-packages.yml` keeps its
dependency-ordered `needs:`, which is now a publication guarantee rather than a
build one — a failed upstream still withholds its dependents, so whatever npm
holds is dependency-closed.

The builder is importable, so the regression drives the real path over a
three-member closure whose versions npm has never seen.
N4 was returned as unprovable, and that was wrong about the mechanism rather
than about the contract. npm symlinks a directory `file:` dependency by default,
so a dependent reaches whatever the sibling's own build left in its
`node_modules` and an early-finalized sibling costs nothing. npm's supported
`install-links=true` packs and installs it as an ordinary dependency instead,
which leaves the sibling's own manifest as the only statement of where its
dependencies come from — and that is the discriminator.

N4 now runs the real builder twice under an invocation-private npm
configuration: a positive control, so packed local artifacts are known to work
before a failure means anything, then the same closure with `b` rewritten to the
registry range the moment its build completes. `a`'s install fails, no manifest
is finalized, and `a` never completes. The registry it would have to reach is
unreachable and retries are off, so the failure is hermetic and immediate rather
than whatever npmjs.org happens to answer. The environment is restored however
the case ends; every other build in the file is the ordinary one.

Setting `install-links=false` makes the negative control pass, which is what
makes the scoped configuration load-bearing rather than decoration.

The three places that explained phase 2 by claiming an early rewrite puts a
registry version in front of the dependent's install now say what is actually
true: the cost of finalizing early depends on how npm installs a local directory
and on the sibling's own build residue, and finalizing the closure at the end is
what makes the build independent of both.
`expect(caught).toBeInstanceOf(Error)` accepted any throw, including one from
the observer that applies the mutation — so a negative control that never
reached `a` would have passed while proving nothing.

The build-start events must now be exactly `c`, `b`, `a`, which is what says the
mutation was applied and `a` entered dnt; an observer that threw leaves that list
one short. The caught error must be dnt's `npm install failed with exit code 1`,
which is what says it failed where a registry range has to be resolved rather
than anywhere else in the run. The proof that only `c` and `b` complete and that
finalization never starts is unchanged.

Making the observer throw before its rewrite now fails the case.
@taras
taras merged commit e9855a0 into main Sep 24, 2026
43 of 44 checks passed
@taras
taras deleted the agent/issue-843-local-npm-closure branch September 24, 2026 03:28
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.

Build tagged npm packages without waiting for sibling registry propagation

1 participant