Skip to content

fix(release): bind client platform versions at pack time - #795

Merged
jeremi merged 2 commits into
mainfrom
fix/client-optional-deps-at-pack
Aug 20, 2026
Merged

fix(release): bind client platform versions at pack time#795
jeremi merged 2 commits into
mainfrom
fix/client-optional-deps-at-pack

Conversation

@jeremi

@jeremi jeremi commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes the structural half of #794. That PR repaired the v0.23.0 lockfiles by
hand; this removes the reason they needed repairing.

The contradiction

The three Node client manifests bound their platform packages to the exact
release version:

"optionalDependencies": {
  "@registrystack/evidence-client-darwin-arm64": "0.23.0",
  ...
}

While the release PR is open those versions are unpublished, so npm cannot
resolve them and collapses the lock entries to {"optional": true}. npm
tolerates a placeholder only for as long as the version does not exist. The
moment the release publishes, npm ci on protected main fails with EUSAGE
for every contributor and every CI job that installs a client, until the next
prep rewrites the lockfiles. There is no version string that satisfies both
ends: correct-for-publish is unresolvable-while-open.

Why dropping it from the tree is safe

Two gates were enforcing the same property in different places:

Gate Subject Runs on
registry-release validate_client_package_versions checked-in package.json + lockfile prepare
client_registry.py validate_npm_packages the packed tarball release-candidate.yml, release.yml

The second is the consumer-facing guarantee, and it already runs on the publish
path. The first only forced the contradiction. So the tree binds nothing, and
the release binds the exact set into the manifest it packs.

The field was dead weight in the tree anyway: the generated loader is
local-first, trying require('./evidence-client.darwin-arm64.node') before
require('@registrystack/evidence-client-darwin-arm64'), and CI builds that
.node natively.

Where the binding happens

New client_registry.py bind-optional-deps, called at three sites:

  • release-candidate.yml job assemble — the root tarball that actually gets published
  • release-candidate.yml job clients — the smoke stage
  • release-rehearsal.yml job node-clients — the smoke stage

Ordering is load-bearing and asserted by a structure test: after npm ci,
which rejects a manifest its lockfile does not match, and before npm pack,
which freezes the published manifest.

The planner's gate is inverted rather than deleted: a prepared tree that binds
platform versions is now rejected. Its per-platform loop over
npm/<platform>/package.json is unchanged, so the supported platform list stays
visible and enforced where it is authored.

Failure mode

Loud and pre-publish. A skipped or wrong binding fails validate_npm_packages
with "root npm package does not bind the exact platform versions" while
validating the packed tarball, before anything reaches npm.

Security review notes

Release provenance is security-sensitive under AGENTS.md, so, explicitly:

  • No change to what is published, only to which step writes it. The published
    root manifest carries the same optionalDependencies set as before; the tests
    assert the bound set is identical to what validate_npm_packages requires.
  • No relaxation of any gate. One gate moved from the tree to the artifact, where
    a second gate was already enforcing it on the publish path.
  • The binder refuses a manifest whose name or version does not match what it
    was asked to bind, so it cannot silently bind the wrong package or version.
  • No credentials, tokens, or secrets are read, written, or logged. The binder
    does pure local JSON I/O.
  • Output is deterministic and repeatable: binding twice is byte-for-byte
    identical (asserted).

One cosmetic artifact worth naming: the binder writes with json.dumps(indent=2),
so the packed manifest's publishConfig expands from the tree's hand-compacted
single line to three lines. Deterministic and repeatable, affects only the packed
copy, never the tree.

Scope

Cross-area (crates/ + release/ + .github/workflows/) by necessity: the gate
and the manifests it governs cannot move separately without leaving main in a
state one of them rejects. Both directions are proven, not asserted:

  • Unbinding the manifests without inverting the planner fails 14 tests against
    origin/main's registry-release, all with must bind the exact platform package versions.
  • Inverting the planner without unbinding the manifests is rejected by
    test_prepare_rejects_checked_in_platform_version_binding.

The workflow half is the same atom: it is the call site that restores the
binding at pack time, without which validate-dist rejects the candidate.

Tests

TDD, tests written first. 9 new tests:

  • test_client_registry.pyBindOptionalDependenciesTest, 8 tests: binds every
    platform at the exact version; binds exactly what the packed root package is
    validated against; repeats byte-for-byte; keeps every other manifest field;
    refuses another version; refuses another client; refuses a missing manifest;
    CLI entry point.
  • test_client_registry.pyCheckedInClientManifestTest, 1 test: none of the
    three real manifests or lockfiles binds platform versions. Mutation-tested by
    re-adding the field, which fails it.
  • test_registry_release_plans.py — 1 test: prepare rejects a tree that binds
    platform versions, on both package.json and the lockfile.

An earlier revision also asserted the three call sites by matching substrings
inside workflow run: blocks. Those were dropped: a reformat broke them with no
behaviour change, and the binder's own tests already cover what it writes. The
call sites are shell wiring, first executed for real at the next rehearsal.

Coverage the PR does not claim: bind-optional-deps never runs in PR CI,
because release-candidate.yml and release-rehearsal.yml are dispatch-only.
What PR CI does prove is the regression that was actually breaking — the
Release Linux Node clients job runs npm ci against the de-bound lockfiles on
both release runners, then builds and smoke-loads each native client.

No gates-inventory entry was added; that inventory matches run-step substrings,
which is the same brittleness the dropped tests had.

Verification

Check Result
test_client_registry 17 OK (9 new)
test_registry_release_plans 22 OK (1 new)
test_release_workflow_structure 39 OK (unchanged from main)
test_registry_release 68 OK
test_release_candidate, test_verify_public_release, test_release_rehearsal, test_release_workflow_guard, test_check_gates_inventory, test_build_linux_node_client OK
test_check_release_storage, test_cleanup_release_candidates, test_release_repeatability_workflow, test_zig_glibc_compiler, test_check_release_image_oci_labels, test_compare_release_image_layouts, test_check_release_source_model OK
check-gates-inventory.py pass, 151 gates, no change needed
registry-release validate-current validated beta-34 0.23.0
check-release-source-model.sh (monorepo mode) pass
npm ci in all three client dirs ok
Residual platform refs in the three lockfiles 0
evidence client: npm ci + build:debug + test + check:types ok, 43 pass / 0 fail
relay client, discovery client: same four steps ok
napi re-adding optionalDependencies on build confirmed it does not
binder against the three real manifests 3 platforms bound, manifest otherwise byte-identical

Docs

release/OPERATIONS.md gains a paragraph after the prepare-PR step explaining
why the manifests bind nothing and where the binding happens instead, so the
next operator does not re-add the field.

The Node client manifests pinned their platform packages to the release
being prepared. Those versions do not exist on npm while the release PR is
open, so npm collapses them to placeholder lock entries, and npm tolerates
that only until the release publishes. From that moment `npm ci` fails with
EUSAGE on protected main, and every cycle repaired it by hand.

Two gates enforced the same property in different places. The planner
enforced it on the checked-in manifest and lockfile, which is what forced
the contradiction; client_registry.py enforces it on the packed tarball,
which is the guarantee a consumer actually depends on. Keep the second and
invert the first: the tree binds nothing, the release binds the exact set
into the manifest it packs, and validate-dist reads the tarball back to
prove the binding landed.

The binding sits after `npm ci`, which rejects a manifest its lockfile does
not match, and before `npm pack`, which freezes the published manifest. A
skipped binding fails validate-dist before publish, not after.

The tree keeps naming its platforms in npm/<platform>/package.json, and the
planner still checks each one, so the supported platform list stays visible
and enforced where it is authored.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7f5a21f8d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread release/scripts/client_registry.py
Comment thread .github/workflows/release-candidate.yml
Three of the four binding tests asserted substring ordering inside workflow
`run:` blocks, so a reformat broke them without any behaviour changing. Drop
them; the binder's own tests cover what it writes, and the call sites are
shell wiring that first executes for real at the next rehearsal.

The fourth was not a structure test. It reads the three checked-in manifests
and lockfiles and asserts they bind no platform versions, which is the
regression this change exists to prevent and the only guard that catches a
re-add outside a prepare run. Move it beside the binder it belongs to.

Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
@jeremi

jeremi commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Both Codex findings reviewed. Neither changes the diff.

P1 "Add the required DCO sign-off" — false positive. Both commits carry the trailer, and CI's Check commit sign-offs job passes:

4cdfac629 -> Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
f7f5a21f8 -> Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>

P1 "Keep the change within one owning area" — real rule (AGENTS.md:321), correctly cited, but the remedy does not exist here. The two halves are mutually blocking, so either ordering leaves main in a state one of its own gates rejects. Both directions are now proven rather than asserted:

  • Land crates/ first (unbind the manifests, keep the old planner). The gate on main requires optionalDependencies to equal the exact platform set on both the checked-in manifest and its lockfile. Running the current test suite against origin/main's registry-release fails 14 tests, every one with crates/registry-discovery-client-node/package.json and its lockfile must bind the exact platform package versions.
  • Land release/ first (invert the planner, keep the bound manifests). Covered by test_prepare_rejects_checked_in_platform_version_binding, which asserts must not bind platform package versions for both package.json and package-lock.json.

The .github/workflows/ half is the same atom: it is the call site that restores the binding at pack time, and without it the release would publish a root package with no platform binding, which client_registry.py validate-dist rejects before publish.

Splitting would trade an atomic, gated change for two commits that each break the default branch. Keeping it as one PR.

@jeremi
jeremi merged commit d33f756 into main Aug 20, 2026
36 checks passed
@jeremi
jeremi deleted the fix/client-optional-deps-at-pack branch August 20, 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