diff --git a/.github/scripts/runners.py b/.github/scripts/runners.py index 10b27b81c4d..3bd7becd7e7 100644 --- a/.github/scripts/runners.py +++ b/.github/scripts/runners.py @@ -22,10 +22,18 @@ def normalize_mode(raw_mode): return "default" -def resolve_contract(mode): +def is_enabled(raw_value): + return (raw_value or "").strip().lower() in {"1", "true", "yes", "on", "hosted"} + + +def resolve_contract(mode, control_fallback=False): general_medium = BLACKSMITH_4V if mode == "performance" else GITHUB_HOSTED hot_path = GITHUB_HOSTED if mode == "economic" else BLACKSMITH_4V control = CONTABO_CONTROL if mode == "default" else GITHUB_HOSTED + # Escape hatch: route control-profile jobs to GitHub-hosted runners when the + # self-hosted pool is unavailable, without changing runner mode. + if control_fallback: + control = GITHUB_HOSTED return { "runs_on": { @@ -40,12 +48,16 @@ def resolve_contract(mode): "decision": { "schema_version": 1, "mode": mode, + "control_fallback": control_fallback, }, } def main(): - contract = resolve_contract(normalize_mode(os.environ.get("OD_CI_RUNNER_MODE"))) + contract = resolve_contract( + normalize_mode(os.environ.get("OD_CI_RUNNER_MODE")), + is_enabled(os.environ.get("OD_CI_CONTROL_FALLBACK")), + ) output_path = os.environ.get("GITHUB_OUTPUT") lines = [ f"{key}={value if isinstance(value, str) else compact_json(value)}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859744c3e75..029442bfe5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: id: runners env: OD_CI_RUNNER_MODE: ${{ vars.OD_CI_RUNNER_MODE }} + OD_CI_CONTROL_FALLBACK: ${{ vars.OD_CI_CONTROL_FALLBACK }} run: python3 .github/scripts/runners.py scopes: diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index c709ddd9ae0..45483d769e4 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -234,13 +234,21 @@ if (${JSON.stringify(changedFiles.length > 0)}) process.stdout.write("\\n"); } } -async function runRunners(mode?: string): Promise> { +async function runRunners( + mode?: string, + options: { controlFallback?: boolean } = {}, +): Promise> { const env = { ...process.env }; if (mode === undefined) { delete env.OD_CI_RUNNER_MODE; } else { env.OD_CI_RUNNER_MODE = mode; } + if (options.controlFallback) { + env.OD_CI_CONTROL_FALLBACK = "1"; + } else { + delete env.OD_CI_CONTROL_FALLBACK; + } delete env.GITHUB_OUTPUT; const { stdout } = await execFileAsync("python3", [runnersScriptPath], { @@ -268,8 +276,14 @@ function runnerOutput(profiles: Record, key: string): string { return value; } -function runnerDecision(profiles: Record): { schema_version: number; mode: string } { - return JSON.parse(runnerOutput(profiles, "decision")) as { schema_version: number; mode: string }; +function runnerDecision( + profiles: Record, +): { schema_version: number; mode: string; control_fallback: boolean } { + return JSON.parse(runnerOutput(profiles, "decision")) as { + schema_version: number; + mode: string; + control_fallback: boolean; + }; } function runnerRunsOn(profiles: Record): Record { @@ -1218,7 +1232,7 @@ process.stdin.on("end", () => { it("[P2] resolves CI runner profiles by mode", async () => { const defaultProfiles = await runRunners(); const defaultRunsOn = runnerRunsOn(defaultProfiles); - expect(runnerDecision(defaultProfiles)).toEqual({ schema_version: 1, mode: "default" }); + expect(runnerDecision(defaultProfiles)).toEqual({ schema_version: 1, mode: "default", control_fallback: false }); expect(Object.keys(defaultRunsOn).sort()).toEqual([ "control", "general_medium", @@ -1247,7 +1261,7 @@ process.stdin.on("end", () => { const performanceProfiles = await runRunners("performance"); const performanceRunsOn = runnerRunsOn(performanceProfiles); - expect(runnerDecision(performanceProfiles)).toEqual({ schema_version: 1, mode: "performance" }); + expect(runnerDecision(performanceProfiles)).toEqual({ schema_version: 1, mode: "performance", control_fallback: false }); expect(performanceRunsOn.control).toEqual(["ubuntu-24.04"]); expect(performanceRunsOn.general_medium).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); expect(performanceRunsOn.workspace_unit).toEqual(["ubuntu-24.04"]); @@ -1258,7 +1272,7 @@ process.stdin.on("end", () => { const economicProfiles = await runRunners("economic"); const economicRunsOn = runnerRunsOn(economicProfiles); - expect(runnerDecision(economicProfiles)).toEqual({ schema_version: 1, mode: "economic" }); + expect(runnerDecision(economicProfiles)).toEqual({ schema_version: 1, mode: "economic", control_fallback: false }); expect(economicRunsOn.control).toEqual(["ubuntu-24.04"]); expect(economicRunsOn.general_medium).toEqual(["ubuntu-24.04"]); expect(economicRunsOn.workspace_unit).toEqual(["ubuntu-24.04"]); @@ -1268,6 +1282,31 @@ process.stdin.on("end", () => { expect(economicRunsOn.visual_hot).toEqual(["ubuntu-24.04"]); }); + it("[P2] control fallback routes only the control profile to GitHub-hosted", async () => { + const fallbackProfiles = await runRunners(undefined, { controlFallback: true }); + const fallbackRunsOn = runnerRunsOn(fallbackProfiles); + expect(runnerDecision(fallbackProfiles)).toEqual({ + schema_version: 1, + mode: "default", + control_fallback: true, + }); + expect(fallbackRunsOn.control).toEqual(["ubuntu-24.04"]); + expect(fallbackRunsOn.general_medium).toEqual(["ubuntu-24.04"]); + expect(fallbackRunsOn.workspace_unit).toEqual(["ubuntu-24.04"]); + expect(fallbackRunsOn.windows_tools).toEqual(["windows-latest"]); + expect(fallbackRunsOn.js_hot).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); + expect(fallbackRunsOn.ui_hot).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); + expect(fallbackRunsOn.visual_hot).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); + + // A non-default mode still only flips control; the other tiers stay mode-driven. + const performanceFallbackRunsOn = runnerRunsOn( + await runRunners("performance", { controlFallback: true }), + ); + expect(performanceFallbackRunsOn.control).toEqual(["ubuntu-24.04"]); + expect(performanceFallbackRunsOn.general_medium).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); + expect(performanceFallbackRunsOn.js_hot).toEqual(["blacksmith-4vcpu-ubuntu-2404"]); + }); + it("[P2] routes CI follow-ons through generic handoff workflows", async () => { const [ciWorkflow, commentWorkflow, autofixWorkflow, reportWorkflow, handoffScript] = await Promise.all([ readFile(ciWorkflowPath, "utf8"),