diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69d62aab51..5696647f06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,11 @@ env: OMP_NUM_THREADS: "1" OPENBLAS_NUM_THREADS: "1" MKL_NUM_THREADS: "1" + # uv takes a runner image's own Python when it holds no managed one, and an + # image's Python can trail the patch release a test needs. On 2026-11-10 an + # ubuntu-26.04 runner gave an engine-free leg its Python 3.14.4, where the + # head-to-head scorer's worker recycling needs 3.14.7. + UV_MANAGED_PYTHON: "1" # GitHub runs an explicit `shell: bash` as `bash -eo pipefail`, and a step # with no shell as `bash -e`, where a pipe hides the exit status of the command @@ -30,12 +35,16 @@ defaults: # for engine-uk (45) and integration-uk (8), and for each shorter job at least # 5 minutes and at least twice its slowest run. +# Every job names its runner image. `ubuntu-latest` is whichever image GitHub +# assigns: on 2026-11-10 it gave one engine-free job ubuntu-26.04 and another +# ubuntu-24.04. Move every job to a newer image in one change. + jobs: select-countries: permissions: contents: read pull-requests: read - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: run_us: ${{ steps.plan.outputs.run_us }} @@ -49,7 +58,7 @@ jobs: run: python3 tools/ci_test_plan.py select-countries lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -68,7 +77,7 @@ jobs: run: uv run --no-sync ruff check . engine-free: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 165 strategy: fail-fast: false @@ -92,7 +101,7 @@ jobs: engine-us: needs: select-countries if: needs['select-countries'].outputs.run_us == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 110 strategy: fail-fast: false @@ -123,7 +132,7 @@ jobs: engine-uk: needs: select-countries if: needs['select-countries'].outputs.run_uk == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 90 strategy: fail-fast: false @@ -144,7 +153,7 @@ jobs: if: needs['select-countries'].outputs.run_uk == 'true' permissions: contents: read - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -161,7 +170,7 @@ jobs: wheels: # Editable installs can hide missing package directories and namespace # mistakes. Build each wheel once and compare its contents with source. - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -178,7 +187,7 @@ jobs: # a loopback stand-in. It needs no secrets. permissions: contents: read - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -212,7 +221,7 @@ jobs: - installed-emitter permissions: contents: read - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - uses: actions/checkout@v4 diff --git a/changelog.d/ci-pin-runner-image.fixed.md b/changelog.d/ci-pin-runner-image.fixed.md new file mode 100644 index 0000000000..2973429f06 --- /dev/null +++ b/changelog.d/ci-pin-runner-image.fixed.md @@ -0,0 +1 @@ +The Tests workflow runs every job on `ubuntu-24.04` and sets `UV_MANAGED_PYTHON=1`, so uv downloads its own Python. A job that GitHub placed on an `ubuntu-26.04` runner took that image's Python 3.14.4 and failed the head-to-head scorer's worker-recycling test, which needs 3.14.7. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 81e9408a8e..78aeca3782 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -218,6 +218,9 @@ Every workflow job sets `timeout-minutes` (at least 1.5 times its slowest observed successful run) so a hung job releases its runner from the organization's shared pool; raise a job's cap when its suite legitimately grows past it. +Every workflow job runs on `ubuntu-24.04`, and the workflow sets +`UV_MANAGED_PYTHON=1`, so uv downloads its own Python and never takes the +image's. Move every job to a newer image in one pull request. `load_country_spec("")` loads each packaged country spec once per process and hands every caller the same immutable object; a `Path` argument is diff --git a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py index 91eea5e489..aa6b851f7d 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_ci_integration_job.py @@ -118,6 +118,21 @@ def test_every_workflow_job_caps_its_runtime() -> None: assert type(cap) is int and 0 < cap < 360, name +def test_every_workflow_job_runs_on_one_pinned_image_with_uv_managed_python() -> None: + """A job's image and interpreter must not depend on which runner GitHub assigns. + + ``ubuntu-latest`` moves between images, and uv takes an image's own Python + when it holds no managed one, so a leg can run a patch release that + predates a fix its tests need. + """ + workflow = yaml.safe_load(_TEST_WORKFLOW.read_text(encoding="utf-8")) + + assert workflow["env"]["UV_MANAGED_PYTHON"] == "1" + images = {name: job.get("runs-on") for name, job in workflow["jobs"].items()} + assert images + assert set(images.values()) == {"ubuntu-24.04"}, images + + def test_workflow_uses_one_country_selector_only_for_country_jobs() -> None: workflow = _TEST_WORKFLOW.read_text(encoding="utf-8") selector = workflow.split("\n select-countries:\n", maxsplit=1)[1].split(