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..5baa4f1ce 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,162 @@ 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() + ) + + +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_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"))