ci: add selectable external CI runners (#3451)

This commit is contained in:
Carlos Florêncio
2026-09-06 14:45:59 +01:00
committed by GitHub
parent 8c72bc0eeb
commit 89cddc2239
22 changed files with 2002 additions and 22 deletions
@@ -25,7 +25,7 @@ class BackendTestsWorkflowContractTest(unittest.TestCase):
generate = step_block(workflow, "Generate test report")
publish = step_block(workflow, "Publish bounded test report summary")
temporary_summary = "$RUNNER_TEMP/backend-test-summary-${{ matrix.shard }}.md"
temporary_summary = "$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md"
self.assertIn(
"uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10",
checkout,
@@ -37,12 +37,13 @@ class BackendTestsWorkflowContractTest(unittest.TestCase):
)
self.assertIn("path: .github/vendor/go-test-action", checkout)
self.assertIn(f'report_summary="{temporary_summary}"', generate)
self.assertIn("BACKEND_TEST_SHARD: ${{ matrix.shard }}", generate)
self.assertNotIn("${{ runner.temp }}", generate)
self.assertIn(': > "$report_summary"', generate)
self.assertIn('GITHUB_STEP_SUMMARY="$report_summary"', generate)
self.assertIn(
'INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/'
'test-results-${{ matrix.shard }}.json"',
'test-results-${BACKEND_TEST_SHARD}.json"',
generate,
)
self.assertIn(
@@ -60,8 +61,8 @@ class BackendTestsWorkflowContractTest(unittest.TestCase):
self.assertIn(f'--input "{temporary_summary}"', publish)
self.assertIn('--output "$GITHUB_STEP_SUMMARY"', publish)
self.assertIn("--diagnostics-label", publish)
self.assertIn("backend-test-results-${{ matrix.shard }}", publish)
self.assertIn("${{ github.run_id }}#artifacts", publish)
self.assertIn("backend-test-results-${BACKEND_TEST_SHARD}", publish)
self.assertIn("$GITHUB_RUN_ID#artifacts", publish)
def test_full_json_diagnostics_remain_in_the_named_artifact(self) -> None:
workflow = BACKEND_WORKFLOW.read_text(encoding="utf-8")
@@ -193,6 +193,58 @@ cat "${FAKE_DOCKER_MANIFEST}"
self.assertIn("timeout-minutes: 35", normal_job)
self.assertNotIn("timeout-minutes: 25", normal_job)
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.1
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.2
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.3
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.4
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.5
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-001.6
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-002.1
# @covers AC-PLATFORM-EXTERNAL-E2E-RUNNER-CAPACITY-002.2
def test_external_runner_tiers_are_toggleable_for_eligible_jobs(self) -> None:
workflow = E2E_WORKFLOW.read_text(encoding="utf-8")
light_jobs = {
"changes": ("build", "changes_runner"),
"e2e-gate": (None, "e2e_gate_runner"),
}
for job, (next_job, output_name) in light_jobs.items():
if next_job is None:
job_text = workflow.partition(" e2e-gate:\n")[2]
else:
job_text = job_block(workflow, job, next_job)
expected = (
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan)."
f"{output_name} }}}}"
)
self.assertIn(
expected,
job_text,
f"{job} must select its configured tier",
)
e2e_job = job_block(workflow, "e2e", "playwright_image")
self.assertIn("runs-on: ${{ matrix.runner }}", e2e_job)
self.assertIn(
"matrix: ${{ fromJSON(needs.runner_plan.outputs.plan).e2e_matrix }}",
e2e_job,
)
protected_jobs = {
"build": "e2e",
"e2e-report": "e2e-gate",
"playwright_image": "e2e-containers",
"e2e-containers": "e2e-kubernetes-compatibility",
"e2e-kubernetes-compatibility": "desktop-e2e",
"desktop-e2e": "e2e-report",
}
for job, next_job in protected_jobs.items():
protected_text = job_block(workflow, job, next_job)
self.assertIn("runs-on: ubuntu-latest", protected_text)
self.assertNotIn("runner_plan", protected_text)
self.assertNotIn("KANDEV_CI_EXTERNAL", protected_text)
self.assertNotIn("KANDEV_CI_RUNNER_", protected_text)
def test_contract_runs_in_the_unfiltered_required_workflow(self) -> None:
workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Contract tests for external runner planner wiring and protected jobs."""
from pathlib import Path
import unittest
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW_ROOT = REPO_ROOT / ".github" / "workflows"
PLAN_SCRIPT = REPO_ROOT / ".github" / "scripts" / "runner-plan.py"
def job_block(workflow: str, job: str, next_job: str | None) -> str:
marker = f" {job}:\n"
_, separator, remainder = workflow.partition(marker)
if not separator:
raise AssertionError(f"Workflow has no {job} job")
if next_job is None:
return remainder
return remainder.partition(f"\n {next_job}:\n")[0]
class ExternalRunnerWorkflowContractTest(unittest.TestCase):
def assert_planner(
self, workflow_name: str, workflow_key: str, families: tuple[str, ...]
) -> str:
workflow = (WORKFLOW_ROOT / workflow_name).read_text(encoding="utf-8")
planner = job_block(workflow, "runner_plan", "changes")
self.assertIn("runs-on: ubuntu-latest", planner)
self.assertIn("plan: ${{ steps.plan.outputs.plan }}", planner)
self.assertIn("uses: ./.github/actions/plan-external-runners", planner)
self.assertIn(f"workflow: {workflow_key}", planner)
self.assertIn("families: >-", planner)
self.assertIn("run-id: ${{ github.run_id }}", planner)
self.assertIn("burst: ${{ vars.KANDEV_CI_EXTERNAL_ENABLED }}", planner)
self.assertIn("percent: ${{ vars.KANDEV_CI_EXTERNAL_PERCENT }}", planner)
self.assertIn("light-label: ${{ vars.KANDEV_CI_RUNNER_LIGHT }}", planner)
self.assertIn("standard-label: ${{ vars.KANDEV_CI_RUNNER_STANDARD }}", planner)
for family in families:
self.assertIn(f'"name":"{family}"', planner)
return workflow
def test_e2e_uses_planner_for_eligible_jobs_and_keeps_protected_jobs_hosted(self) -> None:
workflow = self.assert_planner(
"e2e-tests.yml",
"e2e",
("changes", "e2e", "e2e_gate"),
)
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).changes_runner }}",
job_block(workflow, "changes", "build"),
)
build = job_block(workflow, "build", "e2e")
self.assertIn("runs-on: ubuntu-latest", build)
self.assertNotIn("runner_plan", build)
e2e = job_block(workflow, "e2e", "playwright_image")
self.assertIn(
"matrix: ${{ fromJSON(needs.runner_plan.outputs.plan).e2e_matrix }}",
e2e,
)
self.assertIn("runs-on: ${{ matrix.runner }}", e2e)
report = job_block(workflow, "e2e-report", "e2e-gate")
self.assertIn("runs-on: ubuntu-latest", report)
self.assertNotIn("runner_plan", report)
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).e2e_gate_runner }}",
job_block(workflow, "e2e-gate", None),
)
for job, next_job in (
("playwright_image", "e2e-containers"),
("e2e-containers", "e2e-kubernetes-compatibility"),
("e2e-kubernetes-compatibility", "desktop-e2e"),
("desktop-e2e", "e2e-report"),
):
protected = job_block(workflow, job, next_job)
self.assertIn("runs-on: ubuntu-latest", protected)
self.assertNotIn("runner_plan", protected)
self.assertNotIn("KANDEV_CI_EXTERNAL", protected)
def test_backend_uses_planner_and_keeps_services_and_windows_hosted(self) -> None:
workflow = self.assert_planner(
"backend-tests.yml",
"backend",
("test",),
)
for job, next_job in (
("changes", "static_checks"),
("static_checks", "test_shards"),
("test_ambient_env", "test"),
):
protected = job_block(workflow, job, next_job)
self.assertIn("runs-on: ubuntu-latest", protected)
self.assertNotIn("runner_plan", protected)
shards = job_block(workflow, "test_shards", "test_ambient_env")
self.assertIn("- shard: 1", shards)
self.assertIn("- shard: 2", shards)
self.assertIn("runs-on: ubuntu-latest", shards)
self.assertNotIn("runner_plan", shards)
self.assertNotIn("matrix.runner", shards)
test_gate = job_block(workflow, "test", "postgres-boot")
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).test_runner }}",
test_gate,
)
for job, next_job in (("postgres-boot", "test-windows"), ("test-windows", None)):
protected = job_block(workflow, job, next_job)
self.assertIn("runs-on: ubuntu-latest" if job == "postgres-boot" else "runs-on: windows-latest", protected)
self.assertNotIn("runner_plan", protected)
def test_frontend_uses_planner_for_test_and_gate(self) -> None:
workflow = self.assert_planner(
"frontend-tests.yml",
"frontend",
("changes", "frontend", "frontend_gate"),
)
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).changes_runner }}",
job_block(workflow, "changes", "frontend"),
)
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).frontend_runner }}",
job_block(workflow, "frontend", "frontend-gate"),
)
self.assertIn(
"runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).frontend_gate_runner }}",
job_block(workflow, "frontend-gate", None),
)
def test_protected_lint_workflows_do_not_run_the_planner(self) -> None:
for workflow_name in (
"architecture-lint.yml",
"lint-action-pinning.yml",
"lint-harness-files.yml",
):
workflow = (WORKFLOW_ROOT / workflow_name).read_text(encoding="utf-8")
lint = job_block(workflow, "lint", None)
self.assertNotIn("runner_plan:", workflow)
self.assertNotIn("name: Require runner plan", lint)
self.assertIn("runs-on: ubuntu-latest", lint)
def test_plan_script_is_checked_by_required_action_pinning_workflow(self) -> None:
workflow = (WORKFLOW_ROOT / "lint-action-pinning.yml").read_text(encoding="utf-8")
self.assertTrue(PLAN_SCRIPT.is_file())
action = (WORKFLOW_ROOT / ".." / "actions" / "plan-external-runners" / "action.yml").resolve()
self.assertTrue(action.is_file())
action_text = action.read_text(encoding="utf-8")
self.assertIn("using: composite", action_text)
self.assertIn("families:", action_text)
self.assertIn("plan:", action_text)
self.assertIn("runner-plan.py", action_text)
self.assertNotIn("changes_runner:", action_text)
self.assertIn("python3 .github/scripts/runner-plan_test.py", workflow)
self.assertIn("python3 .github/scripts/external-runner-workflow-contract_test.py", workflow)
if __name__ == "__main__":
unittest.main()
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Build deterministic runner assignments from workflow-owned family data."""
import argparse
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class RunnerPlan:
plan: dict[str, object]
warnings: list[str]
@dataclass(frozen=True)
class Family:
name: str
tier: str
instances: tuple[dict[str, object], ...]
is_matrix: bool
GITHUB_RUNNER = "ubuntu-latest"
VALID_TIERS = frozenset({"light", "standard"})
FAMILY_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
def _parse_percentage(raw: str | None) -> tuple[int, list[str]]:
if raw is None or raw.strip() == "":
return 0, []
value = raw.strip()
warning = (
"KANDEV_CI_EXTERNAL_PERCENT must be an integer from 0 to 100, "
f"got {raw!r}"
)
if not value.isascii() or not value.isdigit():
return 0, [warning]
percentage = int(value)
if percentage > 100:
return 0, [warning]
return percentage, []
def _validate_families(raw: object) -> tuple[Family, ...]:
if not isinstance(raw, list) or not raw:
raise ValueError("families must be a non-empty JSON array")
families: list[Family] = []
names: set[str] = set()
for entry in raw:
if not isinstance(entry, dict):
raise ValueError("each family must be a JSON object")
name = entry.get("name")
tier = entry.get("tier")
if not isinstance(name, str) or FAMILY_NAME.fullmatch(name) is None:
raise ValueError(f"invalid family name: {name!r}")
if name in names:
raise ValueError(f"duplicate family name: {name}")
if tier not in VALID_TIERS:
raise ValueError(f"family {name!r} has invalid tier: {tier!r}")
is_matrix = "instances" in entry
instances_value = entry.get("instances", [{}])
if not isinstance(instances_value, list) or not instances_value:
raise ValueError(f"family {name!r} instances must be a non-empty array")
instances: list[dict[str, object]] = []
for instance in instances_value:
if not isinstance(instance, dict):
raise ValueError(f"family {name!r} instances must be JSON objects")
if "runner" in instance:
raise ValueError(f"family {name!r} instance cannot define runner")
instances.append(instance)
names.add(name)
families.append(
Family(
name=name,
tier=tier,
instances=tuple(instances),
is_matrix=is_matrix,
)
)
return tuple(families)
def _external_for_singleton(
*, workflow: str, run_id: str, family: str, percentage: int
) -> bool:
if percentage <= 0:
return False
if percentage >= 100:
return True
key = f"{workflow}\0{run_id}\0{family}".encode("utf-8")
bucket = int.from_bytes(hashlib.sha256(key).digest()[:8], "big") % 100
return bucket < percentage
def _matrix_assignments(
*,
workflow: str,
run_id: str,
family: str,
instances: tuple[dict[str, object], ...],
percentage: int,
) -> set[int]:
external_count = (len(instances) * percentage) // 100
ranked: list[tuple[bytes, int]] = []
for index, instance in enumerate(instances):
matrix_key = json.dumps(instance, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(
f"{workflow}\0{run_id}\0{family}\0{matrix_key}".encode("utf-8")
).digest()
ranked.append((digest, index))
return {index for _, index in sorted(ranked)[:external_count]}
def _plan_family(
*,
workflow: str,
run_id: str,
family: Family,
burst: str,
percentage: int,
external_label: str,
) -> list[dict[str, object]]:
if burst != "true" or not external_label or percentage == 0:
external_indices: set[int] = set()
elif not family.is_matrix:
external_indices = (
{0}
if _external_for_singleton(
workflow=workflow,
run_id=run_id,
family=family.name,
percentage=percentage,
)
else set()
)
else:
external_indices = _matrix_assignments(
workflow=workflow,
run_id=run_id,
family=family.name,
instances=family.instances,
percentage=percentage,
)
return [
{
**instance,
"runner": external_label if index in external_indices else GITHUB_RUNNER,
}
for index, instance in enumerate(family.instances)
]
def build_plan(
*,
workflow: str,
run_id: str,
families: object,
burst: str,
percent: str | None,
light_label: str,
standard_label: str,
) -> RunnerPlan:
percentage, warnings = _parse_percentage(percent)
labels = {"light": light_label, "standard": standard_label}
plan: dict[str, object] = {}
for family in _validate_families(families):
planned = _plan_family(
workflow=workflow,
run_id=run_id,
family=family,
burst=burst,
percentage=percentage,
external_label=labels[family.tier],
)
if family.is_matrix:
plan[f"{family.name}_matrix"] = {"include": planned}
else:
plan[f"{family.name}_runner"] = planned[0]["runner"]
return RunnerPlan(plan=plan, warnings=warnings)
def _write_output(plan: RunnerPlan, output_path: Path) -> None:
value = json.dumps(plan.plan, separators=(",", ":"), sort_keys=True)
with output_path.open("a", encoding="utf-8") as output:
output.write(f"plan={value}\n")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--workflow", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--families", required=True)
parser.add_argument("--burst", default="")
parser.add_argument("--percent")
parser.add_argument("--light-label", default="")
parser.add_argument("--standard-label", default="")
parser.add_argument("--output", type=Path, default=Path("/dev/stdout"))
args = parser.parse_args()
try:
families = json.loads(args.families)
plan = build_plan(
workflow=args.workflow,
run_id=args.run_id,
families=families,
burst=args.burst,
percent=args.percent,
light_label=args.light_label,
standard_label=args.standard_label,
)
except (json.JSONDecodeError, ValueError) as error:
parser.error(str(error))
for warning in plan.warnings:
print(f"::warning::{warning}")
_write_output(plan, args.output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Tests for deterministic external CI runner allocation."""
import importlib.util
from pathlib import Path
import unittest
SCRIPT = Path(__file__).with_name("runner-plan.py")
SPEC = importlib.util.spec_from_file_location("runner_plan", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
runner_plan = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(runner_plan)
E2E_FAMILIES = [
{"name": "changes", "tier": "light"},
{
"name": "e2e",
"tier": "standard",
"instances": [{"shard": shard} for shard in range(1, 15)],
},
{"name": "e2e_gate", "tier": "light"},
]
FRONTEND_FAMILIES = [
{"name": "frontend", "tier": "standard"},
{"name": "frontend_gate", "tier": "light"},
]
BACKEND_MATRIX_FAMILY = [
{
"name": "backend_test",
"tier": "standard",
"instances": [{"shard": 1, "index": 0}, {"shard": 2, "index": 1}],
}
]
class RunnerPlanTest(unittest.TestCase):
def test_missing_percentage_defaults_to_github(self) -> None:
plan = runner_plan.build_plan(
workflow="e2e",
run_id="100",
families=E2E_FAMILIES,
burst="true",
percent=None,
light_label="light-runner",
standard_label="standard-runner",
)
self.assertEqual(plan.plan["changes_runner"], "ubuntu-latest")
self.assertFalse(plan.warnings)
def test_burst_off_ignores_percentage(self) -> None:
plan = runner_plan.build_plan(
workflow="frontend",
run_id="101",
families=FRONTEND_FAMILIES,
burst="false",
percent="100",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertEqual(plan.plan["frontend_runner"], "ubuntu-latest")
self.assertEqual(plan.plan["frontend_gate_runner"], "ubuntu-latest")
def test_matrix_percentage_uses_floor_and_is_stable(self) -> None:
first = runner_plan.build_plan(
workflow="e2e",
run_id="102",
families=E2E_FAMILIES,
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
second = runner_plan.build_plan(
workflow="e2e",
run_id="102",
families=E2E_FAMILIES,
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
first_matrix = first.plan["e2e_matrix"]["include"]
second_matrix = second.plan["e2e_matrix"]["include"]
self.assertEqual(first_matrix, second_matrix)
self.assertEqual(
sum(item["runner"] == "standard-runner" for item in first_matrix), 7
)
def test_backend_matrix_percentage_uses_floor(self) -> None:
plan = runner_plan.build_plan(
workflow="backend",
run_id="103",
families=BACKEND_MATRIX_FAMILY,
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
matrix = plan.plan["backend_test_matrix"]["include"]
self.assertEqual(
sum(item["runner"] == "standard-runner" for item in matrix), 1
)
self.assertEqual(
[(item["shard"], item["index"]) for item in matrix],
[(1, 0), (2, 1)],
)
def test_workflow_owned_family_names_need_no_python_change(self) -> None:
plan = runner_plan.build_plan(
workflow="new-workflow",
run_id="109",
families=[{"name": "new_job", "tier": "standard"}],
burst="true",
percent="100",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertEqual(plan.plan, {"new_job_runner": "standard-runner"})
def test_invalid_family_definition_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "duplicate family name"):
runner_plan.build_plan(
workflow="frontend",
run_id="110",
families=[
{"name": "same", "tier": "light"},
{"name": "same", "tier": "standard"},
],
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
def test_singleton_allocation_is_stable_for_reruns(self) -> None:
first = runner_plan.build_plan(
workflow="frontend",
run_id="104",
families=FRONTEND_FAMILIES,
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
second = runner_plan.build_plan(
workflow="frontend",
run_id="104",
families=FRONTEND_FAMILIES,
burst="true",
percent="50",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertEqual(first.plan, second.plan)
def test_empty_tier_label_falls_back_to_github(self) -> None:
plan = runner_plan.build_plan(
workflow="frontend",
run_id="105",
families=FRONTEND_FAMILIES,
burst="true",
percent="100",
light_label="",
standard_label="",
)
self.assertEqual(plan.plan["frontend_runner"], "ubuntu-latest")
def test_invalid_percentage_fails_closed_with_warning(self) -> None:
plan = runner_plan.build_plan(
workflow="e2e",
run_id="106",
families=E2E_FAMILIES,
burst="true",
percent="101",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertTrue(plan.warnings)
self.assertEqual(plan.plan["changes_runner"], "ubuntu-latest")
matrix = plan.plan["e2e_matrix"]["include"]
self.assertTrue(all(item["runner"] == "ubuntu-latest" for item in matrix))
def test_unicode_digits_fail_closed_with_warning(self) -> None:
plan = runner_plan.build_plan(
workflow="e2e",
run_id="108",
families=E2E_FAMILIES,
burst="true",
percent="²",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertTrue(plan.warnings)
self.assertEqual(plan.plan["changes_runner"], "ubuntu-latest")
matrix = plan.plan["e2e_matrix"]["include"]
self.assertTrue(all(item["runner"] == "ubuntu-latest" for item in matrix))
def test_full_percentage_uses_external_for_non_empty_tier(self) -> None:
plan = runner_plan.build_plan(
workflow="frontend",
run_id="107",
families=FRONTEND_FAMILIES,
burst="true",
percent="100",
light_label="light-runner",
standard_label="standard-runner",
)
self.assertEqual(plan.plan["frontend_runner"], "standard-runner")
self.assertEqual(plan.plan["frontend_gate_runner"], "light-runner")
if __name__ == "__main__":
unittest.main()