diff --git a/.github/scripts/check_github_canary.py b/.github/scripts/check_github_canary.py new file mode 100644 index 0000000000..b8b74f4a1e --- /dev/null +++ b/.github/scripts/check_github_canary.py @@ -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() diff --git a/.github/scripts/publish_r2_release.py b/.github/scripts/publish_r2_release.py index e7d351ae75..5b6ded1331 100755 --- a/.github/scripts/publish_r2_release.py +++ b/.github/scripts/publish_r2_release.py @@ -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}" diff --git a/.github/scripts/releases.py b/.github/scripts/releases.py new file mode 100644 index 0000000000..efb0bba116 --- /dev/null +++ b/.github/scripts/releases.py @@ -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) diff --git a/.github/scripts/test_releases.py b/.github/scripts/test_releases.py new file mode 100644 index 0000000000..f012a911d5 --- /dev/null +++ b/.github/scripts/test_releases.py @@ -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() diff --git a/.github/workflows/r2-release.yml b/.github/workflows/r2-release.yml index 6a35514cb5..d1783bb2d3 100644 --- a/.github/workflows/r2-release.yml +++ b/.github/workflows/r2-release.yml @@ -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. diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index d43bcdccc0..4f05cd0604 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -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' diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 25ffd2d734..26d4985a15 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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: |