diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 3a76a202eae8..3d0504172de1 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -28,6 +28,9 @@ jobs: - name: Test Codex package builder run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py' + - name: Test standalone installer + run: python3 -m unittest discover -s scripts/install -p 'test_*.py' + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 6973d482e26d..986f6c6566b7 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -79,11 +79,10 @@ function Assert-ValidReleaseVersion { function Find-ReleaseAssetMetadata { param( [string]$AssetName, - [string]$ResolvedVersion + [object]$ReleaseMetadata ) - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/tags/rust-v$ResolvedVersion" - $asset = $release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 + $asset = $ReleaseMetadata.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 if ($null -eq $asset) { return $null } @@ -99,20 +98,6 @@ function Find-ReleaseAssetMetadata { } } -function Get-ReleaseAssetMetadata { - param( - [string]$AssetName, - [string]$ResolvedVersion - ) - - $metadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ResolvedVersion $ResolvedVersion - if ($null -eq $metadata) { - throw "Could not find release asset $AssetName for Codex $ResolvedVersion." - } - - return $metadata -} - function Test-ArchiveDigest { param( [string]$ArchivePath, @@ -216,22 +201,38 @@ function Remove-StaleInstallArtifacts { } } -function Resolve-Version { +function Resolve-Release { $normalizedVersion = Normalize-Version -RawVersion $Release Assert-ValidReleaseVersion -Version $normalizedVersion - if ($normalizedVersion -ne "latest") { - return $normalizedVersion + + if ($normalizedVersion -eq "latest") { + $requestedRelease = "latest" + $metadataUri = "https://api.github.com/repos/openai/codex/releases/latest" + } else { + $resolvedVersion = $normalizedVersion + $requestedRelease = $resolvedVersion + $metadataUri = "https://api.github.com/repos/openai/codex/releases/tags/rust-v$resolvedVersion" } - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/latest" - if (-not $release.tag_name) { - Write-Error "Failed to resolve the latest Codex release version." - exit 1 + try { + $releaseMetadata = Invoke-RestMethod -Uri $metadataUri + } catch { + throw "Could not fetch GitHub release metadata for Codex $requestedRelease. GitHub API may be unavailable or rate limited. $($_.Exception.Message)" } - $resolvedVersion = Normalize-Version -RawVersion $release.tag_name - Assert-ValidReleaseVersion -Version $resolvedVersion - return $resolvedVersion + if ($normalizedVersion -eq "latest") { + if (-not $releaseMetadata.tag_name) { + throw "Failed to resolve the latest Codex release version." + } + + $resolvedVersion = Normalize-Version -RawVersion $releaseMetadata.tag_name + Assert-ValidReleaseVersion -Version $resolvedVersion + } + + return [PSCustomObject]@{ + Version = $resolvedVersion + Metadata = $releaseMetadata + } } function Get-VersionFromBinary { @@ -746,7 +747,9 @@ if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { } $currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir -$resolvedVersion = Resolve-Version +$resolvedRelease = Resolve-Release +$resolvedVersion = $resolvedRelease.Version +$releaseMetadata = $resolvedRelease.Metadata $releaseName = "$resolvedVersion-$target" $releaseDir = Join-Path $releasesDir $releaseName @@ -765,12 +768,12 @@ $oldStandaloneBackup = $null $packageAsset = "codex-package-$target.tar.gz" $checksumAsset = "codex-package_SHA256SUMS" -$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion -$checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ResolvedVersion $resolvedVersion +$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata +$checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ReleaseMetadata $releaseMetadata $installLayout = "Package" if ($null -eq $packageMetadata -or $null -eq $checksumMetadata) { $packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" - $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion + $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata if ($null -ne $packageMetadata) { $installLayout = "LegacyPlatformNpm" } else { diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 7513efc42971..d44b353860dd 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -138,10 +138,36 @@ release_metadata_url() { printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version" } +resolve_release() { + normalized_version="$(normalize_version "$RELEASE")" + validate_version "$normalized_version" + + if [ "$normalized_version" = "latest" ]; then + requested_release="latest" + metadata_url="https://api.github.com/repos/openai/codex/releases/latest" + else + resolved_version="$normalized_version" + requested_release="$resolved_version" + metadata_url="$(release_metadata_url "$resolved_version")" + fi + + if ! release_json="$(download_text "$metadata_url")"; then + echo "Could not fetch GitHub release metadata for Codex $requested_release. GitHub API may be unavailable or rate limited." >&2 + exit 1 + fi + + if [ "$normalized_version" = "latest" ]; then + resolved_version="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)" + if [ -z "$resolved_version" ]; then + echo "Failed to resolve the latest Codex release version." >&2 + exit 1 + fi + validate_version "$resolved_version" + fi +} + release_asset_digest_or_empty() { asset="$1" - resolved_version="$2" - release_json="$(download_text "$(release_metadata_url "$resolved_version")")" digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" ' /"name":[[:space:]]*"[^"]+"/ { @@ -190,16 +216,14 @@ release_asset_digest_or_empty() { release_asset_exists() { asset="$1" - resolved_version="$2" - release_asset_digest_or_empty "$asset" "$resolved_version" >/dev/null 2>&1 + release_asset_digest_or_empty "$asset" >/dev/null 2>&1 } release_asset_digest() { asset="$1" - resolved_version="$2" - digest="$(release_asset_digest_or_empty "$asset" "$resolved_version" || true)" + digest="$(release_asset_digest_or_empty "$asset" || true)" if [ -z "$digest" ]; then echo "Could not find SHA-256 digest for release asset $asset." >&2 exit 1 @@ -275,27 +299,6 @@ require_command() { fi } -resolve_version() { - normalized_version="$(normalize_version "$RELEASE")" - validate_version "$normalized_version" - - if [ "$normalized_version" != "latest" ]; then - printf '%s\n' "$normalized_version" - return - fi - - release_json="$(download_text "https://api.github.com/repos/openai/codex/releases/latest")" - resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)" - - if [ -z "$resolved" ]; then - echo "Failed to resolve the latest Codex release version." >&2 - exit 1 - fi - - validate_version "$resolved" - printf '%s\n' "$resolved" -} - pick_profile() { # Use the same shell-specific split Homebrew documents because there is no # universal startup file across macOS/Linux login and interactive shells. @@ -839,14 +842,14 @@ else fi fi -resolved_version="$(resolve_version)" +resolve_release package_asset="codex-package-$vendor_target.tar.gz" checksum_asset="codex-package_SHA256SUMS" -if release_asset_exists "$package_asset" "$resolved_version" && - release_asset_exists "$checksum_asset" "$resolved_version"; then +if release_asset_exists "$package_asset" && + release_asset_exists "$checksum_asset"; then install_layout="package" asset="$package_asset" -elif release_asset_exists "codex-npm-$npm_tag-$resolved_version.tgz" "$resolved_version"; then +elif release_asset_exists "codex-npm-$npm_tag-$resolved_version.tgz"; then install_layout="legacy-platform-npm" asset="codex-npm-$npm_tag-$resolved_version.tgz" else @@ -893,12 +896,12 @@ if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" step "Downloading Codex CLI" if [ "$install_layout" = "package" ]; then - checksum_digest="$(release_asset_digest "$checksum_asset" "$resolved_version")" + checksum_digest="$(release_asset_digest "$checksum_asset")" download_file "$checksum_url" "$checksum_path" verify_archive_digest "$checksum_path" "$checksum_digest" expected_digest="$(package_archive_digest "$asset" "$checksum_path")" else - expected_digest="$(release_asset_digest "$asset" "$resolved_version")" + expected_digest="$(release_asset_digest "$asset")" fi download_file "$download_url" "$archive_path" verify_archive_digest "$archive_path" "$expected_digest" diff --git a/scripts/install/test_install_sh.py b/scripts/install/test_install_sh.py new file mode 100644 index 000000000000..556c6213bb01 --- /dev/null +++ b/scripts/install/test_install_sh.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +INSTALL_SCRIPT = Path(__file__).with_name("install.sh") +VERSION = "0.142.5" + + +class InstallShTest(unittest.TestCase): + def test_metadata_fetch_failure_is_not_reported_as_missing_assets(self) -> None: + result, requests = run_installer(VERSION, metadata_failure=True) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}" + ], + ) + self.assertIn( + f"Could not fetch GitHub release metadata for Codex {VERSION}", + result.stderr, + ) + self.assertNotIn("Could not find Codex package", result.stderr) + + def test_exact_release_fetches_metadata_once(self) -> None: + result, requests = run_installer(VERSION) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + def test_latest_release_reuses_version_metadata(self) -> None: + result, requests = run_installer("latest") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/latest", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + +def run_installer( + release: str, *, metadata_failure: bool = False +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + bin_dir = root / "bin" + bin_dir.mkdir() + request_log = root / "requests.log" + fake_curl = bin_dir / "curl" + fake_curl.write_text( + textwrap.dedent( + """\ + #!/bin/sh + url="" + for arg in "$@"; do + case "$arg" in + https://*) url="$arg" ;; + esac + done + printf '%s\n' "$url" >>"$CODEX_TEST_REQUEST_LOG" + + case "$url" in + https://api.github.com/*) + if [ "$CODEX_TEST_METADATA_FAILURE" = "1" ]; then + echo "curl: (22) The requested URL returned error: 403" >&2 + exit 22 + fi + printf '%s\n' "$CODEX_TEST_METADATA_JSON" + ;; + *) + exit 22 + ;; + esac + """ + ), + encoding="utf-8", + ) + fake_curl.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "CODEX_HOME": str(root / "codex-home"), + "CODEX_INSTALL_DIR": str(root / "install-bin"), + "CODEX_NON_INTERACTIVE": "1", + "CODEX_RELEASE": release, + "CODEX_TEST_METADATA_FAILURE": "1" if metadata_failure else "0", + "CODEX_TEST_METADATA_JSON": release_metadata(), + "CODEX_TEST_REQUEST_LOG": str(request_log), + "HOME": str(root / "home"), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "SHELL": "/bin/sh", + } + ) + result = subprocess.run( + ["/bin/sh", str(INSTALL_SCRIPT)], + capture_output=True, + check=False, + env=env, + text=True, + ) + requests = ( + request_log.read_text(encoding="utf-8").splitlines() + if request_log.exists() + else [] + ) + return result, requests + + +def release_metadata() -> str: + assets = [ + { + "name": f"codex-package-{target}.tar.gz", + "digest": f"sha256:{'a' * 64}", + } + for target in ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-musl", + "x86_64-unknown-linux-musl", + ) + ] + assets.append( + { + "name": "codex-package_SHA256SUMS", + "digest": f"sha256:{'b' * 64}", + } + ) + return json.dumps( + {"tag_name": f"rust-v{VERSION}", "assets": assets}, + indent=2, + ) + + +if __name__ == "__main__": + unittest.main()