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
@@ -0,0 +1,59 @@
name: Plan external runner allocation
description: Compute deterministic external-runner assignments for a workflow
inputs:
workflow:
description: Runner-plan workflow family key
required: true
run-id:
description: Stable workflow run identifier used for allocation
required: true
families:
description: JSON job-family definitions owned by the calling workflow
required: true
burst:
description: Exact true value enables external allocation
required: false
default: ""
percent:
description: External allocation percentage from 0 to 100
required: false
default: ""
light-label:
description: Configured light-tier runner label
required: false
default: ""
standard-label:
description: Configured standard-tier runner label
required: false
default: ""
outputs:
plan:
description: JSON object containing runner and matrix outputs
value: ${{ steps.plan.outputs.plan }}
runs:
using: composite
steps:
- name: Plan runner allocation
id: plan
shell: bash
env:
RUNNER_PLAN_WORKFLOW: ${{ inputs.workflow }}
RUNNER_PLAN_RUN_ID: ${{ inputs.run-id }}
RUNNER_PLAN_FAMILIES: ${{ inputs.families }}
KANDEV_CI_EXTERNAL_ENABLED: ${{ inputs.burst }}
KANDEV_CI_EXTERNAL_PERCENT: ${{ inputs.percent }}
KANDEV_CI_RUNNER_LIGHT: ${{ inputs.light-label }}
KANDEV_CI_RUNNER_STANDARD: ${{ inputs.standard-label }}
run: |
python3 "$GITHUB_WORKSPACE/.github/scripts/runner-plan.py" \
--workflow "$RUNNER_PLAN_WORKFLOW" \
--run-id "$RUNNER_PLAN_RUN_ID" \
--families "$RUNNER_PLAN_FAMILIES" \
--burst "$KANDEV_CI_EXTERNAL_ENABLED" \
--percent "$KANDEV_CI_EXTERNAL_PERCENT" \
--light-label "$KANDEV_CI_RUNNER_LIGHT" \
--standard-label "$KANDEV_CI_RUNNER_STANDARD" \
--output "$GITHUB_OUTPUT"
@@ -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()
+2
View File
@@ -24,6 +24,8 @@ permissions:
jobs:
lint:
name: Architecture boundaries do not regress
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
steps:
+53 -6
View File
@@ -33,11 +33,38 @@ permissions:
packages: read
jobs:
runner_plan:
name: Plan external runner allocation
runs-on: ubuntu-latest
outputs:
plan: ${{ steps.plan.outputs.plan }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Plan runner allocation
id: plan
uses: ./.github/actions/plan-external-runners
with:
workflow: backend
run-id: ${{ github.run_id }}
families: >-
[{"name":"test","tier":"light"}]
burst: ${{ vars.KANDEV_CI_EXTERNAL_ENABLED }}
percent: ${{ vars.KANDEV_CI_EXTERNAL_PERCENT }}
light-label: ${{ vars.KANDEV_CI_RUNNER_LIGHT }}
standard-label: ${{ vars.KANDEV_CI_RUNNER_STANDARD }}
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
@@ -106,6 +133,8 @@ jobs:
name: Backend Static Checks
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
# Pre-baked image bundles Go, golangci-lint, go-licenses, Docker CLI, Node,
# pnpm, and the Playwright base — see .github/docker/ci-base/Dockerfile.
@@ -226,6 +255,8 @@ jobs:
name: Backend Tests (${{ matrix.shard }}/2)
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -334,11 +365,13 @@ jobs:
if: always()
shell: bash
working-directory: ${{ github.workspace }}
env:
BACKEND_TEST_SHARD: ${{ matrix.shard }}
run: |
report_summary="$RUNNER_TEMP/backend-test-summary-${{ matrix.shard }}.md"
report_summary="$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md"
: > "$report_summary"
GITHUB_STEP_SUMMARY="$report_summary" \
INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/test-results-${{ matrix.shard }}.json" \
INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/test-results-${BACKEND_TEST_SHARD}.json" \
INPUT_MODULEDIRECTORY="$GITHUB_WORKSPACE/apps/backend" \
INPUT_OMIT="successful" \
node ".github/vendor/go-test-action/dist/index.js"
@@ -347,12 +380,14 @@ jobs:
if: always()
shell: bash
working-directory: ${{ github.workspace }}
env:
BACKEND_TEST_SHARD: ${{ matrix.shard }}
run: |
python3 .github/scripts/bounded-step-summary.py \
--input "$RUNNER_TEMP/backend-test-summary-${{ matrix.shard }}.md" \
--input "$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md" \
--output "$GITHUB_STEP_SUMMARY" \
--diagnostics-label "backend-test-results-${{ matrix.shard }}" \
--diagnostics-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts"
--diagnostics-label "backend-test-results-${BACKEND_TEST_SHARD}" \
--diagnostics-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID#artifacts"
- name: Upload test artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -368,6 +403,8 @@ jobs:
name: Backend (ambient integration env)
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
# test_shards runs in the same clean container and never sets these vars,
# so it cannot catch a package that starts reading them again — the class
@@ -430,9 +467,10 @@ jobs:
# `test-windows` were outside it before this workflow became a gate.
test:
name: Run Backend Tests
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).test_runner }}
needs:
- changes
- runner_plan
- static_checks
- test_shards
- test_ambient_env
@@ -442,6 +480,7 @@ jobs:
steps:
- name: Require backend checks to pass
env:
RUNNER_PLAN_RESULT: ${{ needs.runner_plan.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
STATIC_CHECKS_RESULT: ${{ needs.static_checks.result }}
TEST_SHARDS_RESULT: ${{ needs.test_shards.result }}
@@ -451,6 +490,7 @@ jobs:
run: |
set -euo pipefail
echo "Runner plan: ${RUNNER_PLAN_RESULT}"
echo "Change detection: ${CHANGES_RESULT}"
echo "Static checks: ${STATIC_CHECKS_RESULT}"
echo "Test shards: ${TEST_SHARDS_RESULT}"
@@ -458,6 +498,13 @@ jobs:
echo "Postgres boot: ${POSTGRES_BOOT_RESULT}"
echo "Windows tests: ${TEST_WINDOWS_RESULT}"
# Runner planning must succeed because its outputs select the runner
# fleet. A planner error cannot silently become a hosted fallback.
if [[ "${RUNNER_PLAN_RESULT}" != "success" ]]; then
echo "Runner planning did not succeed; refusing to report a pass." >&2
exit 1
fi
# Change detection itself must succeed: if it errored we do not know
# whether the skips below were deliberate.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
+56 -7
View File
@@ -39,12 +39,47 @@ permissions:
packages: read
jobs:
runner_plan:
name: Plan external runner allocation
runs-on: ubuntu-latest
outputs:
plan: ${{ steps.plan.outputs.plan }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Plan runner allocation
id: plan
uses: ./.github/actions/plan-external-runners
with:
workflow: e2e
run-id: ${{ github.run_id }}
families: >-
[
{"name":"changes","tier":"light"},
{"name":"e2e","tier":"standard","instances":[
{"shard":1},{"shard":2},{"shard":3},{"shard":4},
{"shard":5},{"shard":6},{"shard":7},{"shard":8},
{"shard":9},{"shard":10},{"shard":11},{"shard":12},
{"shard":13},{"shard":14}
]},
{"name":"e2e_gate","tier":"light"}
]
burst: ${{ vars.KANDEV_CI_EXTERNAL_ENABLED }}
percent: ${{ vars.KANDEV_CI_EXTERNAL_PERCENT }}
light-label: ${{ vars.KANDEV_CI_RUNNER_LIGHT }}
standard-label: ${{ vars.KANDEV_CI_RUNNER_STANDARD }}
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
needs: runner_plan
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).changes_runner }}
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
@@ -113,6 +148,8 @@ jobs:
name: Build
needs: changes
if: needs.changes.outputs.run == 'true'
# Build reads the GitHub API and downloads a timing artifact with the job token.
# Keep this credential-bearing path on the GitHub-hosted boundary.
runs-on: ubuntu-latest
# Pre-baked image bundles Go, Node 24, pnpm 9.15.9 — see
# .github/docker/ci-base/Dockerfile. Uses the `build` tag (with Go)
@@ -367,9 +404,9 @@ jobs:
e2e:
name: E2E Shard ${{ matrix.shard }}/14
needs: [changes, build]
needs: [changes, build, runner_plan]
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
runs-on: ${{ matrix.runner }}
# Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright browser
# binaries (Chromium etc.) — see .github/docker/ci-base/Dockerfile. This is
# the biggest speedup since `npx playwright install chromium --with-deps`
@@ -398,8 +435,7 @@ jobs:
NODE_OPTIONS: --dns-result-order=ipv4first
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
matrix: ${{ fromJSON(needs.runner_plan.outputs.plan).e2e_matrix }}
steps:
- name: Checkout code
@@ -467,9 +503,10 @@ jobs:
working-directory: apps/web
env:
E2E_SHARD: ${{ matrix.shard }}
E2E_MANIFEST_SHARD: ${{ matrix.shard }}
KANDEV_E2E_BUILD_IDENTITY: ${{ github.workspace }}/apps/backend/bin/e2e-build-identity.json
KANDEV_E2E_EXPECTED_SOURCE_REVISION: ${{ github.sha }}
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/normal/${{ matrix.shard }}.json"
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/normal/${E2E_MANIFEST_SHARD}.json"
- name: Upload blob report
if: always()
@@ -1104,6 +1141,8 @@ jobs:
# reports to download, and the artifact step would fail on an empty run.
# `e2e-gate` below, not this job, is what the merge queue waits on.
if: always() && needs.changes.outputs.run == 'true'
# Report aggregation queries GitHub history and downloads artifacts with the
# job token, so it remains on a GitHub-hosted runner during the pilot.
runs-on: ubuntu-latest
# Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright CLI.
# `runtime` tag — no Go needed for merging blob reports.
@@ -1473,10 +1512,11 @@ jobs:
# count changes.
e2e-gate:
name: E2E Tests Passed
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).e2e_gate_runner }}
needs:
[
changes,
runner_plan,
build,
playwright_image,
e2e,
@@ -1490,6 +1530,7 @@ jobs:
steps:
- name: Require the E2E jobs to have passed or been skipped
env:
RUNNER_PLAN_RESULT: ${{ needs.runner_plan.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
BUILD_RESULT: ${{ needs.build.result }}
PLAYWRIGHT_IMAGE_RESULT: ${{ needs.playwright_image.result }}
@@ -1502,6 +1543,7 @@ jobs:
set -euo pipefail
echo "Change detection: ${CHANGES_RESULT}"
echo "Runner plan: ${RUNNER_PLAN_RESULT}"
echo "Build: ${BUILD_RESULT}"
echo "Playwright image: ${PLAYWRIGHT_IMAGE_RESULT}"
echo "E2E shards: ${E2E_RESULT}"
@@ -1510,6 +1552,13 @@ jobs:
echo "Desktop smoke: ${DESKTOP_E2E_RESULT}"
echo "Report merge: ${E2E_REPORT_RESULT}"
# Runner planning must succeed because its outputs select the runner
# fleet. A planner error cannot silently become a hosted fallback.
if [[ "${RUNNER_PLAN_RESULT}" != "success" ]]; then
echo "Runner planning did not succeed; refusing to report a pass." >&2
exit 1
fi
# Change detection itself must succeed: if it errored we do not know
# whether the skips below were deliberate.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
+42 -5
View File
@@ -29,12 +29,42 @@ permissions:
packages: read
jobs:
runner_plan:
name: Plan external runner allocation
runs-on: ubuntu-latest
outputs:
plan: ${{ steps.plan.outputs.plan }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Plan runner allocation
id: plan
uses: ./.github/actions/plan-external-runners
with:
workflow: frontend
run-id: ${{ github.run_id }}
families: >-
[
{"name":"changes","tier":"light"},
{"name":"frontend","tier":"standard"},
{"name":"frontend_gate","tier":"light"}
]
burst: ${{ vars.KANDEV_CI_EXTERNAL_ENABLED }}
percent: ${{ vars.KANDEV_CI_EXTERNAL_PERCENT }}
light-label: ${{ vars.KANDEV_CI_RUNNER_LIGHT }}
standard-label: ${{ vars.KANDEV_CI_RUNNER_STANDARD }}
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
needs: runner_plan
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).changes_runner }}
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
@@ -103,9 +133,9 @@ jobs:
frontend:
name: Run Frontend Lint, Tests, and Build
needs: changes
needs: [changes, runner_plan]
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).frontend_runner }}
# Pre-baked image bundles Node 24, pnpm 9.15.9, Go, and go-licenses —
# see .github/docker/ci-base/Dockerfile.
container: ghcr.io/kdlbs/kandev-ci:build-latest
@@ -121,6 +151,7 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Mark workspace safe for git
shell: bash
@@ -353,22 +384,28 @@ jobs:
# conclusion to read instead of a check that stays pending forever.
frontend-gate:
name: Frontend Tests Passed
runs-on: ubuntu-latest
needs: [changes, frontend]
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).frontend_gate_runner }}
needs: [changes, frontend, runner_plan]
if: always()
steps:
- name: Require the frontend job to have passed or been skipped
env:
RUNNER_PLAN_RESULT: ${{ needs.runner_plan.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
FRONTEND_RESULT: ${{ needs.frontend.result }}
run: |
set -euo pipefail
echo "Runner plan: ${RUNNER_PLAN_RESULT}"
# `skipped` is a pass: the change-detection job decided nothing this
# workflow covers was touched. `cancelled` is not — a cancelled job
# never reached a verdict, and treating that as a pass would let an
# untested merge group through.
if [[ "${RUNNER_PLAN_RESULT}" != "success" ]]; then
echo "Runner planning finished with result: ${RUNNER_PLAN_RESULT}" >&2
exit 1
fi
if [[ "${CHANGES_RESULT}" != "success" ]]; then
echo "Change detection finished with result: ${CHANGES_RESULT}" >&2
exit 1
@@ -34,6 +34,8 @@ permissions:
jobs:
lint:
name: All action refs are SHA-pinned
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
steps:
@@ -73,6 +75,12 @@ jobs:
- name: Test E2E workflow contract
run: python3 .github/scripts/e2e-tests-workflow-contract_test.py
- name: Test external runner workflow contract
run: python3 .github/scripts/external-runner-workflow-contract_test.py
- name: Test external runner planner
run: python3 .github/scripts/runner-plan_test.py
- name: Test bounded step summary
run: python3 .github/scripts/bounded-step-summary_test.py
+2
View File
@@ -33,6 +33,8 @@ permissions:
jobs:
lint:
name: Harness files pass lint
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
steps: