diff --git a/.github/workflows/certify-uk-release.yaml b/.github/workflows/certify-uk-release.yaml index 1dfa07f7c..1ea4ed688 100644 --- a/.github/workflows/certify-uk-release.yaml +++ b/.github/workflows/certify-uk-release.yaml @@ -294,7 +294,7 @@ jobs: qualify: needs: [resolve, prepare] if: needs.prepare.outputs.duplicate == 'false' - runs-on: ubuntu-latest + runs-on: ${{ vars.CERTIFY_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CERTIFY_RUNNER_X64 || 'ubuntu-latest' }} # The local gates took 80-89 minutes while every wrapper.household # contract row errored at once. Since #651 those rows run the household # calculator, which takes the gates past two hours, so a 120-minute limit @@ -355,7 +355,7 @@ jobs: replay: needs: [resolve, prepare, qualify] if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.duplicate == 'false' - runs-on: ubuntu-latest + runs-on: ${{ vars.CERTIFY_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CERTIFY_RUNNER_X64 || 'ubuntu-latest' }} timeout-minutes: 355 permissions: contents: write diff --git a/.github/workflows/pr_code_changes.yaml b/.github/workflows/pr_code_changes.yaml index 2521a080d..0cacd40a3 100644 --- a/.github/workflows/pr_code_changes.yaml +++ b/.github/workflows/pr_code_changes.yaml @@ -1,4 +1,24 @@ # Workflow that runs on code changes to a pull request. +# +# Runners. These jobs, the docs build, and Lint and Test on pushes to main +# run on ubuntu-latest unless a repository variable names another runner +# (Settings > Secrets and variables > Actions > Variables): +# CI_RUNNER_X64 instead of ubuntu-latest for those jobs +# CERTIFY_RUNNER_X64 instead of ubuntu-latest for the UK certification's +# qualify and replay jobs, which hold the GitHub App +# key and a write token: a GitHub-hosted larger runner +# only +# CI_RUNNER_EVENTS optional: event names separated by commas, no +# spaces, such as push,workflow_dispatch. Only those +# events use the variables. Empty means every event. +# Delete a variable to go back to ubuntu-latest for the next run. Versioning, +# Publish, NotifyConsumers, the docs deploy and the other certification jobs +# always keep GitHub-hosted runners. Each CI_RUNNER_X64 job has a read-only +# GITHUB_TOKEN, since the organisation's default token can write. Test and +# BundleVerification read the dataset with HUGGING_FACE_TOKEN, so point the +# variables only at runners where that data may be processed, such as the +# organisation's GitHub-hosted larger runners. +# tests/test_ci_runner_variables.py holds the workflows to this. name: Code changes on: @@ -21,7 +41,9 @@ concurrency: jobs: check-changelog: name: Check changelog fragment - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 10 steps: - uses: actions/checkout@v6 @@ -32,7 +54,9 @@ jobs: - name: Check for changelog fragment run: .github/check-changelog.sh Lint: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 10 steps: - uses: actions/checkout@v6 @@ -45,7 +69,9 @@ jobs: - name: Run ruff check run: ruff check . Mypy: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -63,7 +89,9 @@ jobs: run: mypy src/policyengine || echo "mypy found errors (non-blocking until codebase is clean)" Python-Compat: name: Install + smoke-import (py${{ matrix.python-version }}) - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 15 strategy: fail-fast: false @@ -88,7 +116,9 @@ jobs: name: Verify release inputs and bundle packages # Private manifest credentials are available only to trusted repository PRs. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 30 env: POLICYENGINE_SKIP_COUNTRY_IMPORTS: "1" @@ -140,7 +170,9 @@ jobs: - name: Verify bundle packages run: policyengine bundle verify --country us --country uk --packages-only --json Test: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read # Observed 2026-10-14..15: 28 to 54 minutes per Python version, with runs on # 3.12 and 3.13 cancelled at the old 60-minute limit. Fail a hung job instead # of holding a shared org runner for the 6-hour default. diff --git a/.github/workflows/pr_docs_changes.yaml b/.github/workflows/pr_docs_changes.yaml index 96195ecb0..291e6923d 100644 --- a/.github/workflows/pr_docs_changes.yaml +++ b/.github/workflows/pr_docs_changes.yaml @@ -17,7 +17,9 @@ concurrency: jobs: Test: - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read name: Test documentation builds timeout-minutes: 30 steps: diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index e4f923e46..75a3f7b43 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -30,7 +30,9 @@ jobs: # ── Phase 1 gates (skip on sentinel commit) ───────────────── Lint: if: github.event.head_commit.message != 'Update package version' - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read timeout-minutes: 10 steps: - uses: actions/checkout@v6 @@ -43,7 +45,9 @@ jobs: Test: if: github.event.head_commit.message != 'Update package version' - runs-on: ubuntu-latest + runs-on: ${{ vars.CI_RUNNER_X64 != '' && (vars.CI_RUNNER_EVENTS == '' || contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) && vars.CI_RUNNER_X64 || 'ubuntu-latest' }} + permissions: + contents: read # Successful runs on main 2026-10-14..16: 3.11 33-54 min, 3.12 32-56, # 3.13 36-60, 3.14 23-42; at 60 minutes every push run on 16 Oct was # cancelled before Versioning, so nothing was released (#640). Matches diff --git a/changelog.d/705.changed.md b/changelog.d/705.changed.md new file mode 100644 index 000000000..a42bb87c7 --- /dev/null +++ b/changelog.d/705.changed.md @@ -0,0 +1 @@ +- CI test and check jobs can run on another runner pool named by the repository variables `CI_RUNNER_X64`, `CERTIFY_RUNNER_X64` and `CI_RUNNER_EVENTS`, and the jobs `CI_RUNNER_X64` can move now hold a read-only token. With no variable set, every job runs where it does today. diff --git a/tests/test_ci_runner_variables.py b/tests/test_ci_runner_variables.py new file mode 100644 index 000000000..3083b8d38 --- /dev/null +++ b/tests/test_ci_runner_variables.py @@ -0,0 +1,165 @@ +"""Which runner each CI job uses, and what the runner variables can move. + +pr_code_changes.yaml explains the variables. These tests hold the workflows +to them: + +1. Each covered job's ``runs-on`` is the one runner expression, built from + its variable and ubuntu-latest, so an unset variable keeps ubuntu-latest. +2. Every other job keeps a literal GitHub-hosted label. Versioning, Publish, + NotifyConsumers, the docs deploy and the certification jobs that resolve, + prepare, catch up, accept and promote never move. +3. The expression picks the variable's runner exactly when the variable is + set and CI_RUNNER_EVENTS is empty or lists the run's event, matched as a + whole name: "pull_request" never matches "pull_request_target". +4. Every job that CI_RUNNER_X64 can move has a GITHUB_TOKEN that can only + read: the organisation's default token can write, and a moved job takes + its token to the other runner. The certification jobs keep their write + token and the GitHub App key, so they have their own variable, for + GitHub-hosted larger runners only. +""" + +from itertools import product +from pathlib import Path + +import pytest +import yaml + +WORKFLOWS = Path(__file__).resolve().parents[1] / ".github" / "workflows" + +X64 = "ubuntu-latest" + +# (workflow, job) -> variable +COVERED = { + ("pr_code_changes.yaml", "check-changelog"): "CI_RUNNER_X64", + ("pr_code_changes.yaml", "Lint"): "CI_RUNNER_X64", + ("pr_code_changes.yaml", "Mypy"): "CI_RUNNER_X64", + ("pr_code_changes.yaml", "Python-Compat"): "CI_RUNNER_X64", + ("pr_code_changes.yaml", "BundleVerification"): "CI_RUNNER_X64", + ("pr_code_changes.yaml", "Test"): "CI_RUNNER_X64", + ("pr_docs_changes.yaml", "Test"): "CI_RUNNER_X64", + ("push.yaml", "Lint"): "CI_RUNNER_X64", + ("push.yaml", "Test"): "CI_RUNNER_X64", + ("certify-uk-release.yaml", "qualify"): "CERTIFY_RUNNER_X64", + ("certify-uk-release.yaml", "replay"): "CERTIFY_RUNNER_X64", +} + +NEVER_MOVED = { + ("push.yaml", "Docs"), + ("push.yaml", "Versioning"), + ("push.yaml", "Publish"), + ("push.yaml", "NotifyConsumers"), + ("certify-uk-release.yaml", "resolve"), + ("certify-uk-release.yaml", "prepare"), + ("certify-uk-release.yaml", "catch-up"), + ("certify-uk-release.yaml", "accept"), + ("certify-uk-release.yaml", "promote"), +} + + +def runner_expression(variable, label=X64): + return ( + f"${{{{ vars.{variable} != '' && (vars.CI_RUNNER_EVENTS == '' || " + "contains(format(',{0},', vars.CI_RUNNER_EVENTS), format(',{0},', github.event_name))) " + f"&& vars.{variable} || '{label}' }}}}" + ) + + +def documents(): + for path in sorted(WORKFLOWS.glob("*.y*ml")): + yield path.name, yaml.safe_load(path.read_text(encoding="utf-8")) + + +def jobs(): + for name, document in documents(): + for job_name, job in document["jobs"].items(): + if "runs-on" in job: + yield (name, job_name), job["runs-on"] + + +# Invariant 1. + + +def test_covered_jobs_use_the_runner_expression(): + found = dict(jobs()) + for key, variable in COVERED.items(): + assert found[key] == runner_expression(variable), key + + +# Invariant 2. + + +def test_every_other_job_keeps_a_github_hosted_label(): + found = dict(jobs()) + assert NEVER_MOVED <= set(found) + for key, runs_on in found.items(): + if key in COVERED: + continue + assert runs_on == X64, (key, runs_on) + + +# Invariant 3: the expression's rule, as GitHub evaluates it. "a && b || c" +# gives b when a and b are non-empty, else c; an unset variable is "". + + +def chosen_runner(variable_value, events_value, event, label=X64): + if variable_value != "" and ( + events_value == "" or f",{event}," in f",{events_value}," + ): + return variable_value + return label + + +EVENTS = [ + "pull_request", + "pull_request_target", + "push", + "workflow_dispatch", + "repository_dispatch", + "schedule", +] +EVENT_LISTS = [ + "", + "push", + "push,workflow_dispatch", + "pull_request", + "pull_request_target", + "repository_dispatch,schedule,workflow_dispatch", +] + + +@pytest.mark.parametrize( + "variable_value,events_value,event", + list(product(["", "pe-x64-4core"], EVENT_LISTS, EVENTS)), +) +def test_the_variable_applies_only_to_the_listed_events( + variable_value, events_value, event +): + chosen = chosen_runner(variable_value, events_value, event) + listed = events_value == "" or event in events_value.split(",") + if variable_value and listed: + assert chosen == variable_value + else: + assert chosen == X64 + + +# Invariant 4. + + +def test_ci_runner_jobs_hold_a_read_only_token(): + workflows = dict(documents()) + for (workflow, job_name), variable in COVERED.items(): + if variable != "CI_RUNNER_X64": + continue + document = workflows[workflow] + job = document["jobs"][job_name] + # A job's own permissions replace the workflow's; with neither, the + # job gets the organisation's default token, which can write. + permissions = job.get("permissions", document.get("permissions")) + assert isinstance(permissions, dict), (workflow, job_name) + for scope, level in permissions.items(): + assert level in ("read", "none") or scope == "id-token", ( + workflow, + job_name, + scope, + level, + )