diff --git a/.github/scripts/select-runner.cjs b/.github/scripts/select-runner.cjs index fe102dc..fa82dc5 100644 --- a/.github/scripts/select-runner.cjs +++ b/.github/scripts/select-runner.cjs @@ -43,7 +43,7 @@ function hostedResult(hostedRunner, reason) { runner: hostedRunner || DEFAULT_HOSTED_RUNNER, route: "hosted", reason, - idleRunnerCount: 0, + onlineRunnerCount: 0, }); } @@ -156,8 +156,8 @@ function permitsLocalExecution(input) { function preflight(input) { // The fallback is itself an untrusted operational value. Canonicalize it - // before every early return so hosted-only, rerun, and security guards can - // never be redirected to a generic or managed self-hosted label. + // before every early return so hosted-only and security guards can never + // be redirected to a generic or managed self-hosted label. const hosted = canonicalHostedRunner(input); const hostedRunner = hosted.runner; @@ -167,14 +167,6 @@ function preflight(input) { const selfHostedOnly = input.policy === "self-hosted-only"; - if ( - !selfHostedOnly && - Number.isInteger(input.runAttempt) && - input.runAttempt > 1 - ) { - return { result: hostedResult(hostedRunner, "rerun") }; - } - // Public repositories do not save billed minutes. Only explicitly reviewed // caller event classes may route locally, and fork/Dependabot code must never // receive the observer credential. This guard is duplicated on the token- @@ -197,11 +189,7 @@ function preflight(input) { return { result: hostedResult(hostedRunner, "missing-config") }; } - if ( - !exactNonEmptyString(input.hostedRunner) || - !Number.isInteger(input.runAttempt) || - input.runAttempt < 1 - ) { + if (!exactNonEmptyString(input.hostedRunner)) { if (selfHostedOnly) { throw new StrictRoutingError("missing-config"); } @@ -216,9 +204,6 @@ function preflight(input) { !exactNonEmptyString(input.owner) || (input.scope === "repository" && !exactNonEmptyString(input.repository)) || - // Attempts greater than one returned `rerun` above. Requiring exactly - // one here keeps prefer-self-hosted reruns on the hosted route. - input.runAttempt !== 1 || !validTimeout) ) { return { result: hostedResult(hostedRunner, "missing-config") }; @@ -254,7 +239,7 @@ function preflight(input) { runner: candidates.labels[0], route: "self-hosted", reason: "self-hosted-only", - idleRunnerCount: 0, + onlineRunnerCount: 0, }), }; } @@ -279,7 +264,6 @@ function validateRunner(runner) { typeof runner.name !== "string" || !exactNonEmptyString(runner.os) || typeof runner.status !== "string" || - typeof runner.busy !== "boolean" || (Object.hasOwn(runner, "ephemeral") && typeof runner.ephemeral !== "boolean") || !Array.isArray(runner.labels) || @@ -380,7 +364,7 @@ function throwIfAborted(signal) { } } -function selectIdleCandidate(runners, labels, managedRunnerPrefix) { +function selectOnlineCandidate(runners, labels, managedRunnerPrefix) { // `runs-on` contains only the selected label, so GitHub may assign any // runner carrying it. Treat that case-insensitive label as contaminated // if even one bearer falls outside the managed namespace, reports itself @@ -449,7 +433,10 @@ function selectIdleCandidate(runners, labels, managedRunnerPrefix) { ); const safeLabelKeys = new Set(safeLabels.map((candidate) => candidate.key)); - const idleRunners = inventory.filter(({ runner, routingLabelKeys }) => { + // Liveness, not idleness: a busy online runner is proof the fleet is alive, + // and GitHub natively queues the job until a matching runner frees up. Only + // a fully offline fleet falls back to the hosted route. + const onlineRunners = inventory.filter(({ runner, routingLabelKeys }) => { // GitHub's 2026-03-10 OpenAPI makes `ephemeral` optional, and live list // responses can omit it. An explicit false is authoritative exclusion; // omission relies on the governed assumption that the exact prefix and @@ -461,19 +448,18 @@ function selectIdleCandidate(runners, labels, managedRunnerPrefix) { return ( runner.name.startsWith(managedRunnerPrefix) && runner.status === "online" && - runner.busy === false && eligibleEphemeralState && [...routingLabelKeys].some((labelKey) => safeLabelKeys.has(labelKey)) ); }); const selectedLabel = safeLabels.find((candidate) => - idleRunners.some(({ routingLabelKeys }) => + onlineRunners.some(({ routingLabelKeys }) => routingLabelKeys.has(candidate.key), ), ); return { - idleRunnerCount: idleRunners.length, + onlineRunnerCount: onlineRunners.length, selectedLabel: selectedLabel?.label, labelNamespaceInvalid: contaminatedLabelKeys.size > 0 && safeLabels.length === 0, @@ -521,22 +507,22 @@ async function selectRunner(input, dependencies = {}) { controller.signal, ); throwIfAborted(controller.signal); - const idle = selectIdleCandidate( + const selection = selectOnlineCandidate( runners, prepared.labels, input.managedRunnerPrefix, ); - if (idle.labelNamespaceInvalid) { + if (selection.labelNamespaceInvalid) { return hostedResult(prepared.hostedRunner, "invalid-response"); } - if (!idle.selectedLabel) { - return hostedResult(prepared.hostedRunner, "no-idle-runner"); + if (!selection.selectedLabel) { + return hostedResult(prepared.hostedRunner, "no-online-runner"); } return Object.freeze({ - runner: idle.selectedLabel, + runner: selection.selectedLabel, route: "self-hosted", - reason: "idle", - idleRunnerCount: idle.idleRunnerCount, + reason: "online", + onlineRunnerCount: selection.onlineRunnerCount, }); } catch (error) { return errorResult(error, controller, prepared.hostedRunner); @@ -556,7 +542,6 @@ function inputsFromEnvironment(env) { observerClientID: env.OBSERVER_CLIENT_ID, hasObserverSecret: env.HAS_OBSERVER_SECRET === "true", tokenOutcome: env.TOKEN_OUTCOME, - runAttempt: Number(env.RUN_ATTEMPT), owner: env.REPOSITORY_OWNER, repository: env.REPOSITORY_NAME, apiTimeoutSeconds: Number(env.API_TIMEOUT_SECONDS), @@ -574,7 +559,7 @@ async function runGitHubScript({ github, core, env }) { core.setOutput("runner", result.runner); core.setOutput("route", result.route); core.setOutput("reason", result.reason); - core.setOutput("idle-runner-count", String(result.idleRunnerCount)); + core.setOutput("online-runner-count", String(result.onlineRunnerCount)); return result; } @@ -586,6 +571,6 @@ module.exports = Object.freeze({ permitsLocalExecution, preflight, runGitHubScript, - selectIdleCandidate, + selectOnlineCandidate, selectRunner, }); diff --git a/.github/scripts/select-runner.test.cjs b/.github/scripts/select-runner.test.cjs index 30d4e23..34e2464 100644 --- a/.github/scripts/select-runner.test.cjs +++ b/.github/scripts/select-runner.test.cjs @@ -46,7 +46,6 @@ function input(overrides = {}) { observerClientID: "Iv23observer", hasObserverSecret: true, tokenOutcome: "success", - runAttempt: 1, owner: "melodic-software", repository: "medley", apiTimeoutSeconds: 10, @@ -93,7 +92,7 @@ test("hosted-only returns without an inventory request", async () => { runner: "ubuntu-24.04", route: "hosted", reason: "hosted-only", - idleRunnerCount: 0, + onlineRunnerCount: 0, }); }); @@ -111,7 +110,6 @@ test("self-hosted-only queues the primary managed label without credentials or i owner: "", repository: "", apiTimeoutSeconds: Number.NaN, - runAttempt: 2, }), { request: requestMustNotRun }, ); @@ -119,7 +117,7 @@ test("self-hosted-only queues the primary managed label without credentials or i runner: "melodic-ubuntu-24.04-x64", route: "self-hosted", reason: "self-hosted-only", - idleRunnerCount: 0, + onlineRunnerCount: 0, }); }); @@ -139,7 +137,6 @@ for (const [name, overrides, reason] of [ }, "unapproved-label", ], - ["invalid run attempt", { runAttempt: 0 }, "missing-config"], ]) { test(`self-hosted-only rejects ${name} instead of spending hosted minutes`, async () => { await assert.rejects( @@ -173,7 +170,6 @@ for (const [name, overrides] of [ for (const [routeName, overrides, reason] of [ ["hosted-only", { policy: "hosted-only" }, "hosted-only"], - ["rerun", { runAttempt: 2, tokenOutcome: "skipped" }, "rerun"], [ "public guard", { repositoryPrivate: false, tokenOutcome: "skipped" }, @@ -216,7 +212,7 @@ for (const [routeName, overrides, reason] of [ runner: "ubuntu-24.04", route: "hosted", reason, - idleRunnerCount: 0, + onlineRunnerCount: 0, }); }); } @@ -245,7 +241,7 @@ for (const eventName of ALLOWED_LOCAL_EVENTS) { request: async () => response([runner()]), }); assert.equal(result.route, "self-hosted"); - assert.equal(result.reason, "idle"); + assert.equal(result.reason, "online"); }); } @@ -262,27 +258,6 @@ for (const eventName of BLOCKED_LOCAL_EVENTS) { }); } -test("rerun attempt 2 routes hosted before authentication or inventory", async () => { - const result = await selectRunner( - input({ runAttempt: 2, tokenOutcome: "skipped" }), - { - request: requestMustNotRun, - }, - ); - assert.equal(result.route, "hosted"); - assert.equal(result.reason, "rerun"); -}); - -for (const runAttempt of [undefined, Number.NaN, 0, 1.5, -1]) { - test(`invalid run attempt ${String(runAttempt)} fails hosted as missing configuration`, async () => { - const result = await selectRunner(input({ runAttempt }), { - request: requestMustNotRun, - }); - assert.equal(result.route, "hosted"); - assert.equal(result.reason, "missing-config"); - }); -} - test("invalid scope fails hosted without an inventory request", async () => { const result = await selectRunner(input({ scope: "enterprise" }), { request: requestMustNotRun, @@ -382,8 +357,8 @@ test("organization inventory success uses the exact API contract", async () => { assert.deepEqual(result, { runner: "melodic-ubuntu-24.04-x64", route: "self-hosted", - reason: "idle", - idleRunnerCount: 1, + reason: "online", + onlineRunnerCount: 1, }); }); @@ -404,11 +379,10 @@ test("repository inventory success targets only the caller repository", async () assert.equal(result.route, "self-hosted"); }); -test("filter requires exact label, managed prefix, online, and idle", async () => { +test("filter requires exact label, managed prefix, and online state", async () => { const inventory = [ runner({ name: "unmanaged-1", labels: [{ name: "unrelated-label" }] }), runner({ status: "offline" }), - runner({ busy: true }), runner({ labels: [{ name: "melodic-ubuntu-24.04-x64-other" }] }), runner({ name: "ci-runner-melo-lap-001-1" }), ]; @@ -416,7 +390,19 @@ test("filter requires exact label, managed prefix, online, and idle", async () = request: async () => response(inventory), }); assert.equal(result.route, "self-hosted"); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); +}); + +test("a busy online runner keeps the self-hosted route so GitHub queues the job", async () => { + const result = await selectRunner(input(), { + request: async () => response([runner({ busy: true })]), + }); + assert.deepEqual(result, { + runner: "melodic-ubuntu-24.04-x64", + route: "self-hosted", + reason: "online", + onlineRunnerCount: 1, + }); }); test("an unrelated runner with omitted optional ephemeral is valid but ineligible", async () => { @@ -427,7 +413,7 @@ test("an unrelated runner with omitted optional ephemeral is valid but ineligibl ]), }); assert.equal(result.route, "hosted"); - assert.equal(result.reason, "no-idle-runner"); + assert.equal(result.reason, "no-online-runner"); }); test("a matching managed runner remains eligible when optional ephemeral is omitted", async () => { @@ -442,8 +428,8 @@ test("a matching managed runner remains eligible when optional ephemeral is omit assert.deepEqual(result, { runner: "melodic-ubuntu-24.04-x64", route: "self-hosted", - reason: "idle", - idleRunnerCount: 1, + reason: "online", + onlineRunnerCount: 1, }); }); @@ -460,8 +446,8 @@ test("a single scale-set route selects a live managed runner with an empty REST assert.deepEqual(result, { runner: "melodic-ubuntu-24.04-x64", route: "self-hosted", - reason: "idle", - idleRunnerCount: 1, + reason: "online", + onlineRunnerCount: 1, }); }); @@ -480,8 +466,8 @@ test("an empty REST label list cannot be attributed across ordered scale-set rou }, ); assert.equal(result.route, "hosted"); - assert.equal(result.reason, "no-idle-runner"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.reason, "no-online-runner"); + assert.equal(result.onlineRunnerCount, 0); }); for (const incompatible of [ @@ -511,11 +497,11 @@ for (const incompatible of [ ); assert.equal(result.route, "hosted"); assert.equal(result.reason, "invalid-response"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.onlineRunnerCount, 0); }); } -test("empty-label scale-set inference still requires the managed prefix, online state, and idle state", async () => { +test("empty-label scale-set inference still requires the managed prefix and online state", async () => { const result = await selectRunner(input(), { request: async () => response([ @@ -529,6 +515,17 @@ test("empty-label scale-set inference still requires the managed prefix, online os: "unknown", labels: [], }), + ]), + }); + assert.equal(result.route, "hosted"); + assert.equal(result.reason, "no-online-runner"); + assert.equal(result.onlineRunnerCount, 0); +}); + +test("a busy empty-label scale-set runner keeps the inferred single route live", async () => { + const result = await selectRunner(input(), { + request: async () => + response([ runnerWithoutEphemeral({ busy: true, os: "unknown", @@ -536,9 +533,9 @@ test("empty-label scale-set inference still requires the managed prefix, online }), ]), }); - assert.equal(result.route, "hosted"); - assert.equal(result.reason, "no-idle-runner"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.route, "self-hosted"); + assert.equal(result.reason, "online"); + assert.equal(result.onlineRunnerCount, 1); }); for (const incompatible of [ @@ -557,7 +554,7 @@ for (const incompatible of [ }); assert.equal(result.route, "hosted"); assert.equal(result.reason, "invalid-response"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.onlineRunnerCount, 0); }); } @@ -586,7 +583,6 @@ test("mixed live-shape inventory selects only the exact managed omitted-field ru labels: [{ name: "unrelated-label" }], }), runnerWithoutEphemeral({ status: "offline" }), - runnerWithoutEphemeral({ busy: true }), runnerWithoutEphemeral({ labels: [{ name: "unrelated-label" }] }), runnerWithoutEphemeral({ name: "ci-runner-melo-lap-001-1" }), ]; @@ -594,7 +590,7 @@ test("mixed live-shape inventory selects only the exact managed omitted-field ru request: async () => response(inventory), }); assert.equal(result.route, "self-hosted"); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("same-label wrong-prefix sibling contaminates the complete label namespace", async () => { @@ -610,7 +606,7 @@ test("same-label wrong-prefix sibling contaminates the complete label namespace" }); assert.equal(result.route, "hosted"); assert.equal(result.reason, "invalid-response"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.onlineRunnerCount, 0); }); test("same-label explicit-false sibling contaminates the complete label namespace", async () => { @@ -627,7 +623,7 @@ test("same-label explicit-false sibling contaminates the complete label namespac }); assert.equal(result.route, "hosted"); assert.equal(result.reason, "invalid-response"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.onlineRunnerCount, 0); }); for (const os of ["unknown", "UNKNOWN", "LiNuX"]) { @@ -672,7 +668,7 @@ for (const os of ["windows", "macOS"]) { ); assert.equal(result.route, "hosted"); assert.equal(result.reason, "invalid-response"); - assert.equal(result.idleRunnerCount, 0); + assert.equal(result.onlineRunnerCount, 0); }); } @@ -688,7 +684,7 @@ test("an omitted-field runner on an unrelated label does not poison a good sibli ]), }); assert.equal(result.route, "self-hosted"); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("a wrong-OS runner on an unrelated label does not poison the V1 namespace", async () => { @@ -704,7 +700,7 @@ test("a wrong-OS runner on an unrelated label does not poison the V1 namespace", ]), }); assert.equal(result.route, "self-hosted"); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("ordered candidates skip a contaminated label and select a clean lower priority", async () => { @@ -730,7 +726,7 @@ test("ordered candidates skip a contaminated label and select a clean lower prio ); assert.equal(result.route, "self-hosted"); assert.equal(result.runner, labels[1]); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("ordered candidates skip a wrong-OS label and preserve clean configured spelling", async () => { @@ -757,7 +753,7 @@ test("ordered candidates skip a wrong-OS label and preserve clean configured spe ); assert.equal(result.route, "self-hosted"); assert.equal(result.runner, labels[1]); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("runner label case variants dedupe while configured spelling is returned", async () => { @@ -778,7 +774,7 @@ test("runner label case variants dedupe while configured spelling is returned", ); assert.equal(result.route, "self-hosted"); assert.equal(result.runner, configuredLabel); - assert.equal(result.idleRunnerCount, 1); + assert.equal(result.onlineRunnerCount, 1); }); test("ordered candidates win independent of API runner ordering", async () => { @@ -798,15 +794,15 @@ test("ordered candidates win independent of API runner ordering", async () => { { request: async () => response(inventory) }, ); assert.equal(result.runner, labels[0]); - assert.equal(result.idleRunnerCount, 2); + assert.equal(result.onlineRunnerCount, 2); }); -test("stable saturation routes hosted", async () => { +test("a fully offline fleet routes hosted", async () => { const result = await selectRunner(input(), { - request: async () => response([runner({ busy: true })]), + request: async () => response([runner({ status: "offline" })]), }); assert.equal(result.route, "hosted"); - assert.equal(result.reason, "no-idle-runner"); + assert.equal(result.reason, "no-online-runner"); }); test("pagination reads every page before selecting", async () => { @@ -1014,7 +1010,7 @@ test("workflow rejects partial outputs when github-script infrastructure fails", ); assert.match( workflow, - /idle-runner-count: \$\{\{ steps\.select\.outcome == 'success' && steps\.select\.outputs\.idle-runner-count \|\| '0' \}\}/u, + /online-runner-count: \$\{\{ steps\.select\.outcome == 'success' && steps\.select\.outputs\.online-runner-count \|\| '0' \}\}/u, ); assert.equal( [...workflow.matchAll(/steps\.select\.outcome == 'success'/gu)].length, @@ -1060,7 +1056,6 @@ test("generated github-script bundle executes the tested adapter", async () => { OBSERVER_CLIENT_ID: "Iv23observer", HAS_OBSERVER_SECRET: "true", TOKEN_OUTCOME: "success", - RUN_ATTEMPT: "1", REPOSITORY_OWNER: "melodic-software", REPOSITORY_NAME: "medley", REPOSITORY_PRIVATE: "true", @@ -1073,8 +1068,8 @@ test("generated github-script bundle executes the tested adapter", async () => { ); assert.equal(outputs.get("runner"), "melodic-ubuntu-24.04-x64"); assert.equal(outputs.get("route"), "self-hosted"); - assert.equal(outputs.get("reason"), "idle"); - assert.equal(outputs.get("idle-runner-count"), "1"); + assert.equal(outputs.get("reason"), "online"); + assert.equal(outputs.get("online-runner-count"), "1"); }); test("Docker-dependent OSV exception breadcrumb remains machine-readable", () => { diff --git a/.github/workflows/select-runner.yml b/.github/workflows/select-runner.yml index d0e89d4..026836b 100644 --- a/.github/workflows/select-runner.yml +++ b/.github/workflows/select-runner.yml @@ -52,9 +52,9 @@ on: reason: description: Stable machine-readable routing reason. value: ${{ jobs.select.outputs.reason }} - idle-runner-count: - description: Number of matching eligible online, idle managed runners. - value: ${{ jobs.select.outputs.idle-runner-count }} + online-runner-count: + description: Number of matching eligible online managed runners (busy included). + value: ${{ jobs.select.outputs.online-runner-count }} permissions: {} @@ -76,13 +76,12 @@ jobs: runner: ${{ steps.select.outcome == 'success' && steps.select.outputs.runner || inputs.policy == 'self-hosted-only' && 'ci-runner-selection-failed' || 'ubuntu-24.04' }} route: ${{ steps.select.outcome == 'success' && steps.select.outputs.route || inputs.policy == 'self-hosted-only' && 'error' || 'hosted' }} reason: ${{ steps.select.outcome == 'success' && steps.select.outputs.reason || inputs.policy == 'self-hosted-only' && 'selector-error' || 'api-error' }} - idle-runner-count: ${{ steps.select.outcome == 'success' && steps.select.outputs.idle-runner-count || '0' }} + online-runner-count: ${{ steps.select.outcome == 'success' && steps.select.outputs.online-runner-count || '0' }} steps: - name: Mint read-only observer token id: observer-token if: >- inputs.policy == 'prefer-self-hosted' && - github.run_attempt == 1 && (inputs.self-hosted-label != '' || inputs.self-hosted-labels-json != '') && inputs.hosted-runner != '' && (inputs.scope == 'organization' || inputs.scope == 'repository') && @@ -124,7 +123,6 @@ jobs: OBSERVER_CLIENT_ID: ${{ inputs.observer-client-id }} HAS_OBSERVER_SECRET: ${{ env.HAS_OBSERVER_SECRET }} TOKEN_OUTCOME: ${{ steps.observer-token.outcome }} - RUN_ATTEMPT: ${{ github.run_attempt }} REPOSITORY_OWNER: ${{ github.repository_owner }} REPOSITORY_NAME: ${{ github.event.repository.name }} REPOSITORY_PRIVATE: ${{ github.event.repository.private }} @@ -187,7 +185,7 @@ jobs: runner: hostedRunner || DEFAULT_HOSTED_RUNNER, route: "hosted", reason, - idleRunnerCount: 0, + onlineRunnerCount: 0, }); } @@ -300,8 +298,8 @@ jobs: function preflight(input) { // The fallback is itself an untrusted operational value. Canonicalize it - // before every early return so hosted-only, rerun, and security guards can - // never be redirected to a generic or managed self-hosted label. + // before every early return so hosted-only and security guards can never + // be redirected to a generic or managed self-hosted label. const hosted = canonicalHostedRunner(input); const hostedRunner = hosted.runner; @@ -311,14 +309,6 @@ jobs: const selfHostedOnly = input.policy === "self-hosted-only"; - if ( - !selfHostedOnly && - Number.isInteger(input.runAttempt) && - input.runAttempt > 1 - ) { - return { result: hostedResult(hostedRunner, "rerun") }; - } - // Public repositories do not save billed minutes. Only explicitly reviewed // caller event classes may route locally, and fork/Dependabot code must never // receive the observer credential. This guard is duplicated on the token- @@ -341,11 +331,7 @@ jobs: return { result: hostedResult(hostedRunner, "missing-config") }; } - if ( - !exactNonEmptyString(input.hostedRunner) || - !Number.isInteger(input.runAttempt) || - input.runAttempt < 1 - ) { + if (!exactNonEmptyString(input.hostedRunner)) { if (selfHostedOnly) { throw new StrictRoutingError("missing-config"); } @@ -360,9 +346,6 @@ jobs: !exactNonEmptyString(input.owner) || (input.scope === "repository" && !exactNonEmptyString(input.repository)) || - // Attempts greater than one returned `rerun` above. Requiring exactly - // one here keeps prefer-self-hosted reruns on the hosted route. - input.runAttempt !== 1 || !validTimeout) ) { return { result: hostedResult(hostedRunner, "missing-config") }; @@ -398,7 +381,7 @@ jobs: runner: candidates.labels[0], route: "self-hosted", reason: "self-hosted-only", - idleRunnerCount: 0, + onlineRunnerCount: 0, }), }; } @@ -423,7 +406,6 @@ jobs: typeof runner.name !== "string" || !exactNonEmptyString(runner.os) || typeof runner.status !== "string" || - typeof runner.busy !== "boolean" || (Object.hasOwn(runner, "ephemeral") && typeof runner.ephemeral !== "boolean") || !Array.isArray(runner.labels) || @@ -524,7 +506,7 @@ jobs: } } - function selectIdleCandidate(runners, labels, managedRunnerPrefix) { + function selectOnlineCandidate(runners, labels, managedRunnerPrefix) { // `runs-on` contains only the selected label, so GitHub may assign any // runner carrying it. Treat that case-insensitive label as contaminated // if even one bearer falls outside the managed namespace, reports itself @@ -593,7 +575,10 @@ jobs: ); const safeLabelKeys = new Set(safeLabels.map((candidate) => candidate.key)); - const idleRunners = inventory.filter(({ runner, routingLabelKeys }) => { + // Liveness, not idleness: a busy online runner is proof the fleet is alive, + // and GitHub natively queues the job until a matching runner frees up. Only + // a fully offline fleet falls back to the hosted route. + const onlineRunners = inventory.filter(({ runner, routingLabelKeys }) => { // GitHub's 2026-03-10 OpenAPI makes `ephemeral` optional, and live list // responses can omit it. An explicit false is authoritative exclusion; // omission relies on the governed assumption that the exact prefix and @@ -605,19 +590,18 @@ jobs: return ( runner.name.startsWith(managedRunnerPrefix) && runner.status === "online" && - runner.busy === false && eligibleEphemeralState && [...routingLabelKeys].some((labelKey) => safeLabelKeys.has(labelKey)) ); }); const selectedLabel = safeLabels.find((candidate) => - idleRunners.some(({ routingLabelKeys }) => + onlineRunners.some(({ routingLabelKeys }) => routingLabelKeys.has(candidate.key), ), ); return { - idleRunnerCount: idleRunners.length, + onlineRunnerCount: onlineRunners.length, selectedLabel: selectedLabel?.label, labelNamespaceInvalid: contaminatedLabelKeys.size > 0 && safeLabels.length === 0, @@ -665,22 +649,22 @@ jobs: controller.signal, ); throwIfAborted(controller.signal); - const idle = selectIdleCandidate( + const selection = selectOnlineCandidate( runners, prepared.labels, input.managedRunnerPrefix, ); - if (idle.labelNamespaceInvalid) { + if (selection.labelNamespaceInvalid) { return hostedResult(prepared.hostedRunner, "invalid-response"); } - if (!idle.selectedLabel) { - return hostedResult(prepared.hostedRunner, "no-idle-runner"); + if (!selection.selectedLabel) { + return hostedResult(prepared.hostedRunner, "no-online-runner"); } return Object.freeze({ - runner: idle.selectedLabel, + runner: selection.selectedLabel, route: "self-hosted", - reason: "idle", - idleRunnerCount: idle.idleRunnerCount, + reason: "online", + onlineRunnerCount: selection.onlineRunnerCount, }); } catch (error) { return errorResult(error, controller, prepared.hostedRunner); @@ -700,7 +684,6 @@ jobs: observerClientID: env.OBSERVER_CLIENT_ID, hasObserverSecret: env.HAS_OBSERVER_SECRET === "true", tokenOutcome: env.TOKEN_OUTCOME, - runAttempt: Number(env.RUN_ATTEMPT), owner: env.REPOSITORY_OWNER, repository: env.REPOSITORY_NAME, apiTimeoutSeconds: Number(env.API_TIMEOUT_SECONDS), @@ -718,7 +701,7 @@ jobs: core.setOutput("runner", result.runner); core.setOutput("route", result.route); core.setOutput("reason", result.reason); - core.setOutput("idle-runner-count", String(result.idleRunnerCount)); + core.setOutput("online-runner-count", String(result.onlineRunnerCount)); return result; } @@ -730,7 +713,7 @@ jobs: permitsLocalExecution, preflight, runGitHubScript, - selectIdleCandidate, + selectOnlineCandidate, selectRunner, }); return module.exports; diff --git a/.github/workflows/selector-conformance.yml b/.github/workflows/selector-conformance.yml index 67a2826..9acd403 100644 --- a/.github/workflows/selector-conformance.yml +++ b/.github/workflows/selector-conformance.yml @@ -85,8 +85,8 @@ jobs: policy: hosted-only hosted-runner: ubuntu-24.04 - public-guard-or-rerun: - name: Public guard or rerun + public-guard: + name: Public guard uses: ./.github/workflows/select-runner.yml with: policy: prefer-self-hosted @@ -98,7 +98,7 @@ jobs: assert: name: Assert fail-open contract - needs: [selector-unit, hosted-only, public-guard-or-rerun] + needs: [selector-unit, hosted-only, public-guard] runs-on: ubuntu-24.04 timeout-minutes: 2 steps: @@ -108,10 +108,12 @@ jobs: HOSTED_RUNNER: ${{ needs.hosted-only.outputs.runner }} HOSTED_ROUTE: ${{ needs.hosted-only.outputs.route }} HOSTED_REASON: ${{ needs.hosted-only.outputs.reason }} - GUARDED_RUNNER: ${{ needs.public-guard-or-rerun.outputs.runner }} - GUARDED_ROUTE: ${{ needs.public-guard-or-rerun.outputs.route }} - GUARDED_REASON: ${{ needs.public-guard-or-rerun.outputs.reason }} - EXPECTED_GUARDED_REASON: ${{ github.run_attempt > 1 && 'rerun' || 'hosted-only' }} + GUARDED_RUNNER: ${{ needs.public-guard.outputs.runner }} + GUARDED_ROUTE: ${{ needs.public-guard.outputs.route }} + GUARDED_REASON: ${{ needs.public-guard.outputs.reason }} + # The public-repository guard fires on every attempt; re-runs make a + # fresh liveness decision instead of forcing a hosted route. + EXPECTED_GUARDED_REASON: hosted-only run: | test "$HOSTED_RUNNER" = ubuntu-24.04 test "$HOSTED_ROUTE" = hosted diff --git a/README.md b/README.md index 62cab44..68bc2b4 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,17 @@ GitHub continues the normal weekly patching of each hosted image generation. job has its own runner and timeout; the selector's platform limit does not carry into that job. `prefer-self-hosted` is deliberately fail-open to the configured hosted - runner. It uses a read-only observer GitHub App and chooses local only when a - governed scale-set route has a managed-prefix runner that is online, idle, and - not explicitly reported as non-ephemeral. Full reruns route hosted in that - mode. `self-hosted-only` instead returns the configured exact managed - label without inventory or observer credentials, including on reruns, so a + runner. It uses a read-only observer GitHub App and chooses local when a + governed scale-set route has a managed-prefix runner that is online and not + explicitly reported as non-ephemeral, regardless of busy state: liveness, not + idleness. GitHub natively [queues a job until a matching runner is + available][runner-routing], failing it only after 24 hours queued, so a busy + fleet absorbs bursts without spending hosted minutes; only a fully offline + fleet falls back to the hosted route. Re-running failed jobs reuses the + prior attempt's successful selector output; re-running all jobs makes a + fresh liveness decision. Neither forces the hosted route. + `self-hosted-only` instead returns the configured exact managed + label without inventory or observer credentials, so a trusted private workload queues until governed capacity is available. The queue-only label must be the exact centrally allowlisted `melodic-ubuntu-24.04-x64` route; adding another route requires a reviewed @@ -163,8 +169,9 @@ GitHub continues the normal weekly patching of each hosted image generation. repositories, fork pull requests, and Dependabot runs route hosted before the observer-token action can execute, following GitHub's [self-hosted runner security guidance][runner-security] and - [Dependabot secret boundary][dependabot-secrets]. Call it once per independently - schedulable workload: + [Dependabot secret boundary][dependabot-secrets]. Call it exactly once per + workflow and feed the single output to every lane's `runs-on`; per-lane + selector fan-out only multiplies identical preflight jobs: ```yaml jobs: @@ -189,12 +196,12 @@ GitHub continues the normal weekly patching of each hosted image generation. ``` Never use `secrets: inherit`; pass only the observer key. Stable output reasons - are `idle`, `self-hosted-only`, `hosted-only`, `rerun`, `no-idle-runner`, + are `online`, `self-hosted-only`, `hosted-only`, `no-online-runner`, `missing-config`, `missing-secret`, `auth-error`, `api-timeout`, `api-error`, `invalid-response`, and the strict infrastructure sentinel `selector-error`. The security eligibility guard also reports `hosted-only`. `selector-conformance.yml` runs the deterministic selector test - suite and proves the public, hosted-only, queue-only, and attempt-2 contracts + suite and proves the public, hosted-only, and queue-only contracts without accessing local capacity. The tested CommonJS source is generated into the workflow, so the reusable-workflow SHA pins the implementation without a second checkout/ref. This matters because actions inside a called @@ -264,8 +271,8 @@ GitHub continues the normal weekly patching of each hosted image generation. reserved for the `ci-runner` controller's one-job JIT workers. The REST response does not attest that ownership or lifecycle. The selector rejects visible namespace conflicts, but credentials and configuration must prevent - another runner from satisfying the same prefix-and-route contract. Online and - idle state are still required in the returned inventory observation. + another runner from satisfying the same prefix-and-route contract. Online + state is still required in the returned inventory observation. V1 compute is Linux x64, but GitHub's official [JIT-configuration response][runner-jit-config] reports `os: unknown`, as can @@ -279,7 +286,7 @@ GitHub continues the normal weekly patching of each hosted image generation. Because downstream `runs-on` contains only the returned route, namespace integrity is checked across every explicit case-insensitive bearer returned - by the paginated inventory—not only the idle runner observed by the selector. + by the paginated inventory—not only the online runner observed by the selector. A route is contaminated when an explicit bearer is outside the managed name prefix, reports `ephemeral: false`, or reports an OS outside the V1 Linux/JIT-unknown contract; that route is never returned. For one configured @@ -287,7 +294,7 @@ GitHub continues the normal weekly patching of each hosted image generation. multiple configured routes, a conforming empty-label managed runner is ineligible because it cannot be attributed, while a nonconforming one contaminates every candidate because its hidden route could be any of them. - An explicitly distinct clean lower-priority route remains eligible. Idle + An explicitly distinct clean lower-priority route remains eligible. Online counts include only eligible runners on clean routes. If every configured route is contaminated, selection fails hosted with `invalid-response`; omitted-field runners carrying unrelated explicit labels do not poison the @@ -310,10 +317,10 @@ GitHub continues the normal weekly patching of each hosted image generation. Inventory is an observation, not a reservation or snapshot. Pagination can race with registration and status changes between requests; stable `total_count` and unique runner IDs are fail-closed consistency checks, not - snapshot isolation. Several simultaneous selectors can observe the same idle - runner and select local; that burst can queue until capacity appears. Once all - matching runners report busy, later - selectors route hosted with `no-idle-runner`. Validation, authentication, + snapshot isolation. Several simultaneous selectors can observe the same + online runner and select local; GitHub queues that burst until capacity + appears. Only when no matching runner is online do later + selectors route hosted with `no-online-runner`. Validation, authentication, API, timeout, malformed-response, and github-script failures produce hosted outputs. A failure of the selector job or hosted runner before outputs exist cannot be converted by workflow expressions; dependent jobs remain blocked @@ -668,6 +675,7 @@ standards catalog. [pssa-1708]: https://github.com/PowerShell/PSScriptAnalyzer/issues/1708 [pulumi-oidc]: https://www.pulumi.com/docs/administration/access-identity/oidc-issuers/ [pulumi-stack-export]: https://www.pulumi.com/docs/iac/cli/commands/pulumi_stack_export/ +[runner-routing]: https://docs.github.com/en/actions/reference/runners/self-hosted-runners#routing-precedence-for-self-hosted-runners [runner-security]: https://docs.github.com/en/actions/reference/security/secure-use#hardening-for-self-hosted-runners [runner-pricing]: https://docs.github.com/en/billing/reference/actions-runner-pricing [runner-labels]: https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/apply-labels