Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions codex/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,18 @@ runs:
exit 1
fi

# Install the Codex CLI from npm. `@openai/codex` ships a prebuilt
# per-platform binary, so this is ~3 s with no Rust toolchain. We pin
# an explicit version (`codex_version`, default a `0.131.0-alpha.*`)
# because tend needs `codex plugin add` (PR #21396, first in
# `rust-v0.131.0-alpha.17`) and as of 2026-05-15 npm's `latest`
# (0.130.0) still predates it — only the `alpha` dist-tag carries it.
# Bump the default once a stable npm release ships PR #21396.
# Install the Codex CLI from npm, on the retry window the other pre-agent
# installers share (shared/steps/lib/retry.sh). We pin an explicit version
# (`codex_version`, default a `0.131.0-alpha.*`) because tend needs
# `codex plugin add` (PR #21396, first in `rust-v0.131.0-alpha.17`) and as
# of 2026-05-15 npm's `latest` (0.130.0) still predates it — only the
# `alpha` dist-tag carries it. Bump the default once a stable npm release
# ships PR #21396.
- name: Install Codex CLI
shell: bash
run: |
npm install -g "@openai/codex@${{ inputs.codex_version }}"
codex --version
run: bash "${{ github.action_path }}/../shared/steps/install-codex-cli.sh"
env:
CODEX_VERSION: ${{ inputs.codex_version }}

# Install the tend-ci-runner plugin from the action's own checkout.
# `github.action_path` is .../<runner-cache>/max-sixty/tend/<ref>/codex/;
Expand Down
127 changes: 125 additions & 2 deletions generator/tests/test_shared_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -1390,8 +1390,8 @@ def _run_install(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
)


def _attempts(env: dict[str, str]) -> int:
return int(Path(env["CURL_ATTEMPTS"]).read_text().strip())
def _attempts(env: dict[str, str], key: str = "CURL_ATTEMPTS") -> int:
return int(Path(env[key]).read_text().strip())


def _sleeps(env: dict[str, str]) -> list[int]:
Expand Down Expand Up @@ -1573,3 +1573,126 @@ def test_install_proxy_uv_installs_first_try_without_sleeping(
assert _attempts(proxy_uv_env) == 1, _attempts(proxy_uv_env)
assert _sleeps(proxy_uv_env) == [], _sleeps(proxy_uv_env)
assert "uvx-fake" in result.stdout, result.stdout


# ---------------------------------------------------------------------------
# install-codex-cli.sh — the same retry contract, on the npm registry
# ---------------------------------------------------------------------------

INSTALL_CODEX_CLI = REPO_ROOT / "shared" / "steps" / "install-codex-cli.sh"

# Same failure schedule as the curl fakes, answering with npm's registry-side
# error instead. On the attempt that succeeds it plants a `codex` on PATH, so
# the script's closing `codex --version` has something to run.
#
# The package spec is read off the last argument rather than a fixed position,
# so a flag added to the install command doesn't silently turn the version the
# fake echoes into `-g`.
FAKE_NPM = """#!/usr/bin/env bash
spec=${*: -1}
n=$(cat "$NPM_ATTEMPTS" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$NPM_ATTEMPTS"
if [ "$n" -le "${NPM_FAILURES:-0}" ]; then
echo "npm error code E503" >&2
echo "npm error 503 Service Unavailable - GET https://registry.npmjs.org/$spec" >&2
exit 1
fi
printf '#!/usr/bin/env bash\\necho codex-cli %s\\n' "${spec##*@}" > "$FAKE_BIN/codex"
chmod +x "$FAKE_BIN/codex"
"""


@pytest.fixture
def codex_cli_env(tmp_path: Path) -> dict[str, str]:
"""A fake npm/sleep on PATH plus the env the codex install step is given."""
bindir = _fake_bin(tmp_path, npm=FAKE_NPM, sleep=FAKE_SLEEP_RECORDING)

return {
"PATH": f"{bindir}:/usr/bin:/bin",
"CODEX_VERSION": "0.131.0-alpha.22",
"FAKE_BIN": str(bindir),
"NPM_ATTEMPTS": str(tmp_path / "npm-attempts"),
"SLEEPS": str(tmp_path / "sleeps"),
}


def _run_codex_cli(env: dict[str, str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(INSTALL_CODEX_CLI)],
env=env,
capture_output=True,
text=True,
)


def test_install_codex_cli_rides_out_a_registry_burst(
codex_cli_env: dict[str, str],
) -> None:
"""The npm install is as exposed as the two CDN ones, and gets the window.

It is the codex action's only third-party reach, and it runs ahead of the
agent step, so a registry blip that exhausts its retries costs the whole
run — the sibling failure mode, with registry.npmjs.org in place of a CDN.
"""
codex_cli_env["NPM_FAILURES"] = "4"

result = _run_codex_cli(codex_cli_env)

assert result.returncode == 0, (
f"gave up on a blip it should have ridden out; stderr:\n{result.stderr}"
)
assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 5, _attempts(
codex_cli_env, "NPM_ATTEMPTS"
)


def test_install_codex_cli_backs_off_exponentially(
codex_cli_env: dict[str, str],
) -> None:
"""Each wait at least doubles; only the floors are pinned, jitter is free."""
codex_cli_env["NPM_FAILURES"] = "99"

_run_codex_cli(codex_cli_env)

assert _sleeps(codex_cli_env) == pytest.approx([5, 10, 20, 40], abs=9), (
f"backoff did not double: {_sleeps(codex_cli_env)}"
)
assert all(
actual >= floor
for actual, floor in zip(_sleeps(codex_cli_env), [5, 10, 20, 40], strict=True)
), f"slept less than the backoff floor: {_sleeps(codex_cli_env)}"


def test_install_codex_cli_reddens_when_every_attempt_fails(
codex_cli_env: dict[str, str],
) -> None:
"""A registry that stays down still fails the step, and says how hard it tried."""
codex_cli_env["NPM_FAILURES"] = "99"

result = _run_codex_cli(codex_cli_env)

assert result.returncode != 0, result.stdout
assert "after 5 attempts" in result.stdout, result.stdout
assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 5, _attempts(
codex_cli_env, "NPM_ATTEMPTS"
)


def test_install_codex_cli_installs_first_try_without_sleeping(
codex_cli_env: dict[str, str],
) -> None:
"""The happy path is one install, no delay, and a codex that answers.

The version assertion is what keeps the pin honest: the step has to install
the `codex_version` it was handed, not npm's `latest`, which still predates
`codex plugin add`.
"""
result = _run_codex_cli(codex_cli_env)

assert result.returncode == 0, result.stderr
assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 1, _attempts(
codex_cli_env, "NPM_ATTEMPTS"
)
assert _sleeps(codex_cli_env) == [], _sleeps(codex_cli_env)
assert "codex-cli 0.131.0-alpha.22" in result.stdout, result.stdout
2 changes: 1 addition & 1 deletion shared/steps/install-claude-binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ set -eo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)

# Retry transient 403s/5xxs from the installer CDN, on the window lib/retry.sh
# holds for both pre-agent installers. The lib is concatenated onto the front
# holds for the pre-agent installers. The lib is concatenated onto the front
# of the sandbox script rather than sourced from inside it: nothing grants the
# sandbox UID read access to the action's own checkout — setup-sandbox.sh
# grants traversal (o+x) on the workspace's ancestors only — whereas stdin is a
Expand Down
24 changes: 24 additions & 0 deletions shared/steps/install-codex-cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Install the pinned Codex CLI from npm.
#
# The npm registry is the only third-party reach in the codex action, and this
# step runs ahead of `Run Codex` — so a registry blip that isn't ridden out
# costs the whole run, which is the failure lib/retry.sh's window exists for.
#
# `@openai/codex` ships a prebuilt per-platform binary: two packages, no
# dependency tree to resolve and no Rust toolchain to run. Measured on a
# GitHub-hosted runner with an empty npm cache, 2026-08-09: 2.6-3.1 s across
# three installs, which sits far enough inside retry_install's 60 s timeout
# that npm shares the lib's window rather than needing one of its own.
#
# Inputs (env): CODEX_VERSION.
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib/retry.sh
. "${SCRIPT_DIR}/lib/retry.sh"

retry_install "codex ${CODEX_VERSION}" \
"npm install -g '@openai/codex@${CODEX_VERSION}'"

codex --version
20 changes: 10 additions & 10 deletions shared/steps/lib/retry.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# The retry window the pre-agent installers share: install-claude-binary.sh
# and install-proxy-uv.sh both fetch from a CDN before the agent step exists,
# so a blip that exhausts their retries costs the whole run — the step goes red
# having done none of the work the trigger asked for. The lost run is what
# justifies a window this wide, independent of how the failure is reported
# afterwards.
# The retry window the pre-agent installers share: install-claude-binary.sh,
# install-proxy-uv.sh and install-codex-cli.sh each reach a third party — two
# CDNs and the npm registry — before the agent step exists, so a blip that
# exhausts their retries costs the whole run: the step goes red having done
# none of the work the trigger asked for. The lost run is what justifies a
# window this wide, independent of how the failure is reported afterwards.
#
# Sourced, not executed.

Expand All @@ -14,10 +14,10 @@
# rate limit hits them together, and an unjittered backoff has them retry
# together too.
#
# The inner `set -o pipefail` is required by both callers' `curl | sh`
# shape: without it a curl failure passes empty stdin to the downstream
# shell, which exits 0, masking the failure so the loop breaks after one
# attempt without retrying.
# The inner `set -o pipefail` is required by the `curl | sh` callers:
# without it a curl failure passes empty stdin to the downstream shell,
# which exits 0, masking the failure so the loop breaks after one attempt
# without retrying.
retry_install() {
local label=$1 cmd=$2 attempts=5 i backoff
for i in $(seq 1 "$attempts"); do
Expand Down
Loading