mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Guard prerelease channel and canary updates against older versions (#47597)
## Why Publishing a hotfix for an older release line or alpha can replace a newer prerelease pointer. Release pointers should advance according to version order. ## What changed - Share release version validation and numeric comparison across publishing scripts, including alpha hotfix suffixes and alpha, beta, and stable ordering. - Read the prerelease pointer directly from R2 and update it only for a newer version or missing or invalid version metadata. - Restrict `latest-alpha-cli` updates to alpha releases with successful R2 and npm publishing, and compare against the branch's Cargo version before advancing it. Allow replacement when the manifest version cannot be parsed or validated. ## Testing Add 15 passing unit tests covering version validation, older release lines and alphas, numeric hotfix ordering, prerelease precedence, equal versions, and missing or invalid versions. GitOrigin-RevId: 6bee3906a47f613b662ef2b2224cfcbd1b56650c
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare a publishing release with the canary Cargo manifest from stdin.
|
||||
|
||||
Usage: check_github_canary.py RELEASE_VERSION
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
from releases import should_update_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
current_version = tomllib.loads(sys.stdin.read())["workspace"]["package"][
|
||||
"version"
|
||||
]
|
||||
except (tomllib.TOMLDecodeError, KeyError, TypeError):
|
||||
current_version = ""
|
||||
if not isinstance(current_version, str):
|
||||
current_version = ""
|
||||
print(str(should_update_version(sys.argv[1], current_version)).lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,7 +8,8 @@ checksum and checked using object metadata before the run succeeds. The
|
||||
versioned prefix includes every release asset plus installer-facing
|
||||
``release.json`` metadata derived from the verified downloads. Once those
|
||||
objects are verified, the same metadata advances ``codex/channels/latest`` when
|
||||
the release is marked latest and ``codex/channels/prerelease`` for prereleases.
|
||||
the release is marked latest and ``codex/channels/prerelease`` for prereleases
|
||||
that are newer than its current version, or when that version is unreadable.
|
||||
Stable releases also update the mutable ``codex/install.sh`` and
|
||||
``codex/install.ps1`` bootstrap aliases from their verified versioned assets.
|
||||
"""
|
||||
@@ -26,18 +27,15 @@ from pathlib import Path
|
||||
from typing import Any, NamedTuple, NoReturn
|
||||
from urllib.parse import quote
|
||||
|
||||
from releases import is_valid_release_version, should_update_version
|
||||
|
||||
BUCKET = "releases"
|
||||
PREFIX = "codex"
|
||||
REPOSITORY = "openai/codex"
|
||||
RELEASE_METADATA_NAME = "release.json"
|
||||
PRERELEASE_CHANNEL_KEY = f"{PREFIX}/channels/prerelease"
|
||||
INSTALLER_NAMES = ("install.sh", "install.ps1")
|
||||
MAX_UPLOAD_WORKERS = 8
|
||||
# Keep this pattern in sync with release-tag validation in
|
||||
# .github/workflows/rust-release.yml.
|
||||
VERSION_RE = re.compile(
|
||||
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha(?:\.[0-9]+){0,2}"
|
||||
r"|beta(?:\.[0-9]+)?))?$"
|
||||
)
|
||||
CRC64_RE = re.compile(r"^[A-Za-z0-9+/]{11}=$")
|
||||
SHA256_RE = re.compile(r"^sha256:([0-9a-f]{64})$")
|
||||
MISSING_OBJECT_RE = re.compile(r"\((?:404|NoSuchKey|NotFound)\)")
|
||||
@@ -265,6 +263,29 @@ def verify_remote(
|
||||
)
|
||||
|
||||
|
||||
def should_update_prerelease(endpoint: str, version: str) -> bool:
|
||||
# Read R2 directly: the public CDN may still serve an older version.
|
||||
url = f"s3://{BUCKET}/{PRERELEASE_CHANNEL_KEY}"
|
||||
try:
|
||||
raw = run_command(["aws", "s3", "cp", url, "-", "--endpoint-url", endpoint])
|
||||
except subprocess.CalledProcessError as error:
|
||||
if MISSING_OBJECT_RE.search(error.stderr or ""):
|
||||
return True
|
||||
raise_s3("read", PRERELEASE_CHANNEL_KEY, error, (error.stderr or "").strip())
|
||||
except OSError as error:
|
||||
raise_s3("read", PRERELEASE_CHANNEL_KEY, error)
|
||||
|
||||
try:
|
||||
metadata = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return True
|
||||
tag = metadata.get("tag_name") if isinstance(metadata, dict) else None
|
||||
if not isinstance(tag, str) or not tag.startswith("rust-v"):
|
||||
return True
|
||||
|
||||
return should_update_version(version, tag.removeprefix("rust-v"))
|
||||
|
||||
|
||||
def publish_installers(endpoint: str, tag: str, assets: list[ReleaseAsset]) -> None:
|
||||
installers = {asset.path.name: asset for asset in assets}
|
||||
missing = sorted(set(INSTALLER_NAMES) - installers.keys())
|
||||
@@ -384,7 +405,7 @@ def main() -> int:
|
||||
raise PublishError("AWS_ENDPOINT_URL is required for the R2 S3 endpoint")
|
||||
|
||||
version = args.tag.removeprefix("rust-v")
|
||||
if args.tag == version or not VERSION_RE.fullmatch(version):
|
||||
if args.tag == version or not is_valid_release_version(version):
|
||||
raise PublishError(f"invalid rust release tag: {args.tag}")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
assets_directory = Path(temp_dir) / "assets"
|
||||
@@ -481,7 +502,9 @@ def main() -> int:
|
||||
channels = []
|
||||
if args.make_latest == "true":
|
||||
channels.append("latest")
|
||||
if args.prerelease == "true":
|
||||
if args.prerelease == "true" and should_update_prerelease(
|
||||
endpoint, version
|
||||
):
|
||||
channels.append("prerelease")
|
||||
for channel in channels:
|
||||
channel_key = f"{PREFIX}/channels/{channel}"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Validate and compare the version formats used by Codex releases."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
_VERSION_RE = re.compile(
|
||||
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha(?:\.[0-9]+){0,2}"
|
||||
r"|beta(?:\.[0-9]+)?))?$"
|
||||
)
|
||||
|
||||
|
||||
def is_valid_release_version(version: str) -> bool:
|
||||
return _VERSION_RE.fullmatch(version) is not None
|
||||
|
||||
|
||||
def _version_key(version: str) -> tuple[tuple[int, ...], int, tuple[int, ...]]:
|
||||
base, _, prerelease = version.partition("-")
|
||||
channel, _, suffix = prerelease.partition(".")
|
||||
return (
|
||||
tuple(int(part) for part in base.split(".")),
|
||||
{"alpha": 0, "beta": 1, "": 2}[channel],
|
||||
tuple(int(part) for part in suffix.split(".")) if suffix else (),
|
||||
)
|
||||
|
||||
|
||||
def should_update_version(version: str, current_version: str) -> bool:
|
||||
"""Compare version strings; replace an unreadable current version."""
|
||||
if not is_valid_release_version(version):
|
||||
raise ValueError(f"invalid release version: {version}")
|
||||
if not is_valid_release_version(current_version):
|
||||
# Replace an unreadable pointer so publishing can repair it.
|
||||
return True
|
||||
|
||||
return _version_key(version) > _version_key(current_version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if is_valid_release_version(sys.argv[1]) else 1)
|
||||
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
|
||||
from releases import is_valid_release_version, should_update_version
|
||||
|
||||
|
||||
class VersionComparisonTest(unittest.TestCase):
|
||||
def test_validation_accepts_alpha_hotfix(self) -> None:
|
||||
self.assertTrue(is_valid_release_version("0.123.0-alpha.5.2"))
|
||||
|
||||
def test_validation_rejects_extra_component(self) -> None:
|
||||
self.assertFalse(is_valid_release_version("0.123.0-alpha.5.2.3"))
|
||||
|
||||
def test_next_alpha_after_public_release(self) -> None:
|
||||
self.assertTrue(should_update_version("0.124.0-alpha.1", "0.123.0"))
|
||||
|
||||
def test_public_release_after_next_alpha(self) -> None:
|
||||
self.assertFalse(should_update_version("0.123.0", "0.124.0-alpha.1"))
|
||||
|
||||
def test_hotfix_for_an_older_release_line(self) -> None:
|
||||
self.assertFalse(should_update_version("0.100.0-alpha.1.2", "0.123.0-alpha.5"))
|
||||
|
||||
def test_hotfix_for_an_older_alpha(self) -> None:
|
||||
self.assertFalse(should_update_version("0.123.0-alpha.2.3", "0.123.0-alpha.10"))
|
||||
|
||||
def test_hotfix_for_the_current_alpha(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0-alpha.5.2", "0.123.0-alpha.5"))
|
||||
|
||||
def test_hotfix_numbers_compare_numerically(self) -> None:
|
||||
self.assertTrue(
|
||||
should_update_version("0.123.0-alpha.5.10", "0.123.0-alpha.5.2")
|
||||
)
|
||||
|
||||
def test_numbered_alpha_after_bare_alpha(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0-alpha.1", "0.123.0-alpha"))
|
||||
|
||||
def test_beta_after_alpha(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0-beta", "0.123.0-alpha.10"))
|
||||
|
||||
def test_public_release_after_beta(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0", "0.123.0-beta.2"))
|
||||
|
||||
def test_equal_version(self) -> None:
|
||||
self.assertFalse(should_update_version("0.123.0-alpha.5", "0.123.0-alpha.5"))
|
||||
|
||||
def test_missing_current_version(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0-alpha.5", ""))
|
||||
|
||||
def test_invalid_current_version(self) -> None:
|
||||
self.assertTrue(should_update_version("0.123.0-alpha.5", "0.123"))
|
||||
|
||||
def test_invalid_release_version(self) -> None:
|
||||
self.assertRaises(ValueError, should_update_version, "0.123", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
RELEASE_MAKE_LATEST: ${{ inputs.make_latest }}
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
RELEASE_STAGE: ${{ inputs.stage }}
|
||||
PYTHONPATH: .github/scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The publisher parallelizes objects; serialize parts within each object to avoid R2 throttling.
|
||||
|
||||
@@ -34,6 +34,9 @@ jobs:
|
||||
- name: Test downstream Python release resolution
|
||||
run: python3 -m unittest discover -s .github/scripts -p 'test_resolve_python_cli_release.py'
|
||||
|
||||
- name: Test release version comparison
|
||||
run: python3 -m unittest discover -s .github/scripts -p 'test_releases.py'
|
||||
|
||||
- name: Test PyPI release verification
|
||||
run: uv run --no-project --with packaging==26.2 python -m unittest discover -s .github/scripts -p 'test_verify_pypi_release.py'
|
||||
|
||||
|
||||
@@ -41,13 +41,12 @@ jobs:
|
||||
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|
||||
|| { echo "❌ Not a tag ref"; exit 1; }
|
||||
|
||||
# Release tags must match the version in Cargo.toml.
|
||||
# Keep this pattern in sync with VERSION_RE in
|
||||
# .github/scripts/publish_r2_release.py.
|
||||
[[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha(\.[0-9]+){0,2}|beta(\.[0-9]+)?))?$ ]] \
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v}"
|
||||
[[ "${GITHUB_REF_NAME}" == rust-v* ]] \
|
||||
&& python3 .github/scripts/releases.py "${tag_ver}" \
|
||||
|| { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; }
|
||||
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v}"
|
||||
# Release tags must match the version in Cargo.toml.
|
||||
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
|
||||
| sed -E 's/version *= *"([^"]+)".*/\1/')"
|
||||
|
||||
@@ -2019,11 +2018,9 @@ jobs:
|
||||
${{
|
||||
!cancelled() &&
|
||||
needs.release.result == 'success' &&
|
||||
needs.release.outputs.npm_tag == 'alpha' &&
|
||||
needs.publish-r2.result == 'success' &&
|
||||
(
|
||||
needs.release.outputs.should_publish_npm != 'true' ||
|
||||
needs.publish-npm.result == 'success'
|
||||
)
|
||||
needs.publish-npm.result == 'success'
|
||||
}}
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -2035,7 +2032,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Determine whether to update the GitHub canary branch
|
||||
id: github_canary
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_VERSION: ${{ needs.release.outputs.version }}
|
||||
PYTHONPATH: .github/scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Despite its name, raw+json returns raw TOML. The default response
|
||||
# is JSON metadata with the file contents encoded as base64.
|
||||
update="$(gh api "repos/$GITHUB_REPOSITORY/contents/codex-rs/Cargo.toml?ref=latest-alpha-cli" \
|
||||
-H 'Accept: application/vnd.github.raw+json' \
|
||||
| python3 .github/scripts/check_github_canary.py "$RELEASE_VERSION")"
|
||||
echo "update=$update" >> "$GITHUB_OUTPUT"
|
||||
- name: Update latest-alpha-cli branch
|
||||
if: success() && steps.github_canary.outputs.update == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
||||
Reference in New Issue
Block a user