diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index b2b4f2c..e566643 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -127,6 +127,77 @@ def test_dist_tag_verification_waits_for_registry_convergence( self.assertEqual(dist_tag.call_count, 2) sleep.assert_called_once_with(3) + @mock.patch("tools.npm_release.subprocess.run") + def test_missing_dist_tag_is_reported_as_absent(self, run: mock.Mock) -> None: + run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + self.assertIsNone( + npm_release.npm_dist_tag("npm", "@arcships/light-ocr", "preview") + ) + + @mock.patch("tools.npm_release.time.sleep") + @mock.patch("tools.npm_release.npm_integrity") + def test_integrity_verification_waits_for_a_batch_concurrently( + self, integrity: mock.Mock, sleep: mock.Mock + ) -> None: + integrity.side_effect = [None, "sha512-b", "sha512-a"] + + npm_release.wait_for_integrities( + "npm", + { + "@arcships/a@1.0.0": "sha512-a", + "@arcships/b@1.0.0": "sha512-b", + }, + ) + + self.assertEqual( + integrity.call_args_list, + [ + mock.call("npm", "@arcships/a@1.0.0"), + mock.call("npm", "@arcships/b@1.0.0"), + mock.call("npm", "@arcships/a@1.0.0"), + ], + ) + sleep.assert_called_once_with(3) + + @mock.patch("tools.npm_release.wait_for_dist_tag") + @mock.patch("tools.npm_release.subprocess.run") + @mock.patch( + "tools.npm_release.npm_dist_tag", + return_value=npm_release.RUNTIME_VERSION, + ) + def test_matching_preview_latest_tag_is_removed( + self, + dist_tag: mock.Mock, + run: mock.Mock, + wait: mock.Mock, + ) -> None: + package = "@arcships/light-ocr-tiny" + + npm_release.remove_dist_tag_if_version( + "npm", package, "latest", npm_release.RUNTIME_VERSION + ) + + dist_tag.assert_called_once_with("npm", package, "latest") + command = run.call_args.args[0] + self.assertEqual(command[:5], ["npm", "dist-tag", "rm", package, "latest"]) + self.assertIn(f"--registry={npm_release.NPM_REGISTRY}", command) + wait.assert_called_once_with("npm", package, "latest", None) + + @mock.patch("tools.npm_release.subprocess.run") + @mock.patch("tools.npm_release.npm_dist_tag", return_value="0.0.9") + def test_existing_preview_latest_version_is_preserved( + self, dist_tag: mock.Mock, run: mock.Mock + ) -> None: + npm_release.remove_dist_tag_if_version( + "npm", "@arcships/light-ocr-tiny", "latest", "0.1.0" + ) + + dist_tag.assert_called_once() + run.assert_not_called() + def test_stages_and_packs_the_independently_versioned_release_set(self) -> None: npm = shutil.which("npm") if npm is None: diff --git a/tools/npm_release.py b/tools/npm_release.py index 169905b..958df10 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -1225,16 +1225,28 @@ def npm_integrity(npm: str, specification: str) -> str | None: def wait_for_integrity(npm: str, specification: str, expected: str) -> None: + wait_for_integrities(npm, {specification: expected}) + + +def wait_for_integrities(npm: str, expected: dict[str, str]) -> None: + pending = dict(expected) attempts = REGISTRY_WAIT_SECONDS // 3 for _ in range(attempts): - actual = npm_integrity(npm, specification) - if actual == expected: + for specification, integrity in list(pending.items()): + actual = npm_integrity(npm, specification) + if actual == integrity: + del pending[specification] + continue + if actual is not None: + raise RuntimeError( + f"registry integrity mismatch for {specification}" + ) + if not pending: return - if actual is not None and actual != expected: - raise RuntimeError(f"registry integrity mismatch for {specification}") time.sleep(3) raise RuntimeError( - f"registry did not expose {specification} within {REGISTRY_WAIT_SECONDS} seconds" + "registry did not expose " + f"{', '.join(sorted(pending))} within {REGISTRY_WAIT_SECONDS} seconds" ) @@ -1273,13 +1285,16 @@ def npm_dist_tag(npm: str, package: str, tag: str) -> str | None: raise RuntimeError( f"npm dist-tag lookup failed for {package}: {completed.stderr.strip()}" ) - value = json.loads(completed.stdout) + output = completed.stdout.strip() + value = json.loads(output) if output else None if value is not None and not isinstance(value, str): raise RuntimeError(f"registry returned invalid {tag} tag for {package}") return value -def wait_for_dist_tag(npm: str, package: str, tag: str, version: str) -> None: +def wait_for_dist_tag( + npm: str, package: str, tag: str, version: str | None +) -> None: attempts = REGISTRY_WAIT_SECONDS // 3 for _ in range(attempts): if npm_dist_tag(npm, package, tag) == version: @@ -1305,6 +1320,7 @@ def publish(arguments: argparse.Namespace) -> None: names = [ FACADE_PACKAGES[tier]["name"] for tier in ("small", "tiny", "medium") ] + pending: dict[str, str] = {} for name in names: record = records[name] specification = f"{name}@{record['version']}" @@ -1333,10 +1349,48 @@ def publish(arguments: argparse.Namespace) -> None: cwd=ROOT, check=True, ) - wait_for_integrity(arguments.npm, specification, record["integrity"]) + pending[specification] = record["integrity"] + print(json.dumps({"package": specification, "status": "submitted"})) + wait_for_integrities(arguments.npm, pending) + for specification in pending: print(json.dumps({"package": specification, "status": "published"})) +def remove_dist_tag_if_version( + npm: str, package: str, tag: str, version: str +) -> None: + current = npm_dist_tag(npm, package, tag) + if current is None: + print(json.dumps({"package": package, "tag": tag, "status": "absent"})) + return + if current != version: + print( + json.dumps( + { + "package": package, + "tag": tag, + "status": "preserved", + "version": current, + } + ) + ) + return + subprocess.run( + [ + npm, + "dist-tag", + "rm", + package, + tag, + f"--registry={NPM_REGISTRY}", + ], + cwd=ROOT, + check=True, + ) + wait_for_dist_tag(npm, package, tag, None) + print(json.dumps({"package": package, "tag": tag, "status": "removed"})) + + def promote(arguments: argparse.Namespace) -> None: tarballs = arguments.tarball_dir.resolve() release = read_json(tarballs / "release-manifest.json") @@ -1346,6 +1400,23 @@ def promote(arguments: argparse.Namespace) -> None: ): raise RuntimeError("release manifest version does not match promotion request") records = {record["name"]: record for record in release["packages"]} + # npm may create `latest` for the first version of a new package even when + # it is published with `--tag next`. Keep preview-only tiers off `latest` + # without disturbing an independently promoted older version. + preview_names = sorted( + [ + FACADE_PACKAGES[tier]["name"] + for tier in ("tiny", "medium") + ] + + [ + MODEL_PACKAGES[tier]["name"] + for tier in ("tiny", "medium") + ] + ) + for name in preview_names: + remove_dist_tag_if_version( + arguments.npm, name, arguments.tag, records[name]["version"] + ) # Tiny and Medium intentionally stay on `next` until their G2 evidence is # accepted. The stable closure is the native runtime, model-free JS runtime, # and Small facade; Small continues to exact-pin the existing 0.3.4 model.