From f7f5a21f8dfd692394a0e626d17cb5918569b0ba Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 13:45:38 +0200 Subject: [PATCH 1/2] fix(release): bind client platform versions at pack time 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//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 --- .github/workflows/release-candidate.yml | 12 ++ .github/workflows/release-rehearsal.yml | 4 + .../package-lock.json | 59 -------- .../package.json | 5 - .../package-lock.json | 59 -------- .../package.json | 5 - .../package-lock.json | 59 -------- .../registry-relay-client-node/package.json | 5 - release/OPERATIONS.md | 9 ++ release/scripts/client_registry.py | 54 ++++++- release/scripts/registry-release | 23 ++- release/scripts/test_client_registry.py | 138 ++++++++++++++++++ .../scripts/test_registry_release_plans.py | 45 +++--- .../test_release_workflow_structure.py | 59 ++++++++ 14 files changed, 310 insertions(+), 226 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ff7d1737f..a1fe6e82d 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -655,6 +655,10 @@ jobs: LICENSE README.md client.js client.d.ts index.js index.d.ts package.json; do cp "${client_dir}/${source}" "${root_stage}/${source}" done + python3 release/scripts/client_registry.py bind-optional-deps \ + --package-json "${root_stage}/package.json" \ + --version "${CLIENT_VERSION}" \ + --client "${client}" (cd "${root_stage}" && npm pack \ --ignore-scripts --pack-destination "${smoke}") root_package="${smoke}/registrystack-${client}-client-${CLIENT_VERSION}.tgz" @@ -1066,6 +1070,14 @@ jobs: node_client="${GITHUB_WORKSPACE}/crates/registry-${client}-client-node" (cd "${node_client}" && npm ci --ignore-scripts) test -z "$(find "${node_client}" -maxdepth 1 -name '*.node' -print -quit)" + # The tree binds no platform versions, which is what keeps the + # `npm ci` above satisfiable once this release publishes. Bind + # them here, after the install and before the pack, so the + # published root package still resolves its native binary. + python3 release/scripts/client_registry.py bind-optional-deps \ + --package-json "${node_client}/package.json" \ + --version "${version}" \ + --client "${client}" (cd "${node_client}" && npm pack \ --ignore-scripts --pack-destination "${RUNNER_TEMP}") root_package="registrystack-${client}-client-${version}.tgz" diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index 976c6fa4e..e86e303e2 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -160,6 +160,10 @@ jobs: LICENSE README.md client.js client.d.ts index.js index.d.ts package.json; do cp "${client_dir}/${source}" "${root_stage}/${source}" done + python3 release/scripts/client_registry.py bind-optional-deps \ + --package-json "${root_stage}/package.json" \ + --version "${CLIENT_VERSION}" \ + --client "${client}" (cd "${root_stage}" && npm pack \ --ignore-scripts --pack-destination "${smoke}") root_package="${smoke}/registrystack-${client}-client-${CLIENT_VERSION}.tgz" diff --git a/crates/registry-discovery-client-node/package-lock.json b/crates/registry-discovery-client-node/package-lock.json index 4640d5d48..f725b9305 100644 --- a/crates/registry-discovery-client-node/package-lock.json +++ b/crates/registry-discovery-client-node/package-lock.json @@ -15,11 +15,6 @@ }, "engines": { "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/discovery-client-darwin-arm64": "0.23.0", - "@registrystack/discovery-client-linux-arm64-gnu": "0.23.0", - "@registrystack/discovery-client-linux-x64-gnu": "0.23.0" } }, "node_modules/@emnapi/core": { @@ -1688,60 +1683,6 @@ "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@registrystack/discovery-client-darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/discovery-client-darwin-arm64/-/discovery-client-darwin-arm64-0.23.0.tgz", - "integrity": "sha512-AHOmObJzK07WsazZJv3GdqRoi9AN2fDbqlaI4d6qBGrT5qhC1kqwd8pQSzpbg0qXjRiBnonesE3onOOFb/OpQA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/discovery-client-linux-arm64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/discovery-client-linux-arm64-gnu/-/discovery-client-linux-arm64-gnu-0.23.0.tgz", - "integrity": "sha512-F4EpVelEoqKHeqyowq6p+icyv6w/2XIgFAonJ850ULxqTnAM/zO6qESqOKFJ34WCmOXCPrFk1eFt2nfjf19jtg==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/discovery-client-linux-x64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/discovery-client-linux-x64-gnu/-/discovery-client-linux-x64-gnu-0.23.0.tgz", - "integrity": "sha512-AzW0RNwBlpLhxhwWX7fBT/raKUjhZHw0x0C4wcQBv2khzQB3WQeT4wJGnEaMgLqTxAdPuMeseqt0eg59vH1xGQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/crates/registry-discovery-client-node/package.json b/crates/registry-discovery-client-node/package.json index 35ea37d6d..ef6417d48 100644 --- a/crates/registry-discovery-client-node/package.json +++ b/crates/registry-discovery-client-node/package.json @@ -29,10 +29,5 @@ "@napi-rs/cli": "3.8.2", "@types/node": "22.20.1", "typescript": "6.0.3" - }, - "optionalDependencies": { - "@registrystack/discovery-client-darwin-arm64": "0.23.0", - "@registrystack/discovery-client-linux-arm64-gnu": "0.23.0", - "@registrystack/discovery-client-linux-x64-gnu": "0.23.0" } } diff --git a/crates/registry-evidence-client-node/package-lock.json b/crates/registry-evidence-client-node/package-lock.json index 242fff72c..346dc1bea 100644 --- a/crates/registry-evidence-client-node/package-lock.json +++ b/crates/registry-evidence-client-node/package-lock.json @@ -13,11 +13,6 @@ }, "engines": { "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/evidence-client-darwin-arm64": "0.23.0", - "@registrystack/evidence-client-linux-arm64-gnu": "0.23.0", - "@registrystack/evidence-client-linux-x64-gnu": "0.23.0" } }, "node_modules/@emnapi/core": { @@ -1654,60 +1649,6 @@ "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@registrystack/evidence-client-darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/evidence-client-darwin-arm64/-/evidence-client-darwin-arm64-0.23.0.tgz", - "integrity": "sha512-InLa8bKjcGPZxe4xRwq/xdDA+5kpvC+190xi64TRAZ3WiQKj4D7TY/UBdMHYEbcBKn5aIanaAxqJEzxf2aIkXQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/evidence-client-linux-arm64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/evidence-client-linux-arm64-gnu/-/evidence-client-linux-arm64-gnu-0.23.0.tgz", - "integrity": "sha512-ZUOS9QRCGCLgYhR2vmLrpxeLx+cnRxwYhqrpgM0lQ4YfZ+l03RXj2b9dZigfnJ3qwM8ymrKKJVhOMmO8Ez85vA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/evidence-client-linux-x64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/evidence-client-linux-x64-gnu/-/evidence-client-linux-x64-gnu-0.23.0.tgz", - "integrity": "sha512-y50NtKUj13n4TXXOrW7qvgI5Xe74HDQuN9MRKGCtlQoCojJxc1BthQ7xl7WbBi6tf61Xbz0P6zvVE+bAgzU3lQ==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/crates/registry-evidence-client-node/package.json b/crates/registry-evidence-client-node/package.json index 84a3b6666..4a9c7b114 100644 --- a/crates/registry-evidence-client-node/package.json +++ b/crates/registry-evidence-client-node/package.json @@ -42,10 +42,5 @@ }, "devDependencies": { "@napi-rs/cli": "3.8.2" - }, - "optionalDependencies": { - "@registrystack/evidence-client-darwin-arm64": "0.23.0", - "@registrystack/evidence-client-linux-arm64-gnu": "0.23.0", - "@registrystack/evidence-client-linux-x64-gnu": "0.23.0" } } diff --git a/crates/registry-relay-client-node/package-lock.json b/crates/registry-relay-client-node/package-lock.json index ee361de93..e053ccde2 100644 --- a/crates/registry-relay-client-node/package-lock.json +++ b/crates/registry-relay-client-node/package-lock.json @@ -15,11 +15,6 @@ }, "engines": { "node": ">=22.12.0" - }, - "optionalDependencies": { - "@registrystack/relay-client-darwin-arm64": "0.23.0", - "@registrystack/relay-client-linux-arm64-gnu": "0.23.0", - "@registrystack/relay-client-linux-x64-gnu": "0.23.0" } }, "node_modules/@emnapi/core": { @@ -1656,60 +1651,6 @@ "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@registrystack/relay-client-darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/relay-client-darwin-arm64/-/relay-client-darwin-arm64-0.23.0.tgz", - "integrity": "sha512-ZWIAMEedWVdF73aBy/AU8XyEx1qjv9pXB9H9bBBW1g2ya15MfuSG3zJqt8ExOSXwCj+4DblPK6GL44tpwZT4Jg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/relay-client-linux-arm64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/relay-client-linux-arm64-gnu/-/relay-client-linux-arm64-gnu-0.23.0.tgz", - "integrity": "sha512-n2u1pcVdnwev6O8W1yUiBw1PseVmUKJN6M0C0+WVSyluMLMr4T3NJ6t7ZtBT6z+j0PzLoXyPrFOdAPuX72FPag==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@registrystack/relay-client-linux-x64-gnu": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@registrystack/relay-client-linux-x64-gnu/-/relay-client-linux-x64-gnu-0.23.0.tgz", - "integrity": "sha512-OAwZ6PtE2RTF/IGxln++kx64FTPcFHMarbO/tJ74VcxqxcfV2umFMvjsarSuhViO+xh+qrrffEoJRwuKzx/Fzw==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/crates/registry-relay-client-node/package.json b/crates/registry-relay-client-node/package.json index 7f470d33b..0acfa0544 100644 --- a/crates/registry-relay-client-node/package.json +++ b/crates/registry-relay-client-node/package.json @@ -28,10 +28,5 @@ "@napi-rs/cli": "3.8.2", "@types/node": "22.20.1", "typescript": "6.0.3" - }, - "optionalDependencies": { - "@registrystack/relay-client-darwin-arm64": "0.23.0", - "@registrystack/relay-client-linux-arm64-gnu": "0.23.0", - "@registrystack/relay-client-linux-x64-gnu": "0.23.0" } } diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index eec7390a2..388d9574d 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -142,6 +142,15 @@ checks pass. The merge commit is the intended candidate source. The exact protected-main revision accepted by `request-candidate` becomes the candidate source and future tag target. There is no finalization or closeout PR. +The Node client manifests and their lockfiles deliberately bind no platform +package versions. Those versions name the release being prepared, which is +unpublished for as long as the PR is open, so a tree that carries them records +placeholder lock entries and leaves `npm ci` unsatisfiable on protected `main` +from the moment the release publishes. The candidate binds them into the root +manifest it packs, and `client_registry.py validate-dist` proves the published +root package carries the exact set. The planner rejects a prepared tree that +binds them. + Before opening the release PR, push the prepared branch and run the read-only Ubuntu rehearsal from that branch: diff --git a/release/scripts/client_registry.py b/release/scripts/client_registry.py index 6ddce7232..fd4ede14d 100644 --- a/release/scripts/client_registry.py +++ b/release/scripts/client_registry.py @@ -95,6 +95,48 @@ def npm_platforms(client: str) -> tuple[tuple[str, str], ...]: ) +def expected_optional_dependencies(client: str, version: str) -> dict[str, str]: + definition = client_definition(client) + return { + f"{definition.npm_root_package}-{platform}": version + for platform, _binary in npm_platforms(client) + } + + +def bind_optional_dependencies(package_json: Path, version: str, client: str) -> None: + """Bind a root manifest to its exact platform packages before it is packed. + + The checked-in manifest cannot carry these versions. At preparation time + they name a release that is not published yet, so npm resolves them to + placeholder lock entries and `npm ci` stops being satisfiable on the + default branch from the moment that release publishes. The published root + package must still bind them exactly, so the binding happens here, against + the manifest that is about to be packed, and validate_npm_packages proves + it landed by reading the packed tarball back. + """ + definition = client_definition(client) + try: + metadata = json.loads(package_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ClientRegistryError( + f"cannot read root manifest {package_json}: {exc}" + ) from exc + if not isinstance(metadata, dict): + raise ClientRegistryError(f"root manifest {package_json} is malformed") + if ( + metadata.get("name") != definition.npm_root_package + or metadata.get("version") != version + ): + raise ClientRegistryError( + f"root manifest {package_json} must identify " + f"{definition.npm_root_package} at version {version}" + ) + metadata["optionalDependencies"] = expected_optional_dependencies(client, version) + package_json.write_text( + json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + def npm_tarballs(directory: Path, version: str, client: str) -> list[Path]: definition = client_definition(client) return [ @@ -169,10 +211,7 @@ def npm_package_metadata(path: Path) -> tuple[dict[str, Any], set[str]]: def validate_npm_packages(directory: Path, version: str, client: str) -> list[Path]: definition = client_definition(client) - expected_optional = { - f"{definition.npm_root_package}-{platform}": version - for platform, _binary in npm_platforms(client) - } + expected_optional = expected_optional_dependencies(client, version) paths = npm_tarballs(directory, version, client) for path in paths: metadata, names = npm_package_metadata(path) @@ -375,6 +414,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace: validate.add_argument("--directory", type=Path, required=True) validate.add_argument("--version", required=True) validate.add_argument("--client", choices=sorted(CLIENTS), required=True) + bind = subparsers.add_parser("bind-optional-deps") + bind.add_argument("--package-json", type=Path, required=True) + bind.add_argument("--version", required=True) + bind.add_argument("--client", choices=sorted(CLIENTS), required=True) npm = subparsers.add_parser("npm-state") npm.add_argument("--tarball", type=Path, required=True) pypi = subparsers.add_parser("pypi-state") @@ -390,6 +433,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "validate-dist": validate_distribution(args.directory, args.version, args.client) print("validated") + elif args.command == "bind-optional-deps": + bind_optional_dependencies(args.package_json, args.version, args.client) + print("bound") elif args.command == "npm-state": print(npm_registry_state(args.tarball, npm_metadata(args.tarball))) elif args.command == "pypi-state": diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 843cefd6e..247f5e172 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -1320,21 +1320,20 @@ def validate_client_package_versions(repo: Path, version: str) -> list[Path]: "@registrystack/evidence-client", "@registrystack/relay-client", }: - expected_optional = { - f"{expected_name}-{platform}": version - for platform in ( - "darwin-arm64", - "linux-arm64-gnu", - "linux-x64-gnu", - ) - } - if ( - package.get("optionalDependencies") != expected_optional - or lock_package.get("optionalDependencies") != expected_optional + # The published root package binds the exact platform versions, + # and client_registry.py proves that against the packed tarball. + # The tree must not carry them: at preparation time they name a + # release that is not published yet, so npm records placeholder + # lock entries and `npm ci` stops resolving on the default branch + # from the moment that release publishes. + if any( + "optionalDependencies" in surface + for surface in (package, lock_package) ): raise ReleasePlanError( f"{relative_path(repo, package_path)} and its lockfile must " - "bind the exact platform package versions" + "not bind platform package versions; the release binds them " + "when it packs the root package" ) for platform in ( "darwin-arm64", diff --git a/release/scripts/test_client_registry.py b/release/scripts/test_client_registry.py index e0898737c..a6568a298 100644 --- a/release/scripts/test_client_registry.py +++ b/release/scripts/test_client_registry.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import contextlib import hashlib import importlib.util import io @@ -230,5 +231,142 @@ def test_pypi_retry_rejects_an_unexpected_or_changed_file(self) -> None: ) +class BindOptionalDependenciesTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.directory = Path(self.temporary_directory.name) + self.version = "1.2.3" + self.client = "relay" + self.definition = self.module.client_definition(self.client) + self.package_json = self.directory / "package.json" + self._write_manifest( + name=self.definition.npm_root_package, + version=self.version, + ) + + def _write_manifest(self, **fields: object) -> None: + self.package_json.write_text( + json.dumps(fields, indent=2) + "\n", encoding="utf-8" + ) + + def _read_manifest(self) -> dict: + return json.loads(self.package_json.read_text(encoding="utf-8")) + + def _expected(self) -> dict[str, str]: + return { + f"{self.definition.npm_root_package}-{platform}": self.version + for platform, _binary in self.module.npm_platforms(self.client) + } + + def test_binds_every_platform_at_the_exact_version(self) -> None: + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + self.assertEqual( + self._read_manifest()["optionalDependencies"], self._expected() + ) + + def test_binds_what_the_packed_root_package_is_validated_against(self) -> None: + # The binding and the gate that proves it must not drift apart. Both + # read one platform list, so a platform added to either is required by + # the other in the same commit. + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + tarballs = self.module.npm_tarballs( + self.directory, self.version, self.client + ) + write_npm_package( + tarballs[-1], + name=self.definition.npm_root_package, + version=self.version, + optional_dependencies=self._read_manifest()["optionalDependencies"], + ) + for path, (platform, binary) in zip( + tarballs[:-1], + self.module.npm_platforms(self.client), + strict=True, + ): + write_npm_package( + path, + name=f"{self.definition.npm_root_package}-{platform}", + version=self.version, + binary=binary, + ) + self.module.validate_npm_packages(self.directory, self.version, self.client) + + def test_repeats_byte_for_byte(self) -> None: + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + once = self.package_json.read_bytes() + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + self.assertEqual(self.package_json.read_bytes(), once) + + def test_keeps_every_other_manifest_field(self) -> None: + self._write_manifest( + name=self.definition.npm_root_package, + version=self.version, + files=["client.js"], + devDependencies={"@napi-rs/cli": "3.8.2"}, + ) + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + manifest = self._read_manifest() + self.assertEqual(manifest["files"], ["client.js"]) + self.assertEqual(manifest["devDependencies"], {"@napi-rs/cli": "3.8.2"}) + + def test_refuses_a_manifest_at_another_version(self) -> None: + self._write_manifest( + name=self.definition.npm_root_package, + version="9.9.9", + ) + with self.assertRaisesRegex(self.module.ClientRegistryError, "must identify"): + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + + def test_refuses_a_manifest_for_another_client(self) -> None: + self._write_manifest( + name="@registrystack/evidence-client", + version=self.version, + ) + with self.assertRaisesRegex(self.module.ClientRegistryError, "must identify"): + self.module.bind_optional_dependencies( + self.package_json, self.version, self.client + ) + + def test_refuses_a_missing_manifest(self) -> None: + with self.assertRaises(self.module.ClientRegistryError): + self.module.bind_optional_dependencies( + self.directory / "absent.json", self.version, self.client + ) + + def test_command_line_binds_the_manifest(self) -> None: + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + exit_code = self.module.main( + [ + "bind-optional-deps", + "--package-json", + str(self.package_json), + "--version", + self.version, + "--client", + self.client, + ] + ) + self.assertEqual(exit_code, 0) + self.assertEqual(stdout.getvalue(), "bound\n") + self.assertEqual( + self._read_manifest()["optionalDependencies"], self._expected() + ) + + if __name__ == "__main__": unittest.main() diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index ecf0709ce..918043582 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -354,24 +354,6 @@ def _write_surfaces(self) -> None: "packages": {"": {"name": name, "version": "1.1.0"}}, }, ) - optional_dependencies = { - f"{name}-{platform}": "1.1.0" - for platform in ( - "darwin-arm64", - "linux-arm64-gnu", - "linux-x64-gnu", - ) - } - package = json.loads( - (client_root / "package.json").read_text(encoding="utf-8") - ) - package["optionalDependencies"] = optional_dependencies - write_json(client_root / "package.json", package) - lock = json.loads( - (client_root / "package-lock.json").read_text(encoding="utf-8") - ) - lock["packages"][""]["optionalDependencies"] = optional_dependencies - write_json(client_root / "package-lock.json", lock) for platform in ( "darwin-arm64", "linux-arm64-gnu", @@ -688,6 +670,33 @@ def test_prepare_rejects_stale_client_platform_package_version(self) -> None: package["version"] = "1.1.0" write_json(platform, package) + def test_prepare_rejects_checked_in_platform_version_binding(self) -> None: + # The release binds these when it packs the root package. Carrying them + # in the tree names a version that is unpublished at preparation time, + # so npm writes placeholder lock entries and `npm ci` stops resolving + # on the default branch from the moment that release publishes. + client_root = self.repo.root / "crates/registry-evidence-client-node" + optional = {"@registrystack/evidence-client-linux-x64-gnu": "1.1.0"} + for relative, holder in ( + ("package.json", lambda document: document), + ("package-lock.json", lambda document: document["packages"][""]), + ): + with self.subTest(surface=relative): + path = client_root / relative + original = path.read_text(encoding="utf-8") + document = json.loads(original) + holder(document)["optionalDependencies"] = optional + write_json(path, document) + + result = self.prepare() + + self.assertEqual(1, result.returncode) + self.assertIn( + "must not bind platform package versions", + result.stderr, + ) + write(path, original) + def test_prepare_requires_exact_release_archive_lock(self) -> None: archive_lock = self.repo.root / "docs/site/src/data/archive-lock.yaml" document = yaml.safe_load(archive_lock.read_text(encoding="utf-8")) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index f9dfe7bd2..68e7a5054 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -1215,6 +1215,65 @@ def test_promotes_exact_client_packages_with_oidc_and_retry_safety(self) -> None self.assertNotIn("PYPI_TOKEN", text) +class ClientPlatformBindingStructureTest(unittest.TestCase): + def test_candidate_binds_platform_versions_between_install_and_pack(self) -> None: + _, document = workflow("release-candidate.yml") + assemble = step_run( + document, + "assemble", + "Assemble public payload and validate " + "version-appropriate install inputs", + ) + # The tree binds no platform versions, so the published root package is + # correct only because the release binds them itself. The binding sits + # after `npm ci`, which rejects a manifest its lockfile does not match, + # and before `npm pack`, which is what freezes the published manifest. + install = assemble.index('(cd "${node_client}" && npm ci --ignore-scripts)') + bind = assemble.index("client_registry.py bind-optional-deps") + pack = assemble.index('(cd "${node_client}" && npm pack') + self.assertLess(install, bind) + self.assertLess(bind, pack) + + def test_candidate_smoke_packs_the_published_manifest(self) -> None: + _, document = workflow("release-candidate.yml") + smoke = step_run(document, "clients", "Smoke Node client packages") + # Smoking the copied tree manifest would install a root package that + # names no platform dependency, so it would pass without ever + # exercising the manifest the release actually publishes. + bind = smoke.index("client_registry.py bind-optional-deps") + self.assertLess(smoke.index('cp "${client_dir}/${source}"'), bind) + self.assertLess(bind, smoke.index('(cd "${root_stage}" && npm pack')) + + def test_rehearsal_smoke_packs_the_published_manifest(self) -> None: + _, document = workflow("release-rehearsal.yml") + rehearsal = step_run( + document, + "node-clients", + "Build, package, and smoke Linux Node clients", + ) + bind = rehearsal.index("client_registry.py bind-optional-deps") + self.assertLess(rehearsal.index('cp "${client_dir}/${source}"'), bind) + self.assertLess(bind, rehearsal.index('(cd "${root_stage}" && npm pack')) + + def test_no_checked_in_client_manifest_binds_platform_versions(self) -> None: + # This is the regression the binding exists to prevent: a manifest that + # names its own unpublished release leaves `npm ci` unsatisfiable on + # the default branch from the moment that release publishes. + for client in ("discovery", "evidence", "relay"): + with self.subTest(client=client): + root = ROOT / f"crates/registry-{client}-client-node" + manifest = json.loads( + (root / "package.json").read_text(encoding="utf-8") + ) + lock = json.loads( + (root / "package-lock.json").read_text(encoding="utf-8") + ) + self.assertNotIn("optionalDependencies", manifest) + self.assertNotIn( + "optionalDependencies", lock["packages"][""] + ) + + class SupportingWorkflowStructureTest(unittest.TestCase): def test_operator_docs_match_the_latest_non_prerelease_contract(self) -> None: operations = (ROOT / "release/OPERATIONS.md").read_text(encoding="utf-8") From 4cdfac62904d98fcda2725160b6e2b3c98a53aaf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 15:28:50 +0200 Subject: [PATCH 2/2] test(release): guard the tree instead of the workflow text 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 --- release/scripts/test_client_registry.py | 20 +++++++ .../test_release_workflow_structure.py | 59 ------------------- 2 files changed, 20 insertions(+), 59 deletions(-) diff --git a/release/scripts/test_client_registry.py b/release/scripts/test_client_registry.py index a6568a298..5baa4f1ce 100644 --- a/release/scripts/test_client_registry.py +++ b/release/scripts/test_client_registry.py @@ -368,5 +368,25 @@ def test_command_line_binds_the_manifest(self) -> None: ) +class CheckedInClientManifestTest(unittest.TestCase): + def test_no_client_manifest_binds_platform_versions(self) -> None: + # The regression the binding exists to prevent: a manifest naming its + # own unpublished release leaves `npm ci` unsatisfiable on the default + # branch from the moment that release publishes. The release binds + # these when it packs the root package, so the tree carries none. + repo = Path(__file__).resolve().parents[2] + for client in ("discovery", "evidence", "relay"): + with self.subTest(client=client): + root = repo / f"crates/registry-{client}-client-node" + manifest = json.loads( + (root / "package.json").read_text(encoding="utf-8") + ) + lock = json.loads( + (root / "package-lock.json").read_text(encoding="utf-8") + ) + self.assertNotIn("optionalDependencies", manifest) + self.assertNotIn("optionalDependencies", lock["packages"][""]) + + if __name__ == "__main__": unittest.main() diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 68e7a5054..f9dfe7bd2 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -1215,65 +1215,6 @@ def test_promotes_exact_client_packages_with_oidc_and_retry_safety(self) -> None self.assertNotIn("PYPI_TOKEN", text) -class ClientPlatformBindingStructureTest(unittest.TestCase): - def test_candidate_binds_platform_versions_between_install_and_pack(self) -> None: - _, document = workflow("release-candidate.yml") - assemble = step_run( - document, - "assemble", - "Assemble public payload and validate " - "version-appropriate install inputs", - ) - # The tree binds no platform versions, so the published root package is - # correct only because the release binds them itself. The binding sits - # after `npm ci`, which rejects a manifest its lockfile does not match, - # and before `npm pack`, which is what freezes the published manifest. - install = assemble.index('(cd "${node_client}" && npm ci --ignore-scripts)') - bind = assemble.index("client_registry.py bind-optional-deps") - pack = assemble.index('(cd "${node_client}" && npm pack') - self.assertLess(install, bind) - self.assertLess(bind, pack) - - def test_candidate_smoke_packs_the_published_manifest(self) -> None: - _, document = workflow("release-candidate.yml") - smoke = step_run(document, "clients", "Smoke Node client packages") - # Smoking the copied tree manifest would install a root package that - # names no platform dependency, so it would pass without ever - # exercising the manifest the release actually publishes. - bind = smoke.index("client_registry.py bind-optional-deps") - self.assertLess(smoke.index('cp "${client_dir}/${source}"'), bind) - self.assertLess(bind, smoke.index('(cd "${root_stage}" && npm pack')) - - def test_rehearsal_smoke_packs_the_published_manifest(self) -> None: - _, document = workflow("release-rehearsal.yml") - rehearsal = step_run( - document, - "node-clients", - "Build, package, and smoke Linux Node clients", - ) - bind = rehearsal.index("client_registry.py bind-optional-deps") - self.assertLess(rehearsal.index('cp "${client_dir}/${source}"'), bind) - self.assertLess(bind, rehearsal.index('(cd "${root_stage}" && npm pack')) - - def test_no_checked_in_client_manifest_binds_platform_versions(self) -> None: - # This is the regression the binding exists to prevent: a manifest that - # names its own unpublished release leaves `npm ci` unsatisfiable on - # the default branch from the moment that release publishes. - for client in ("discovery", "evidence", "relay"): - with self.subTest(client=client): - root = ROOT / f"crates/registry-{client}-client-node" - manifest = json.loads( - (root / "package.json").read_text(encoding="utf-8") - ) - lock = json.loads( - (root / "package-lock.json").read_text(encoding="utf-8") - ) - self.assertNotIn("optionalDependencies", manifest) - self.assertNotIn( - "optionalDependencies", lock["packages"][""] - ) - - class SupportingWorkflowStructureTest(unittest.TestCase): def test_operator_docs_match_the_latest_non_prerelease_contract(self) -> None: operations = (ROOT / "release/OPERATIONS.md").read_text(encoding="utf-8")