Skip to content

Latest commit

 

History

History
265 lines (210 loc) · 16.3 KB

File metadata and controls

265 lines (210 loc) · 16.3 KB

Releasing @cosyte/cli

How the cosyte CLI (and its twin cosyte-mcp server, one package, two bins) gets to npm, and the gotchas worth not rediscovering. The suite-wide mechanics live in the umbrella config/RELEASING.md; this file is the CLI-specific overlay.

The two founder stops (nothing crosses them unattended)

  1. Flipping the repo public (PUB-FLIP).
  2. The real npm publish.

Both are standing human gates. Everything up to them (the changeset, the version PR, the publish dry-run, provenance/OIDC config, this doc) is agent-shippable. The publish itself is not.

The vendor → npm dep swap (done, with one dep that could not follow)

@cosyte/cli is the only package in the suite that hard-depends on its siblings: an npx-invoked bin cannot peer-depend on something the user pre-installed. Those deps used to be vendored pnpm pack tarballs (file:vendor/*.tgz, ADR 0021/0023). A published package cannot ship a file:vendor/…tgz dependency, and two releases went out doing exactly that.

They are now real npm ranges, with the single exception of @cosyte/fhir, which is not on the registry and therefore is not declared at all. vendor/ survives only to supply @cosyte/fhir to this repo's own test run, as a devDependency; pnpm vendor:refresh still refreshes the tarballs, and the other nine are no longer wired to anything.

▶ THIS STEP WAS SKIPPED TWICE, AND 0.0.1 + 0.0.2 ARE BROKEN ON npm BECAUSE OF IT

Both published with all ten file:vendor/*.tgz specifiers intact. vendor/ is not in files, and there is no bundledDependencies, so those tarballs ship none of them. Every install route (npm i, npm i -g, npx) dies on the first one:

npm error code ENOENT
npm error path node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz

A published version is immutable (ADR 0001), so neither can be repaired; the fix ships as a later version. The lesson, and it is now a step the pipeline runs:

npm publish --dry-run cannot catch this, and the checklist implied it could. A dry-run packs a tarball; it never resolves that tarball's dependencies from a registry. The gate that catches it is npm install of the packed tarball from a directory outside this repo. A green dry-run is not install-proof.

That install is no longer something a human has to remember. It is the install-gate job in .github/workflows/release.yml, and the release job that publishes needs: it, so a red gate stops the publish. See "The install gate" below for exactly what it does and does not cover.

The dependency swap, as actually done

Verified against the live registry on 2026-08-03, and by installing the packed tarball in a clean directory, not assumed:

dep shipped as resolves?
@cosyte/hl7 dependencies, ^0.0.7 yes
@cosyte/terminology dependencies, ^0.0.9 yes
six breadth parsers optionalDependencies, real ranges yes
@modelcontextprotocol/sdk optionalDependencies, 1.29.0 yes
@cosyte/transform optionalDependencies, ^0.0.4 no
@cosyte/fhir not declared at all no

On the 0.0.x ladder ^0.0.7 is an exact pin (caret on a 0.0.z version allows no other version), which is what we want pre-alpha: the CLI is tested against exactly those sibling releases, and Dependabot now proposes each bump as its own reviewable PR.

Why @cosyte/fhir is not declared, rather than declared optional. It is not on the registry (FHIR-NPM-NAME, a persistent npm E403; the earlier "name similarity" reading was retracted ecosystem-wide on 2026-08-03, so treat the cause as unexplained and do not assert one). Declaring it at all breaks the install, which was measured here rather than reasoned about:

root manifest declares npm install
optional @cosyte/fhir alone exit 0
optional @cosyte/transform alone exit 0
both ERESOLVE, exit 1
optional @cosyte/fhir as an optional peer + transform ERESOLVE, exit 1

So the two cannot both be named. @cosyte/transform is declared (it is real, and it starts installing by itself the day @cosyte/fhir publishes, with no release needed here) and @cosyte/fhir is not. Do not attribute this to a missing peerDependenciesMeta.optional flag: measured across the suite, that flag does not decide the outcome (synth marks all seven peers optional and still fails ERESOLVE; deid declares the same optional @cosyte/fhir peer and installs cleanly). The mechanism is not yet explained. Record measurements, not theories.

@cosyte/fhir is kept as a devDependency on the vendored tarball, so this repo's own FHIR and convert tests still run, and so it satisfies @cosyte/transform's peer in the dev tree.

Say this precisely, because the obvious shorter sentence is false. devDependencies are published: they stay in the published package.json (npm view @cosyte/hl7@0.0.7 devDependencies returns a full list), and so the fix release's manifest still carries one file:vendor/*.tgz specifier. What makes that harmless is that a consumer never installs a dependency's devDependencies, so npm never resolves the path. That was verified, not assumed: installing the packed tarball in a clean directory exits 0 with the file: devDependency present in the manifest. The runtime closure is what had to be clean, and it is.

What an installed copy cannot do, and why that is not a crash

With @cosyte/fhir and @cosyte/transform both absent, every code path that reaches for them degrades to a value-free CLI_PARSER_UNAVAILABLE (exit 69). That required a code change, and shipping the manifest swap without it would have been worse than the install break: both were imported with a bare await import(), so an install without them raised a raw resolver error and a stack frame, which the CLI's value-free posture forbids.

loadOptional() could not be reused as-is: its signature is loadOptional<T>(format: CosyteFormat, …) and "transform" is not a CosyteFormat, and its diagnostic hardcodes the word "parser", which is wrong for a conversion library. So src/core/parsers.ts now has loadOptionalPackage(detail, load) underneath it, plus a loadFhir() whose diagnostic says the package is not on the registry (rather than loadOptional's "install it", which would be false). @cosyte/hl7 and @cosyte/terminology stay hard deps and keep their bare imports, correctly. test/absent-sibling.test.ts holds this shut, including a static guard over src/.

What that guard does and does not catch, because "any new call site" would overstate it. It flags a single-line await import("@cosyte/fhir") or import("@cosyte/transform") that is not wrapped by one of the loaders, which is the shape the defect actually took, and it carries negative controls so it cannot pass by matching nothing. It does not catch a thunk assigned to a variable and awaited elsewhere, an import split across lines, or a static import … from "@cosyte/fhir". That last one matters: this repo now has its first static reference to that package (src/core/parsers.ts, as import type, which is erased at build time and emits no runtime load, verified in dist/). Dropping the word type would load it eagerly and break every command in an installed copy, and the guard would not see it.

The npx @cosyte/cli short form does not work, and the swap does not fix it

Separate, pre-existing, and measured on both the published 0.0.2 and the fixed tarball:

$ npx @cosyte/cli --help
npm error could not determine executable to run

npx runs the executable whose name matches the package name's last segment, which would be cli; this package ships cosyte and cosyte-mcp. npx --package @cosyte/cli cosyte --help works and is what the docs tell people to run. A cli bin alias would fix the short form and is deliberately not added, because npm install -g would then put a command named cli on every user's PATH. If that trade is ever revisited, it is a founder call, not a packaging tidy-up.

The pipeline

Releases run on Changesets:

  1. A change lands with a changeset (pnpm changeset): a patch on the 0.0.x-until-first-alpha ladder (a published version is never moved back). The parsers publish at 0.0.1; the CLI begins its public history at 0.0.1.
  2. On push to main with pending changesets, .github/workflows/release.yml (a thin caller of the shared cosyte/.github release pipeline) opens/updates a "Version Packages" PR that consumes the changesets and bumps version + CHANGELOG.md.
  3. Merging that PR runs the workflow again; with no pending changesets it runs pnpm run release (changeset publish) inside the protected release environment: the approval gate. Nothing reaches npm without a deliberate human ack.

NPM_TOKEN must be an npm Automation token (a classic Publish token demands a 2FA OTP CI cannot supply, and the publish dies at the very last step with EOTP).

The install gate

.github/workflows/release.yml runs node scripts/release-install-gate.mjs in an install-gate job that the publishing job needs:. It is the automated form of what used to be checklist step 6, and it runs on every push to main, which includes the push that opens or updates the "Version Packages" PR. Locally: pnpm build && node scripts/release-install-gate.mjs (add --json for the full report, --manifest-only for the offline specifier lint alone).

What it does. Packs this tree with pnpm pack, installs the packed tarball with npm install into a fresh directory outside this repository's working tree (and refuses if that directory is inside it), then executes every bin the manifest declares from the installed copy: cosyte must exit 0, and cosyte-mcp, a stdio server that never exits on its own, must not exit non-zero inside a bounded window. Before any of that it refuses a specifier naming a local path (file:, link:, a relative or absolute path) in dependencies, optionalDependencies or peerDependencies.

It fails closed. Every outcome that is not a clean pass exits non-zero with its own reason, and those reasons are deliberately distinct: install-failed (the install exited non-zero), bin-missing (a packaging defect: the tarball has no file for a declared bin), bin-failed (the bin ran and failed), local-path-specifier, and gate-timeout / gate-registry-unreachable / gate-error for a run that reached no verdict at all. A transient registry fault therefore delays a correct release until the run is repeated; the alternative, warning and continuing, is how 0.0.1 and 0.0.2 happened.

What it deliberately does NOT cover.

  • devDependencies. A consumer never installs them, so the file:vendor/*.tgz specifier this manifest carries on purpose is reported and passed over. The gate names it in its report rather than ignoring the field silently.
  • @cosyte/fhir and @cosyte/transform being absent from an installed copy. That is the designed state, not a defect; those paths degrade to a value-free CLI_PARSER_UNAVAILABLE (exit 69). The gate asserts the install exited zero and the bins ran, never that some named package is present.
  • A real parse from the installed copy. Each bin is invoked with --version, which is the load path: it proves the command starts and its module graph resolves, not that a message parses. That stays the job of the suite, of pnpm smoke, and of step 6 below.
  • The version string the installed bin prints. cosyte --version printing 0.0.0 is the skipped-sync-version defect, which is a different failure from "cannot be installed". That stays a human step, and it is step 6 below.
  • The published artifact. The gate reads the tarball this tree packs, not the registry. The shared pipeline's own install check runs after changesets/action has published and fail-opens, so it can report a dead version but cannot prevent one.

Provenance & OIDC

  • package.json#publishConfig sets "provenance": true, and release.yml grants id-token: write, so provenance auto-attaches once the repo is public (the shared pipeline wires NPM_CONFIG_PROVENANCE to public visibility; no workflow edit needed at flip time).
  • OIDC trusted publishing (token-free) is the later step: configure the Trusted Publisher on npm for @cosyte/cli (org cosyte, repo cli, workflow release.yml, environment release), then drop NPM_TOKEN. Keep id-token: write.

Proving the pipe without burning a version

The publish path is exercised without uploading anything:

pnpm build            # dist/ must exist first
pnpm attw             # per-condition types resolve (node16 import + require, bundler)
pnpm smoke            # built dual ESM/CJS `.` + `./mcp`, and BOTH bins run under node, IN dist/
npm publish --dry-run # assembles the tarball (dist + README/LICENSE/CHANGELOG), no upload

node scripts/release-install-gate.mjs # packs, INSTALLS the tarball outside this repo, runs both bins

pnpm smoke and the install gate look similar and answer different questions. smoke exercises dist/ in place, so every path it walks is a path inside this repo; the gate installs the packed tarball somewhere else entirely, which is the only way the file:vendor/*.tgz shape ever surfaces.

scripts/verify.sh cli runs test:coverage (per-dir ≥ 90 on core + commands), build, attw, and smoke as its gate. The nightly Fuzz workflow (.github/workflows/fuzz.yml, pnpm test:fuzz) scales the argv+stdin+MCP fuzz far past the per-PR count. A red in any of these means a real release would fail.

bin name availability. Confirm @cosyte/cli is publishable and that cosyte and cosyte-mcp are free as global bin names on npm before PUB-FLIP. A collision surfaces only at publish time.

The publish checklist (for the human at the gate)

Steps 1, 2 and 3 are already done. Step 2 is the one that was skipped twice.

The install itself is not on this list any more. install-gate in .github/workflows/release.yml packs this tree, installs the tarball outside the repo and runs both bins on every push to main, and the publishing job needs: it. What is left for a human is the one check that gate does not make: the version string.

  1. PUB-FLIP the repo public (founder stop 1). Done; the repo is public.

  2. Swap the vendored file: deps for real @cosyte/* npm ranges. Done; see "The dependency swap, as actually done" above for what could and could not be swapped, and for the measurements behind it. @cosyte/fhir stays undeclared until it is on the registry; when it publishes, declare it and @cosyte/transform starts resolving too.

  3. Confirm the cosyte / cosyte-mcp bin names are free on npm. Done; 0.0.1 owns both.

  4. Land the release changeset; approve the "Version Packages" PR.

  5. Approve the protected release environment to publish (founder stop 2). Provenance attaches automatically.

  6. A HUMAN STEP, AND STILL ONE: check the version string the published bin prints. The install gate proves the published version can be installed and run; it deliberately does not assert WHICH version the bin reports, because a wrong version string and an uninstallable package are different failures. Nothing automated catches this one:

    cd "$(mktemp -d)" && npm init -y >/dev/null
    npm install @cosyte/cli@<version>          # the gate already proved this shape; expect exit 0
    node_modules/.bin/cosyte --version         # must print <version>, NOT 0.0.0
    node_modules/.bin/cosyte parse some.hl7    # must exit 0; the gate runs the bin, not a real parse
    node -e 'import("@cosyte/cli").then(m=>console.log(m.VERSION))'

    cosyte --version is the check that catches a skipped scripts/sync-version.mjs: 0.0.1 and 0.0.2 both printed 0.0.0.