From b5115cdd669d37a8635a9742851e97ba4fb1d42c Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Fri, 16 Oct 2026 14:41:28 +0000 Subject: [PATCH] Resolve stable releases dynamically for exec-server compatibility tests (#54623) ## What changed Replace fixed release pins for app-server/exec-server compatibility tests with the latest stable version and the stable release published closest to 60 days ago. Exclude drafts and prereleases, and prefer the higher version when publication dates are equally close. Resolve release versions and SHA-256 asset digests before Linux Bazel CI runs, passing them through `CODEX_COMPAT_RELEASES` to the release archive repositories. Reuse the argument comment lint action so it also prepares compatibility releases. Add bounded retries for transient GitHub release reads. ## Testing Add unit coverage for stable release filtering, version ordering, nearest publication dates, tie-breaking, and deduplication when both selections resolve to the same release. GitOrigin-RevId: 5a8cf8bdabd77ab3fa89bbb711367b5d9ce8044b --- .../run-argument-comment-lint/action.yml | 9 ++ .github/scripts/contract_releases.py | 126 ++++++++++++++++++ .../scripts/resolve_exec_server_releases.py | 39 ++++++ .github/scripts/test_contract_releases.py | 52 ++++++++ .github/workflows/bazel.yml | 24 ++++ .github/workflows/rust-ci-full.yml | 36 +---- MODULE.bazel | 24 ++-- bazel/extensions/codex_release_archive.bzl | 38 ++---- codex-rs/exec-server/BUILD.bazel | 7 +- 9 files changed, 275 insertions(+), 80 deletions(-) create mode 100644 .github/scripts/contract_releases.py create mode 100644 .github/scripts/resolve_exec_server_releases.py create mode 100644 .github/scripts/test_contract_releases.py diff --git a/.github/actions/run-argument-comment-lint/action.yml b/.github/actions/run-argument-comment-lint/action.yml index 31c752e86e..bcd54a9b94 100644 --- a/.github/actions/run-argument-comment-lint/action.yml +++ b/.github/actions/run-argument-comment-lint/action.yml @@ -24,6 +24,15 @@ runs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + - name: Resolve app-server/exec-server compatibility releases + if: ${{ runner.os == 'Linux' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + releases="$(python3 .github/scripts/resolve_exec_server_releases.py)" + echo "CODEX_COMPAT_RELEASES=$releases" >> "$GITHUB_ENV" + - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os != 'Windows' }} env: diff --git a/.github/scripts/contract_releases.py b/.github/scripts/contract_releases.py new file mode 100644 index 0000000000..c383a94a4d --- /dev/null +++ b/.github/scripts/contract_releases.py @@ -0,0 +1,126 @@ +"""Discover and download published Codex packages for contract runners.""" + +import json +import re +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +ALPHA_TAG = re.compile(r"rust-v[0-9]+\.[0-9]+\.[0-9]+-alpha(?:\.[0-9]+){0,2}") +RELEASE_TAG = re.compile(r"rust-v[0-9]+\.[0-9]+\.[0-9]+(?:-alpha(?:\.[0-9]+){0,2})?") +STABLE_TAG = re.compile(r"rust-v[0-9]+\.[0-9]+\.[0-9]+") +PACKAGE = "codex-package-x86_64-unknown-linux-musl.tar.gz" + + +def github_read(args: list[str]) -> str: + """Retry release reads unless the error is known permanent; never retry verification.""" + for attempt, delay in enumerate((2, 5, 0), start=1): + try: + return subprocess.check_output( + args, text=True, stderr=subprocess.PIPE, timeout=120 + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + reason = "timeout" + exit_code = None + http_status = "unknown" + retryable = True + if isinstance(error, subprocess.CalledProcessError): + message = error.stderr or "" + status = re.search(r"\bHTTP (\d{3})\b", message) + reason = "command_failed" + exit_code = error.returncode + http_status = status[1] if status else "unknown" + # gh uses 1 for general failures, 2 for cancellation and 4 for auth. + # Unknown transport/protocol wording should still get bounded retries. + retryable = ( + error.returncode == 1 + and (status is None or status[1] in {"502", "503", "504"}) + and not any( + marker in message.lower() + for marker in ( + "release not found", + "no assets match", + "no assets to download", + "unknown flag", + "unknown command", + ) + ) + ) + # Only emit classified fields; stderr can contain unknown credential formats. + will_retry = retryable and bool(delay) + print( + f"GitHub release read failed: attempt={attempt} reason={reason} " + f"exit_code={exit_code} http_status={http_status} " + f"will_retry={str(will_retry).lower()}", + file=sys.stderr, + ) + if not will_retry: + raise + print(f"Retrying GitHub release read in {delay}s.", file=sys.stderr) + time.sleep(delay) + + +def list_releases(*, exclude_prereleases: bool = False) -> list[dict]: + # GraphQL-backed listing avoids fetching assets and release notes. + args = [ + "gh", + "release", + "list", + "--repo", + "openai/codex", + "--limit", + "100", + "--json", + "tagName,publishedAt,isDraft,isPrerelease", + ] + if exclude_prereleases: + args.append("--exclude-pre-releases") + return json.loads(github_read(args)) + + +def download_release(tag: str, directory: Path) -> Path: + github_read( + [ + "gh", + "release", + "download", + tag, + "--repo", + "openai/codex", + "--pattern", + PACKAGE, + "--dir", + str(directory), + # A failed download can leave a partial file in this temp directory. + "--clobber", + ] + ) + return directory / PACKAGE + + +def select_stables(releases: list[dict], target_date: datetime) -> list[str]: + stable = [ + release + for release in releases + if not release["isDraft"] + and not release["isPrerelease"] + and STABLE_TAG.fullmatch(release["tagName"]) + ] + stable.sort( + key=lambda release: tuple(map(int, release["tagName"][6:].split("."))), + reverse=True, + ) + if not stable: + raise ValueError("No stable releases available") + # The descending version order breaks equal-distance ties deterministically. + baseline = min( + stable, + key=lambda release: abs( + datetime.fromisoformat(release["publishedAt"].replace("Z", "+00:00")) + - target_date + ), + ) + # A long release pause can make latest and historical resolve to one build. + return list(dict.fromkeys([stable[0]["tagName"], baseline["tagName"]])) diff --git a/.github/scripts/resolve_exec_server_releases.py b/.github/scripts/resolve_exec_server_releases.py new file mode 100644 index 0000000000..1ba85d030b --- /dev/null +++ b/.github/scripts/resolve_exec_server_releases.py @@ -0,0 +1,39 @@ +"""Print CODEX_COMPAT_RELEASES JSON for the existing Bazel compatibility tests. + +For local runs, export CODEX_COMPAT_RELEASES="$(python3 )" +before invoking Bazel. CI resolves it once before building the test targets. +""" + +import json +import re +import sys +from datetime import datetime, timedelta, timezone + +from contract_releases import PACKAGE, github_read, list_releases, select_stables + +STABLE_LOOKBACK_DAYS = 60 + + +def main() -> None: + target_date = datetime.now(timezone.utc) - timedelta(days=STABLE_LOOKBACK_DAYS) + tags = select_stables(list_releases(exclude_prereleases=True), target_date) + releases = {} + for tag in tags: + metadata = json.loads( + github_read(["gh", "api", f"repos/openai/codex/releases/tags/{tag}"]) + ) + asset = next(asset for asset in metadata["assets"] if asset["name"] == PACKAGE) + digest = asset.get("digest") or "" + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ValueError(f"Missing SHA-256 digest for {tag}/{PACKAGE}") + releases[tag] = {"version": tag.removeprefix("rust-v"), "sha256": digest[7:]} + print( + f"Compatibility releases: latest={tags[0]}, " + f"closest to {target_date.isoformat()}={tags[-1]}", + file=sys.stderr, + ) + print(json.dumps({"latest": releases[tags[0]], "historical": releases[tags[-1]]})) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_contract_releases.py b/.github/scripts/test_contract_releases.py new file mode 100644 index 0000000000..99d18bf4be --- /dev/null +++ b/.github/scripts/test_contract_releases.py @@ -0,0 +1,52 @@ +import unittest +from datetime import datetime, timezone + +from contract_releases import select_stables + + +class StableReleaseSelectionTests(unittest.TestCase): + def test_selects_latest_and_closest_stable_by_publication_date(self): + releases = [ + { + "tagName": tag, + "publishedAt": date, + "isPrerelease": False, + "isDraft": False, + } + for tag, date in ( + ("rust-v0.9.9", "2026-02-08T00:00:00Z"), + ("rust-v0.10.2", "2026-01-10T00:00:00Z"), + ("rust-v0.10.10", "2026-01-13T00:00:00Z"), + ("rust-v0.10.11", "2026-01-16T00:00:00Z"), + ("rust-v1.0.1", "2026-01-20T00:00:00Z"), + ("rust-v1.1.0", "2026-02-06T00:00:00Z"), + ("rust-v2.0.0-beta.1", "2026-02-06T00:00:00Z"), + ) + ] + releases.extend( + [ + {"tagName": "rust-v3.0.0", "isPrerelease": True, "isDraft": False}, + {"tagName": "rust-v4.0.0", "isPrerelease": False, "isDraft": True}, + ] + ) + for target_date, expected in ( + # One day newer than the target beats two days older (59 vs. 62). + (datetime(2026, 1, 15, tzinfo=timezone.utc), "rust-v0.10.11"), + # The older release wins when it is closer. + (datetime(2026, 1, 14, tzinfo=timezone.utc), "rust-v0.10.10"), + # Equal distances prefer the higher version. + (datetime(2026, 1, 14, 12, tzinfo=timezone.utc), "rust-v0.10.11"), + ): + with self.subTest(target_date=target_date): + self.assertEqual( + select_stables(releases, target_date), + ["rust-v1.1.0", expected], + ) + self.assertEqual( + select_stables(releases, datetime(2026, 2, 6, tzinfo=timezone.utc)), + ["rust-v1.1.0"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 2f4b09deb1..1c4bd7126e 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -75,6 +75,14 @@ jobs: shell: bash run: ./scripts/check-module-bazel-lock.sh + - name: Resolve app-server/exec-server compatibility releases + if: runner.os == 'Linux' + env: + GH_TOKEN: ${{ github.token }} + run: | + releases="$(python3 .github/scripts/resolve_exec_server_releases.py)" + echo "CODEX_COMPAT_RELEASES=$releases" >> "$GITHUB_ENV" + - name: bazel test //... env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} @@ -367,6 +375,14 @@ jobs: target: ${{ matrix.target }} cache-scope: bazel-${{ github.job }} + - name: Resolve app-server/exec-server compatibility releases + if: runner.os == 'Linux' + env: + GH_TOKEN: ${{ github.token }} + run: | + releases="$(python3 .github/scripts/resolve_exec_server_releases.py)" + echo "CODEX_COMPAT_RELEASES=$releases" >> "$GITHUB_ENV" + - name: bazel build --config=clippy lint targets env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} @@ -466,6 +482,14 @@ jobs: target: ${{ matrix.target }} cache-scope: bazel-${{ github.job }} + - name: Resolve app-server/exec-server compatibility releases + if: runner.os == 'Linux' + env: + GH_TOKEN: ${{ github.token }} + run: | + releases="$(python3 .github/scripts/resolve_exec_server_releases.py)" + echo "CODEX_COMPAT_RELEASES=$releases" >> "$GITHUB_ENV" + - name: bazel build verify-release-build targets env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 4a61b6b12d..188f94ef45 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -113,41 +113,11 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: ./.github/actions/setup-bazel-ci + - name: Run argument comment lint on codex-rs via Bazel + uses: ./.github/actions/run-argument-comment-lint with: target: ${{ runner.os }} - - name: Install Linux sandbox build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os != 'Windows' }} - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" - ./.github/scripts/run-bazel-ci.sh \ - -- \ - build \ - --config=argument-comment-lint \ - --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ - -- \ - ${bazel_targets} - - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os == 'Windows' }} - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - ./.github/scripts/run-argument-comment-lint-bazel.sh \ - --config=argument-comment-lint \ - --platforms=//:local_windows \ - --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} + buildbuddy-api-key: ${{ secrets.BUILDBUDDY_API_KEY }} # --- CI to validate on different os/targets -------------------------------- lint_build: diff --git a/MODULE.bazel b/MODULE.bazel index 1fd46c3b5a..10f68a19bd 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -528,22 +528,16 @@ crate.annotation( http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") -codex_release_archive = use_extension( - "//bazel/extensions:codex_release_archive.bzl", - "codex_release_archive", +codex_release_archive = use_repo_rule("//bazel/extensions:codex_release_archive.bzl", "codex_release_archive") + +codex_release_archive( + name = "codex_release_latest_stable", + release = "latest", ) -codex_release_archive.release( - sha256 = "a676f5722aae0d86cbe3764332f08eab1fdd89dc0a300b3ad4ffbf48611fa3e3", - version = "0.162.1", -) -codex_release_archive.release( - sha256 = "71a28d362c96ac9829bf8203a2c71be451aeb726adb843167fdaf0eae8fe7dd9", - version = "0.145.0", -) -use_repo( - codex_release_archive, - "codex_release_0.145.0_linux_x86_64", - "codex_release_0.162.1_linux_x86_64", + +codex_release_archive( + name = "codex_release_historical_stable", + release = "historical", ) http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") diff --git a/bazel/extensions/codex_release_archive.bzl b/bazel/extensions/codex_release_archive.bzl index e363bace5a..1d244ce902 100644 --- a/bazel/extensions/codex_release_archive.bzl +++ b/bazel/extensions/codex_release_archive.bzl @@ -1,4 +1,4 @@ -"""Bazel module extension for pinned Codex release archives.""" +"""Materialize the stable release packages selected before running Bazel.""" _CODEX_RELEASE_BUILD_FILE = """\ package(default_visibility = ["//visibility:public"]) @@ -20,14 +20,18 @@ filegroup( """ def _codex_release_repository_impl(repository_ctx): + releases = repository_ctx.getenv("CODEX_COMPAT_RELEASES") + if not releases: + fail("Set CODEX_COMPAT_RELEASES to the JSON from resolve_exec_server_releases.py before building release compatibility targets.") + release = json.decode(releases)[repository_ctx.attr.release] asset = "codex-package-x86_64-unknown-linux-musl.tar.gz" - version = repository_ctx.attr.version + version = release["version"] repository_ctx.download_and_extract( url = [ "https://releases.openai.com/codex/releases/{}/{}".format(version, asset), "https://github.com/openai/codex/releases/download/rust-v{}/{}".format(version, asset), ], - sha256 = repository_ctx.attr.sha256, + sha256 = release["sha256"], ) manifest = json.decode(repository_ctx.read("codex-package.json")) entrypoint = manifest["entrypoint"] @@ -44,33 +48,9 @@ def _codex_release_repository_impl(repository_ctx): ) return repository_ctx.repo_metadata(reproducible = True) -_codex_release_repository = repository_rule( +codex_release_archive = repository_rule( implementation = _codex_release_repository_impl, attrs = { - "sha256": attr.string(mandatory = True), - "version": attr.string(mandatory = True), + "release": attr.string(mandatory = True, values = ["latest", "historical"]), }, ) - -_RELEASE = tag_class( - attrs = { - "sha256": attr.string(mandatory = True), - "version": attr.string(mandatory = True), - }, -) - -def _codex_release_archive_impl(module_ctx): - for module in module_ctx.modules: - for release in module.tags.release: - _codex_release_repository( - name = "codex_release_{}_linux_x86_64".format(release.version), - sha256 = release.sha256, - version = release.version, - ) - - return module_ctx.extension_metadata(reproducible = True) - -codex_release_archive = module_extension( - implementation = _codex_release_archive_impl, - tag_classes = {"release": _RELEASE}, -) diff --git a/codex-rs/exec-server/BUILD.bazel b/codex-rs/exec-server/BUILD.bazel index 88114f96a2..355e17e9ee 100644 --- a/codex-rs/exec-server/BUILD.bazel +++ b/codex-rs/exec-server/BUILD.bazel @@ -40,12 +40,13 @@ exec_server_compat_test( current_binary = "//codex-rs/cli:codex", ) +# Preparation selects both releases; the normal Bazel test invocation runs them. exec_server_compat_test( name = "exec-server-stable-release-test", - release = "@codex_release_0.162.1_linux_x86_64", + release = "@codex_release_latest_stable", ) exec_server_compat_test( - name = "exec-server-minimum-release-test", - release = "@codex_release_0.145.0_linux_x86_64", + name = "exec-server-historical-release-test", + release = "@codex_release_historical_stable", )