Prevent npm alpha dist-tags from moving backward (#49704)

## Why

Publishing an older alpha or alpha hotfix can overwrite npm dist-tags that already point to a newer release.

## What changed

Check each package's current npm dist-tag before publishing an alpha release, using the existing release version comparison and workflow concurrency lock. Handle `alpha` and platform-specific tags independently for Codex, the SDK, and the responses API proxy.

When the release does not advance the current version, publish under `release-<version>-<tag>` to keep it installable without changing the alpha or `latest` tags. Abort publishing if the registry lookup fails.

## Testing

Add unit tests for package and platform tag selection, older releases, dotted alpha hotfixes, missing tags, and registry lookup failures.

GitOrigin-RevId: 9c0edec1149bf78c01aac0f700e963630316eaea
This commit is contained in:
Ian MacLeod
2026-09-30 18:40:47 +00:00
committed by copyberry
parent 3b16b5a5b0
commit 5602705c96
3 changed files with 149 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"""Choose a forward-only npm alpha dist-tag under rust-release's concurrency lock."""
import json
from pathlib import Path
import subprocess
import sys
import tarfile
from releases import should_update_version
def npm_alpha_tag(tarball: Path, version: str, tag: str) -> str:
# Read the actual package name: platform archives share @openai/codex,
# while the SDK and proxy have independent alpha pointers.
with tarfile.open(tarball, "r:gz") as archive:
with archive.extractfile("package/package.json") as manifest:
package = json.load(manifest)["name"]
result = subprocess.run(
["npm", "view", package, "dist-tags", "--json", "--prefer-online"],
check=True,
capture_output=True,
text=True,
)
current = json.loads(result.stdout).get(tag, "")
if tag != "alpha":
# build_npm_package.py appends -<platform> to the release version.
current = current.removesuffix(f"-{tag.removeprefix('alpha-')}")
if should_update_version(version, current):
return tag
# npm publish always writes a tag. Keep this version installable without
# changing alpha, alpha-<platform>, or latest. Prefix avoids semver tags,
# which npm rejects. Include the platform for versions of the same package.
print(
f"Keeping {package}@{tag} at {current}; publishing {version} separately",
file=sys.stderr,
)
return f"release-{version}-{tag}"
if __name__ == "__main__":
print(npm_alpha_tag(Path(sys.argv[1]), sys.argv[2], sys.argv[3]))
+95
View File
@@ -1,5 +1,13 @@
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from npm_alpha_tag import npm_alpha_tag
from releases import is_valid_release_version, should_update_version
@@ -52,5 +60,92 @@ class VersionComparisonTest(unittest.TestCase):
self.assertRaises(ValueError, should_update_version, "0.123", "")
class NpmAlphaTagTest(unittest.TestCase):
def test_published_tags_across_packages_and_platforms(self) -> None:
# Each package/tag reads its own registry value, even after a partially
# published release. Also exercise a missing pointer and dotted hotfix.
cases = [
(
"@openai/codex",
"alpha",
"0.158.0-alpha.10",
"0.157.0-alpha.11.1",
"release-0.157.0-alpha.11.1-alpha",
),
(
"@openai/codex",
"alpha-linux-x64",
"0.158.0-alpha.10-linux-x64",
"0.157.0-alpha.11.1",
"release-0.157.0-alpha.11.1-alpha-linux-x64",
),
(
"@openai/codex",
"alpha-win32-arm64",
"0.158.0-alpha.9-win32-arm64",
"0.158.0-alpha.10",
"alpha-win32-arm64",
),
(
"@openai/codex-sdk",
"alpha",
"0.158.0-alpha.10",
"0.158.0-alpha.10.1",
"alpha",
),
(
"@openai/codex-responses-api-proxy",
"alpha",
"0.159.0-alpha.1",
"0.158.0-alpha.10.1",
"release-0.158.0-alpha.10.1-alpha",
),
("@openai/codex-sdk", "alpha", None, "0.158.0-alpha.10", "alpha"),
]
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "package.tgz"
for package, tag, current, version, expected in cases:
with self.subTest(package=package, tag=tag, version=version):
content = json.dumps({"name": package}).encode()
with tarfile.open(tarball, "w:gz") as archive:
info = tarfile.TarInfo("package/package.json")
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
tags = {tag: current} if current is not None else {}
result = subprocess.CompletedProcess([], 0, json.dumps(tags))
with patch(
"npm_alpha_tag.subprocess.run", return_value=result
) as run:
self.assertEqual(npm_alpha_tag(tarball, version, tag), expected)
run.assert_called_once_with(
[
"npm",
"view",
package,
"dist-tags",
"--json",
"--prefer-online",
],
check=True,
capture_output=True,
text=True,
)
def test_registry_failure_prevents_publish(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "package.tgz"
content = b'{"name": "@openai/codex"}'
with tarfile.open(tarball, "w:gz") as archive:
info = tarfile.TarInfo("package/package.json")
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
with patch(
"npm_alpha_tag.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "npm"),
):
with self.assertRaises(subprocess.CalledProcessError):
npm_alpha_tag(tarball, "0.158.0-alpha.10", "alpha")
if __name__ == "__main__":
unittest.main()