Skip to content

feat(ci): the two pre-publish layers, with a declared allowance that has kinds - #35

Merged
NSchatz merged 1 commit into
mainfrom
feat/prepublish-install-layers
Aug 5, 2026
Merged

feat(ci): the two pre-publish layers, with a declared allowance that has kinds#35
NSchatz merged 1 commit into
mainfrom
feat/prepublish-install-layers

Conversation

@NSchatz

@NSchatz NSchatz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes the build half of PREPUBLISH-INSTALL-LAYERS.

A gate that fires after an irreversible act cannot gate. install-check.mjs runs after changeset publish, so by the time it speaks the version is on the registry and permanent (ADR 0001). It can report; it cannot prevent. These two run in ci.yml, on every pull request, before anything is published.

The two layers

Layer Input Default What it does
1 — manifest run-prepublish-manifest-lint on offline, no npm, no network: the consumer-facing dependency fields must carry only specifiers a registry can resolve
2 — pack run-prepublish-install off npm pack the tree, install that tarball into a clean anonymous directory, load what it declares

Layer 1 is on by default because it was measured. Run against all thirteen callers on 2026-08-05: 13/13 pass, zero findings, transform and synth included. The file:vendor/*.tgz specifiers that survive in seven repos are all in devDependencies, which no consumer installs and which the lint deliberately does not read. The backlog's claim that BOTH layers red transform/synth is false for this one. It is also the layer that would have refused @cosyte/cli@0.0.1: that tree carried those specifiers in dependencies at the moment it published, and every other gate read the same tree and passed it.

Layer 2 is off by default because turning it on for thirteen repos is a policy call, not a build. The mechanism is finished and measured; the flip is one word. Against all thirteen callers on 2026-08-05, live registry, default allowance: 11 pass, 2 blocked-peer (transform, synth), 0 red. To adopt it a caller adds run-prepublish-install: true — and transform and synth need nothing else, because the default allowance already names their blocker.

The declared allowance, and why its entries carry a kind

expect-unpublished-deps is the same input release.yml takes, in the same grammar. A repo names its known-unpublished consumer dependencies and the gate asserts exactly that set: a named absence is the expected state and warns, an absence nobody named still fails. Without it layer 2 reds transform and synth for @cosyte/fhir (FHIR-NPM-NAME), and a permanently red gate is one people learn to ignore — the same failure as a step that never runs. With an open-ended one you get a mute, which is worse than no gate because it reads as one.

@cosyte/assets and @cosyte/docs are E404 exactly like @cosyte/fhir is, and they are private: true by design. A deliberate absence and an unexplained block must not share words:

  • name=blocked — outside this repo's control, expected to clear. The only kind that excuses anything, and self-clearing.
  • name=private — absent by design, never going to clear. Excuses nothing, in either gate.
  • name — untagged, the spelling release.yml shipped with. Read as blocked, and reported so it can be tightened.

A malformed entry fails — including one name written twice with different kinds, because de-duplicating on the name alone silently widens the thing whose entire job is to excuse failures.

Both gates strip =blocked and only =blocked. install-check.mjs cannot fail closed on a typo, so any other tag stays attached, matches no dependency, and excuses nothing. Verified byte-identical to the untagged spelling for the one kind that matters.

It fails closed, which is the opposite of the gate it sits beside

Nothing irreversible has happened, a red pull-request check costs a re-run, and a pre-publish gate that swallowed its own crash would be a gate that is not there. What does not flip is a registry that will not answer — that stays three-valued and warns.

Gate

  • node --test "test/*.test.mjs"285/285
  • actionlint -shellcheck=shellcheck — exit 0
  • gate-refuter (opus/xhigh) — REFUTED → NOT REFUTED over two passes. Its nine pass-1 findings are all closed or answered in the diff; the sharpest was that stripping every tag quietly taught the post-publish gate to excuse =private, measured uninstallableblocked-peer on identical facts.

Two things measured while building it, each a red PR on a correct package

  • npm pack --json does not guarantee that stdout is only JSON. --ignore-scripts does not stop prepare, and prepare runs on pack. "Slice from the first bracket" is wrong — the observed prefix line starts with [. The parser scans every bracket and extracts a balanced, string-aware span.
  • The layers need their own job. A tooling checkout inside the caller's workspace is visible to pnpm lint, pnpm format:check and pnpm phi-scan — and ccda's scanner walks from the repo root.

Stated, not fixed

  • blocked-peer establishes less than "fully explained": npm short-circuits, so a second defect behind a declared absence is invisible. PRE-EXISTING — the post-publish gate uses the same word.
  • Layer 1 refuses workspace:/catalog:, which pnpm rewrites at pack time. Latent: none of the thirteen callers is a pnpm workspace.

…has kinds

A gate that fires after an irreversible act cannot gate. `install-check.mjs`
runs after `changeset publish`, so by the time it speaks the version is on the
registry and permanent (ADR 0001). It can report; it cannot prevent. These two
run in `ci.yml`, on every pull request, before anything is published.

LAYER 1 (`run-prepublish-manifest-lint`, ON by default). Offline, no npm, no
network: the consumer-facing dependency fields must carry only specifiers a
registry can resolve. On by default because it was measured, not because it
feels safe — run against all thirteen callers on 2026-08-05 it produced ZERO
findings, `transform` and `synth` included, because the `file:vendor/*.tgz`
specifiers that survive in seven repos are all in `devDependencies`, which no
consumer installs and which the lint deliberately does not read. The backlog's
claim that BOTH layers red those two repos is FALSE for this one. It is also the
layer that would have refused `@cosyte/cli@0.0.1`: that tree carried those
specifiers in `dependencies` at the moment it published.

LAYER 2 (`run-prepublish-install`, OFF by default). `npm pack` the tree, install
THAT TARBALL into a clean anonymous directory, load what it declares. Off by
default because turning it on for thirteen repos is a policy call, not a build.
Measured against all thirteen callers on 2026-08-05 against the live registry
with the default allowance: 11 pass, 2 blocked-peer (`transform`, `synth`), 0
red. To adopt it a caller adds one line and `transform`/`synth` need nothing
else — the default allowance already names their blocker.

THE DECLARED ALLOWANCE. `expect-unpublished-deps` is the same input `release.yml`
takes, in the same grammar. A repo names its known-unpublished consumer
dependencies and the gate asserts EXACTLY that set: a named absence warns, an
absence nobody named still fails. Entries carry a kind, because `@cosyte/assets`
and `@cosyte/docs` are `E404` in exactly the way `@cosyte/fhir` is and are
`private: true` BY DESIGN — a deliberate absence and an unexplained block must
not share words. `=blocked` is the only kind that excuses anything; `=private`
excuses nothing in either gate. A malformed entry fails, including one name
written twice with different kinds, because de-duplicating on the name alone
silently WIDENS the thing whose whole job is to excuse failures.

It fails CLOSED, which is the opposite of the post-publish gate, deliberately:
nothing irreversible has happened, so a crash must red rather than be swallowed.
What does not flip is a registry that will not answer — that stays three-valued
and warns.

Both `self-check.yml` jobs green locally: `node --test` 285/285, `actionlint
-shellcheck=shellcheck` exit 0. `gate-refuter` REFUTED -> NOT REFUTED over two
passes; its nine pass-1 findings are closed or answered in the diff.

Two things measured while building it, each of which would have been a red pull
request on a correct package: `npm pack --json` does not guarantee that stdout is
only JSON (`--ignore-scripts` does not stop `prepare`, which runs on pack), and
"slice from the first bracket" is wrong because the observed prefix line starts
with `[`; and the layers need their own job, because a tooling checkout inside
the caller's workspace is visible to `pnpm phi-scan`, which in `ccda` walks from
the repo root.

Stated, not fixed: `blocked-peer` establishes less than "fully explained" — npm
short-circuits, so a second defect behind a declared absence is invisible
(PRE-EXISTING, same word in the post-publish gate). And layer 1 refuses
`workspace:`/`catalog:`, which pnpm rewrites at pack time; latent, because none
of the thirteen callers is a pnpm workspace.
@NSchatz
NSchatz merged commit 6142ac4 into main Aug 5, 2026
2 checks passed
@NSchatz
NSchatz deleted the feat/prepublish-install-layers branch August 5, 2026 15:34
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