Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions tests/python/test_npm_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 79 additions & 8 deletions tools/npm_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)


Expand Down Expand Up @@ -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:
Expand All @@ -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']}"
Expand Down Expand Up @@ -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")
Expand All @@ -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.
Expand Down
Loading