Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/certify-uk-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 38 additions & 6 deletions .github/workflows/pr_code_changes.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/pr_docs_changes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/705.changed.md
Original file line number Diff line number Diff line change
@@ -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.
165 changes: 165 additions & 0 deletions tests/test_ci_runner_variables.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading