feat(ci): the two pre-publish layers, with a declared allowance that has kinds - #35
Merged
Merged
Conversation
…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.
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the build half of
PREPUBLISH-INSTALL-LAYERS.A gate that fires after an irreversible act cannot gate.
install-check.mjsruns afterchangeset 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 inci.yml, on every pull request, before anything is published.The two layers
run-prepublish-manifest-lintrun-prepublish-installnpm packthe tree, install that tarball into a clean anonymous directory, load what it declaresLayer 1 is on by default because it was measured. Run against all thirteen callers on 2026-08-05: 13/13
pass, zero findings,transformandsynthincluded. Thefile:vendor/*.tgzspecifiers that survive in seven repos are all indevDependencies, which no consumer installs and which the lint deliberately does not read. The backlog's claim that BOTH layers redtransform/synthis false for this one. It is also the layer that would have refused@cosyte/cli@0.0.1: that tree carried those specifiers independenciesat 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, 2blocked-peer(transform,synth), 0 red. To adopt it a caller addsrun-prepublish-install: true— andtransformandsynthneed nothing else, because the default allowance already names their blocker.The declared allowance, and why its entries carry a kind
expect-unpublished-depsis the same inputrelease.ymltakes, 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 redstransformandsynthfor@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/assetsand@cosyte/docsareE404exactly like@cosyte/fhiris, and they areprivate: trueby 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 spellingrelease.ymlshipped with. Read asblocked, 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
=blockedand only=blocked.install-check.mjscannot 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/285actionlint -shellcheck=shellcheck— exit 0gate-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, measureduninstallable→blocked-peeron identical facts.Two things measured while building it, each a red PR on a correct package
npm pack --jsondoes not guarantee that stdout is only JSON.--ignore-scriptsdoes not stopprepare, andprepareruns 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.pnpm lint,pnpm format:checkandpnpm phi-scan— andccda's scanner walks from the repo root.Stated, not fixed
blocked-peerestablishes 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.workspace:/catalog:, which pnpm rewrites at pack time. Latent: none of the thirteen callers is a pnpm workspace.