From 5602705c9622593785441bd6a79237bd24ee3418 Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Wed, 30 Sep 2026 18:22:10 +0000 Subject: [PATCH] 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--` 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 --- .github/scripts/npm_alpha_tag.py | 43 ++++++++++++++ .github/scripts/test_releases.py | 95 ++++++++++++++++++++++++++++++ .github/workflows/rust-release.yml | 11 ++++ 3 files changed, 149 insertions(+) create mode 100644 .github/scripts/npm_alpha_tag.py diff --git a/.github/scripts/npm_alpha_tag.py b/.github/scripts/npm_alpha_tag.py new file mode 100644 index 0000000000..cef206ff83 --- /dev/null +++ b/.github/scripts/npm_alpha_tag.py @@ -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 - 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-, 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])) diff --git a/.github/scripts/test_releases.py b/.github/scripts/test_releases.py index f012a911d5..2133724e63 100644 --- a/.github/scripts/test_releases.py +++ b/.github/scripts/test_releases.py @@ -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() diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 26d4985a15..c542015cfd 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1778,6 +1778,11 @@ jobs: contents: read steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: @@ -1898,6 +1903,12 @@ jobs: ;; esac + # The workflow concurrency group holds the lock from this lookup + # through publish, including concurrent alpha/hotfix release runs. + if [[ "${NPM_TAG}" == "alpha" ]]; then + tag="$(python3 .github/scripts/npm_alpha_tag.py "${tarball}" "${VERSION}" "${tag}")" + fi + publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") if [[ -n "${tag}" ]]; then publish_cmd+=(--tag "${tag}")