feat(ci): drive fleet by pattern var, filter by capability, soften day cap

Address review feedback on PR #16544:

- Read QWEN_REVIEW_FLEET_NAME_PATTERN from the environment so a region
  change is a var edit, not a code change. Default stays on the hz fleet.
- Filter the managed set by capability: a runner must carry the X64
  label (the architecture review jobs target) and must not carry a
  reserved label such as ecs-qwen-release-host. Without this, the day
  pool nominally sized at 40 actually has 24 usable slots because
  hz1-17..hz1-32 are ARM64-only, and the planner would also try to
  relabel the dedicated release hosts.
- Strict integer parsing for the day count: a /^\d+$/ gate before
  Number() so trailing garbage (4O, 40.9, 1e2) cannot silently coerce
  and shrink the daytime pool.
- Soft cap instead of hard fail when dayCount exceeds the online fleet:
  truncate to whatever is online and warn in the run summary. The
  previous behaviour left every runner in its previous pool for up to
  12 hours when a stale var overran reality.
- Restore the per-name "keeping on review" listing in the step summary
  so operators can see which hosts are in the day pool without re-
  running the planner by hand.
- Drop hk1/hk2 references from the schedule workflow and the review
  workflow's runs-on comment; the script no longer cares about regions.

Add an end-to-end day-mode CLI test that drives the planner through a
fake gh and asserts the label calls and the kept-names summary.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
yiliang114andQwen-Coder committed 2026-11-07 22:17:02 +08:00
1 parent 0d5ac6506b
commit 3929cb9c61
4 files changed
+384 -96

No files matched your search

+101 -45
View File
@@ -5,7 +5,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
// Switch all online hz1-hz6 runners between review and CI, twice a day.
// Switch the managed runner fleet between review and CI, twice a day.
// `day` keeps a configured count on `ecs-review` so reviews start during the
// daytime too, and returns the rest of the fleet to CI.
// Offline runners are skipped; their labels stay unchanged.
@@ -17,55 +17,79 @@ import { promisify } from 'node:util';
const execFile = promisify(execFileCallback);
const MANAGED_LABELS = ['ecs-review', 'ecs-qwen'];
// One literal for both the switch filter and the fleet guard below: the
// guard's whole job is to prove the filter will match something, so two
// copies that can drift would reintroduce the silent no-op it prevents.
const FLEET_NAME = /^ecs-qwen-hz[1-6]-\d+$/;
// Labels that mark a runner as dedicated to a non-review workload (release
// hosts, benchmarking, etc). They must never receive an `ecs-review` or
// `ecs-qwen` label, so the planner filters them out by name pattern.
const EXCLUDED_LABELS = new Set(['ecs-qwen-release-host']);
// Architecture the review workflow requires (`runs-on: [..., "x64"]`). A
// runner that lacks this label can never be assigned a review job, so it
// does not count toward daytime review capacity.
const REQUIRED_ARCH_LABEL = 'x64';
const MODES = ['review', 'day', 'ci'];
// Compile the fleet-name regex from configuration, never from a hardcoded
// region. Falling back to a restrictive default keeps a forgotten variable
// from silently widening the managed fleet on the next tick.
export function fleetNameFromEnv(env = process.env) {
const source =
env.QWEN_REVIEW_FLEET_NAME_PATTERN || '^ecs-qwen-hz[1-6]-\\d+$';
try {
return new RegExp(source);
} catch (error) {
throw new Error(
`QWEN_REVIEW_FLEET_NAME_PATTERN is not a valid regex: ${error.message}`,
);
}
}
// True when a runner is eligible to be managed by this schedule. Filters
// out: runners without the architecture the review workflow targets, and
// runners carrying a reserved label such as `ecs-qwen-release-host`.
export function isManagedRunner(runner, fleetName) {
if (!fleetName.test(runner.name)) return false;
const labels = runner.labels.map((l) => l.name.toLowerCase());
if (!labels.includes(REQUIRED_ARCH_LABEL)) return false;
return !labels.some((l) => EXCLUDED_LABELS.has(l));
}
// `dayCount` is how many runners stay on `ecs-review` in `day` mode. The
// fleet is sorted by name and the first `dayCount` online runners keep the
// label; every other managed runner then serves CI, so the daytime review
// capacity is exactly that count and never the whole fleet. Numeric sort
// (hz1-2 before hz1-10) keeps the pick deterministic across runs and across
// changes in runner count.
export function planLabels(runners, mode, dayCount = 0) {
// fleet is sorted by name and the first `dayCount` online managed runners
// keep the label; every other managed runner then serves CI, so the daytime
// review capacity is exactly that count and never the whole fleet. Numeric
// sort (hz1-2 before hz1-10) keeps the pick deterministic across runs and
// across changes in runner count.
export function planLabels(runners, mode, dayCount = 0, fleetName) {
const fleet = fleetName ?? fleetNameFromEnv();
if (!MODES.includes(mode)) {
throw new Error('mode must be review, day or ci');
}
const eligible = runners.filter((r) => isManagedRunner(r, fleet));
if (mode === 'day') {
if (!Number.isInteger(dayCount) || dayCount < 1) {
throw new Error(
'mode day needs a positive integer review-day runner count',
);
}
const online = runners.filter(
(r) => r.status === 'online' && FLEET_NAME.test(r.name),
);
// Loud cap: a count larger than the online fleet would silently shrink
// the daytime pool every morning. Better to fail the schedule and let
// the operator lower the var than to drift to whatever happens to be
// online.
if (dayCount > online.length) {
throw new Error(
`review-day runner count ${dayCount} exceeds online fleet of ${online.length}`,
);
}
}
const sorted = runners
.filter((r) => FLEET_NAME.test(r.name))
const sorted = eligible
.slice()
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
const onlineSorted = sorted.filter((r) => r.status === 'online');
// Soft cap: an over-large count truncates to whatever is online, with a
// warning the operator can read in the run summary. Hard-failing here
// would leave every runner in its previous pool (potentially the whole
// fleet on review at 05:00) until somebody notices.
const effectiveDayCount =
mode === 'day' ? Math.min(dayCount, onlineSorted.length) : 0;
const truncated = mode === 'day' && dayCount > onlineSorted.length;
const keep = new Set(
mode === 'day'
? sorted
.filter((r) => r.status === 'online')
.slice(0, dayCount)
.map((r) => r.name)
? onlineSorted.slice(0, effectiveDayCount).map((r) => r.name)
: [],
);
return runners
.filter((r) => r.status === 'online' && FLEET_NAME.test(r.name))
const actions = eligible
.filter((r) => r.status === 'online')
.flatMap((r) => {
const target =
mode === 'review' || keep.has(r.name) ? 'ecs-review' : 'ecs-qwen';
@@ -78,6 +102,14 @@ export function planLabels(runners, mode, dayCount = 0) {
? [{ id: r.id, name: r.name, add, remove }]
: [];
});
return {
actions,
keptNames: mode === 'day' ? [...keep] : [],
truncated,
requestedDayCount: dayCount,
effectiveDayCount,
onlineManaged: onlineSorted.length,
};
}
async function gh(args) {
@@ -95,22 +127,33 @@ async function main() {
'usage: review-runner-schedule.mjs <owner/repo> <review|day|ci> [review-day runner count]',
);
}
const dayCount = rest.length === 0 ? 0 : Number.parseInt(rest.join(' '), 10);
// Validate the mode (and the day count shape) before spending an
// authenticated call on it.
if (!MODES.includes(mode)) {
throw new Error('mode must be review, day or ci');
}
if (mode === 'day' && (!Number.isInteger(dayCount) || dayCount < 1)) {
throw new Error(
'mode day needs a positive integer review-day runner count',
);
// Strict integer parse: `Number.parseInt` accepts trailing garbage (`4O`,
// `40.9`, `1e2` all silently coerce), which would silently shrink the
// daytime pool — the exact property this var is supposed to defend.
const rawCount = rest.join(' ').trim();
let dayCount = 0;
if (mode === 'day') {
if (!/^\d+$/.test(rawCount)) {
throw new Error(
'mode day needs a positive integer review-day runner count',
);
}
dayCount = Number(rawCount);
if (!Number.isSafeInteger(dayCount) || dayCount < 1) {
throw new Error(
'mode day needs a positive integer review-day runner count',
);
}
}
if (!process.env.RUNNER_ADMIN_TOKEN) {
throw new Error(
'RUNNER_ADMIN_TOKEN is empty (needs Administration: write)',
);
}
const fleetName = fleetNameFromEnv();
const pages = JSON.parse(
await gh([
'api',
@@ -120,12 +163,14 @@ async function main() {
]),
);
const runners = pages.flatMap((page) => page.runners);
if (!runners.some((r) => FLEET_NAME.test(r.name))) {
throw new Error('no ecs-qwen-hz[1-6]-<n> runner found');
if (!runners.some((r) => isManagedRunner(r, fleetName))) {
throw new Error(
`no runner matches QWEN_REVIEW_FLEET_NAME_PATTERN (${fleetName.source})`,
);
}
const actions = planLabels(runners, mode, dayCount);
const plan = planLabels(runners, mode, dayCount, fleetName);
const results = await Promise.all(
actions.map(async ({ id, name, add, remove }) => {
plan.actions.map(async ({ id, name, add, remove }) => {
try {
// Add before removing. A failed POST leaves the host in its previous
// pool only; a failed DELETE leaves it in both. Neither leaves it with
@@ -161,11 +206,22 @@ async function main() {
}),
);
const summary = [
`### hz runner pool: ${mode}`,
`### runner pool: ${mode}`,
`- fleet pattern: \`${fleetName.source}\``,
...(mode === 'day'
? [`- keeping on review: first ${dayCount} runners`]
? [
`- keeping on review (${plan.effectiveDayCount}${
plan.truncated ? ` of requested ${plan.requestedDayCount}` : ''
}):`,
...plan.keptNames.map((name) => ` - ${name}`),
]
: []),
`- ${actions.length} runners to switch`,
...(plan.truncated
? [
`- ::warning::requested ${plan.requestedDayCount} day runners but only ${plan.onlineManaged} are online — pool truncated`,
]
: []),
`- ${plan.actions.length} runners to switch`,
...results.map((r) => r.message),
].join('\n');
console.log(summary);
+265 -39
View File
@@ -11,17 +11,86 @@ import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { planLabels } from './review-runner-schedule.mjs';
import {
fleetNameFromEnv,
isManagedRunner,
planLabels,
} from './review-runner-schedule.mjs';
const runner = (id, labels, extra = {}) => ({
id,
name: `ecs-qwen-hz2-${id}`,
labels: labels.map((name) => ({ name })),
status: 'online',
...extra,
});
const FLEET = /^ecs-qwen-hz[1-6]-\d+$/;
// Every fixture runner needs the architecture label the review workflow
// targets — without it `isManagedRunner` returns false and the planner
// skips the runner entirely.
const runner = (id, labels, extra = {}) => {
const { name, ...rest } = extra;
return {
id,
name: name ?? `ecs-qwen-hz2-${id}`,
labels: ['X64', ...labels].map((n) => ({ name: n })),
status: 'online',
...rest,
};
};
const plan = (runners, mode, dayCount = 0) =>
planLabels(runners, mode, dayCount, FLEET).actions;
describe('review runner schedule', () => {
describe('fleetNameFromEnv', () => {
it('falls back to the hz fleet when unset', () => {
assert.equal(fleetNameFromEnv({}).source, '^ecs-qwen-hz[1-6]-\\d+$');
});
it('honours an override and rejects an invalid regex', () => {
assert.equal(
fleetNameFromEnv({ QWEN_REVIEW_FLEET_NAME_PATTERN: '^foo-\\d+$' })
.source,
'^foo-\\d+$',
);
assert.throws(
() => fleetNameFromEnv({ QWEN_REVIEW_FLEET_NAME_PATTERN: '(' }),
/not a valid regex/,
);
});
});
describe('isManagedRunner', () => {
it('requires the review architecture label', () => {
// hz1-17..hz1-32 carry ARM64 only — they can never be assigned a
// review job whose runs-on includes "x64", so they must not count
// toward the daytime pool.
assert.equal(
isManagedRunner(
runner(1, ['ecs-qwen', 'ARM64'], {
// strip the default X64 to model the real host
}),
FLEET,
),
true,
);
const armOnly = {
id: 17,
name: 'ecs-qwen-hz1-17',
labels: ['self-hosted', 'Linux', 'ARM64', 'ecs-qwen'].map((n) => ({
name: n,
})),
status: 'online',
};
assert.equal(isManagedRunner(armOnly, FLEET), false);
});
it('excludes reserved-label hosts such as ecs-qwen-release-host', () => {
const releaseHost = {
id: 17,
name: 'ecs-qwen-hz1-17',
labels: ['self-hosted', 'Linux', 'X64', 'ecs-qwen-release-host'].map(
(n) => ({ name: n }),
),
status: 'online',
};
assert.equal(isManagedRunner(releaseHost, FLEET), false);
});
});
it('switches online hz1-hz6 runners, including busy runners', () => {
const runners = Array.from({ length: 32 }, (_, i) =>
runner(i + 1, ['ecs-qwen', 'ecs-autofix', 'diagnostic'], {
@@ -30,7 +99,7 @@ describe('review runner schedule', () => {
status: i < 2 ? 'offline' : 'online',
}),
);
const actions = planLabels(runners, 'review');
const actions = plan(runners, 'review');
const onlineRunners = runners.filter(({ status }) => status === 'online');
assert.equal(actions.length, 30);
for (const [i, action] of actions.entries()) {
@@ -49,7 +118,7 @@ describe('review runner schedule', () => {
it('returns every online review runner to CI and leaves unrelated runners alone', () => {
assert.deepEqual(
planLabels(
plan(
[
runner(1, ['ecs-review', 'diagnostic']),
runner(2, ['ecs-autofix'], { name: 'ecs-qwen-hz1-2' }),
@@ -78,19 +147,16 @@ describe('review runner schedule', () => {
it('is idempotent and rejects invalid modes', () => {
assert.deepEqual(
planLabels([runner(1, ['ecs-review', 'diagnostic'])], 'review'),
plan([runner(1, ['ecs-review', 'diagnostic'])], 'review'),
[],
);
assert.deepEqual(planLabels([runner(1, ['ecs-qwen'])], 'ci'), []);
assert.throws(
() => planLabels([], 'invalid'),
/mode must be review, day or ci/,
);
assert.deepEqual(plan([runner(1, ['ecs-qwen'])], 'ci'), []);
assert.throws(() => plan([], 'invalid'), /mode must be review, day or ci/);
});
it('keeps the first N runners on review and returns the rest to CI in day mode', () => {
assert.deepEqual(
planLabels(
plan(
[
runner(1, ['ecs-qwen', 'ecs-autofix'], { name: 'ecs-qwen-hz2-1' }),
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz2-2' }),
@@ -130,7 +196,7 @@ describe('review runner schedule', () => {
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz1-2' }),
runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hz1-1' }),
];
const actions = planLabels(runners, 'day', 2);
const actions = plan(runners, 'day', 2);
// Numeric sort puts hz1-1, hz1-2 in the keep set; hz1-10 falls outside
// and would go to CI — but it already has the CI label, so no action.
assert.deepEqual(actions, [
@@ -149,34 +215,44 @@ describe('review runner schedule', () => {
]);
});
it('is idempotent for day mode and rejects a count above the online fleet', () => {
it('truncates to the online fleet when dayCount exceeds it, and reports the pick', () => {
const result = planLabels(
[runner(1, ['ecs-qwen']), runner(2, ['ecs-qwen'])],
'day',
5,
FLEET,
);
// Truncation, not failure: every online managed runner goes on review
// and the summary flags the gap for the operator.
assert.equal(result.truncated, true);
assert.equal(result.requestedDayCount, 5);
assert.equal(result.effectiveDayCount, 2);
assert.equal(result.onlineManaged, 2);
assert.deepEqual(result.keptNames.sort(), [
'ecs-qwen-hz2-1',
'ecs-qwen-hz2-2',
]);
assert.equal(result.actions.length, 2);
});
it('is idempotent for day mode and validates the count', () => {
assert.deepEqual(
planLabels(
[runner(1, ['ecs-review'], { name: 'ecs-qwen-hz2-1' })],
'day',
1,
),
plan([runner(1, ['ecs-review'], { name: 'ecs-qwen-hz2-1' })], 'day', 1),
[],
);
// A stale var would otherwise silently shrink the daytime pool on every
// morning tick while the run stays green.
assert.throws(
() => planLabels([runner(1, ['ecs-qwen'])], 'day', 5),
/exceeds online fleet of 1/,
);
assert.throws(
() => planLabels([runner(1, ['ecs-qwen'])], 'day', 0),
() => plan([runner(1, ['ecs-qwen'])], 'day', 0),
/needs a positive integer review-day runner count/,
);
assert.throws(
() => planLabels([runner(1, ['ecs-qwen'])], 'day', Number.NaN),
() => plan([runner(1, ['ecs-qwen'])], 'day', Number.NaN),
/needs a positive integer review-day runner count/,
);
});
it('skips offline runners when picking the day pool', () => {
assert.deepEqual(
planLabels(
plan(
[
runner(1, ['ecs-review'], {
name: 'ecs-qwen-hz1-1',
@@ -202,6 +278,45 @@ describe('review runner schedule', () => {
);
});
it('excludes ARM64-only and reserved-label hosts from the day pool', () => {
const arm = (id) => ({
id,
name: `ecs-qwen-hz1-${id}`,
labels: ['self-hosted', 'Linux', 'ARM64', 'ecs-qwen'].map((n) => ({
name: n,
})),
status: 'online',
});
const releaseHost = {
id: 99,
name: 'ecs-qwen-hz1-99',
labels: ['self-hosted', 'Linux', 'X64', 'ecs-qwen-release-host'].map(
(n) => ({ name: n }),
),
status: 'online',
};
const x64 = (id) =>
runner(id, ['ecs-qwen'], { name: `ecs-qwen-hz1-${id}` });
// Sorted: hz1-1 (arm, skipped), hz1-2 (x64), hz1-3 (x64), hz1-17 (arm,
// skipped), hz1-99 (release-host, skipped). Day count 2 picks the two
// usable x64 hosts.
const result = planLabels(
[arm(1), x64(2), x64(3), arm(17), releaseHost],
'day',
2,
FLEET,
);
assert.deepEqual(result.keptNames.sort(), [
'ecs-qwen-hz1-2',
'ecs-qwen-hz1-3',
]);
assert.equal(result.onlineManaged, 2);
assert.equal(result.truncated, false);
// Reserved-label and ARM-only hosts must not be touched at all.
const ids = result.actions.map((a) => a.id).sort();
assert.deepEqual(ids, [2, 3]);
});
it('wires two daily UTC switches and manual pool selection', () => {
const schedule = readFileSync(
new URL('../workflows/qwen-review-runner-schedule.yml', import.meta.url),
@@ -225,14 +340,20 @@ describe('review runner schedule', () => {
assert.ok(schedule.includes('secrets.RUNNER_ADMIN_PAT'));
// The daytime pool. A configured count of runners stay on review after
// the 05:00 switch; an unset override falls back to 40, and a count that
// exceeds the online fleet fails the tick instead of shrinking the pool
// silently.
// exceeds the online fleet truncates with a warning rather than
// silently shrinking the pool or failing the whole tick.
assert.ok(schedule.includes("- 'day'"));
assert.match(
schedule,
/REVIEW_DAY_RUNNER_COUNT: .*\$\{\{ vars\.QWEN_REVIEW_DAY_RUNNER_COUNT \|\| '40' \}\}/,
);
assert.ok(schedule.includes('"$REVIEW_DAY_RUNNER_COUNT"'));
// The fleet pattern is configurable so a region change is a var edit,
// not a code change.
assert.match(
schedule,
/QWEN_REVIEW_FLEET_NAME_PATTERN: .*\$\{\{ vars\.QWEN_REVIEW_FLEET_NAME_PATTERN/,
);
// The in-repo half of the fence on the admin PAT. Losing it is silent:
// a dispatch from any other branch still runs, now executing that
// branch's copy of the planner with the token in its environment. The
@@ -264,13 +385,18 @@ if (!args.includes('--method')) {
);
};
const spawnPlanner = (dir, runners, { fail = false } = {}) =>
const spawnPlanner = (
dir,
runners,
{ fail = false, mode = 'review', dayCount = '' } = {},
) =>
spawnSync(
process.execPath,
[
fileURLToPath(new URL('./review-runner-schedule.mjs', import.meta.url)),
'example/repo',
'review',
mode,
...(mode === 'day' ? [dayCount] : []),
],
{
encoding: 'utf8',
@@ -319,7 +445,7 @@ it(
);
it(
'fails loudly when no hz runner exists instead of switching nothing',
'fails loudly when no managed runner exists instead of switching nothing',
{ skip: process.platform === 'win32' },
() => {
// Without the guard a renamed or empty fleet exits 0 after reporting
@@ -342,7 +468,7 @@ it(
assert.equal(result.status, 1, result.stderr);
assert.match(
result.stderr,
/::error::no ecs-qwen-hz\[1-6\]-<n> runner found/,
/::error::no runner matches QWEN_REVIEW_FLEET_NAME_PATTERN/,
);
assert.equal(readFileSync(log, 'utf8'), '');
}
@@ -351,3 +477,103 @@ it(
}
},
);
it(
'day mode end-to-end: applies keep-set labels and writes a kept-names summary',
{ skip: process.platform === 'win32' },
() => {
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
try {
const log = join(dir, 'calls');
writeFakeGh(dir);
writeFileSync(log, '');
const summaryPath = join(dir, 'summary.md');
const result = spawnSync(
process.execPath,
[
fileURLToPath(
new URL('./review-runner-schedule.mjs', import.meta.url),
),
'example/repo',
'day',
'1',
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: dir + delimiter + process.env.PATH,
RUNNER_ADMIN_TOKEN: 'test-only',
GITHUB_STEP_SUMMARY: summaryPath,
PROBE_LOG: log,
PROBE_FAIL: 'false',
PROBE_RUNNERS: JSON.stringify([
runner(1, ['ecs-qwen'], { name: 'ecs-qwen-hz1-1' }),
runner(2, ['ecs-qwen'], { name: 'ecs-qwen-hz1-2' }),
]),
},
},
);
assert.equal(result.status, 0, result.stderr);
// hz1-1 is the pick: POST ecs-review then DELETE ecs-qwen.
// hz1-2 already carries ecs-qwen and stays on CI: no calls.
assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n'), [
'POST',
'DELETE',
]);
const summary = readFileSync(summaryPath, 'utf8');
assert.match(summary, /keeping on review \(1\):/);
assert.match(summary, /ecs-qwen-hz1-1/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it(
'day mode rejects a non-numeric count before touching the API',
{ skip: process.platform === 'win32' },
() => {
const dir = mkdtempSync(join(tmpdir(), 'runner-switch-'));
try {
const log = join(dir, 'calls');
writeFakeGh(dir);
writeFileSync(log, '');
// `parseInt` would accept each of these as `4` — the strict regex
// rejects them so a typo cannot silently shrink the daytime pool.
for (const bad of ['4O', '4.5', '4 0', '1e2', 'abc', '']) {
const result = spawnSync(
process.execPath,
[
fileURLToPath(
new URL('./review-runner-schedule.mjs', import.meta.url),
),
'example/repo',
'day',
bad,
],
{
encoding: 'utf8',
env: {
...process.env,
PATH: dir + delimiter + process.env.PATH,
RUNNER_ADMIN_TOKEN: 'test-only',
GITHUB_STEP_SUMMARY: '',
PROBE_LOG: log,
PROBE_FAIL: 'false',
PROBE_RUNNERS: '[]',
},
},
);
assert.equal(result.status, 1, `expected ${bad} to fail`);
assert.match(
result.stderr,
/needs a positive integer review-day runner count/,
);
}
assert.equal(readFileSync(log, 'utf8'), '');
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
+3 -2
View File
@@ -500,8 +500,9 @@ jobs:
# (default: 360); must stay above QWEN_REVIEW_MAX_TIMEOUT_MINUTES so
# retry plus posting never hit the cap.
timeout-minutes: '${{ fromJSON(vars.QWEN_REVIEW_JOB_TIMEOUT_MINUTES) }}'
# The schedule switches hk1/hk2 to `ecs-review` at 17:00 Asia/Shanghai
# and back to CI at 05:00. Reviews queue while the pool is closed.
# The schedule switches the managed fleet to `ecs-review` at 17:00
# Asia/Shanghai and back to CI at 05:00. Reviews queue while the pool
# is closed.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-review"]'') || fromJSON(''["ubuntu-latest"]'') }}'
permissions:
contents: 'read'
@@ -1,17 +1,21 @@
name: 'Qwen Review Runner Schedule'
# Switch every online hz1-hz6 runner at 17:00 / 05:00 Asia/Shanghai.
# 17:00 puts the whole fleet on review; 05:00 returns it to CI but keeps
# QWEN_REVIEW_DAY_RUNNER_COUNT runners on review, so that many reviews can
# start during the daytime instead of queueing until the evening. GitHub
# cron uses UTC.
# Switch the managed runner fleet between review and CI at 17:00 / 05:00
# Asia/Shanghai. 17:00 puts the whole fleet on review; 05:00 returns it to
# CI but keeps QWEN_REVIEW_DAY_RUNNER_COUNT runners on review, so that many
# reviews can start during the daytime instead of queueing until the
# evening. GitHub cron uses UTC.
# Manual runs can select a pool explicitly.
# Running jobs finish; only the labels for the next job change.
#
# QWEN_REVIEW_DAY_RUNNER_COUNT is the number of online hz runners that stay
# on `ecs-review` in the `day` pool. Unset falls back to 40; the script fails
# loudly if the count exceeds the online fleet, so a stale var cannot
# silently shrink the daytime pool.
# QWEN_REVIEW_DAY_RUNNER_COUNT is the number of online managed runners that
# stay on `ecs-review` in the `day` pool. Unset falls back to 40; if the
# count exceeds the online fleet the pool truncates with a warning rather
# than failing the whole tick.
#
# QWEN_REVIEW_FLEET_NAME_PATTERN is the regex source that decides which
# runners this schedule manages. Unset falls back to `^ecs-qwen-hz[1-6]-\d+$`.
# Change it to onboard or retire a region without touching the script.
#
# RUNNER_ADMIN_PAT needs runner-label write access: repo scope for a
# classic PAT, or repository Administration: write for a fine-grained PAT.
@@ -25,7 +29,7 @@ on:
workflow_dispatch:
inputs:
pool:
description: 'Pool for all online hk1/hk2 runners'
description: 'Pool for the managed runner fleet'
type: 'choice'
required: true
default: 'ci'
@@ -71,5 +75,6 @@ jobs:
RUNNER_ADMIN_TOKEN: '${{ secrets.RUNNER_ADMIN_PAT }}'
POOL: "${{ github.event_name == 'workflow_dispatch' && inputs.pool || (github.event.schedule == '0 9 * * *' && 'review' || 'day') }}"
REVIEW_DAY_RUNNER_COUNT: "${{ vars.QWEN_REVIEW_DAY_RUNNER_COUNT || '40' }}"
QWEN_REVIEW_FLEET_NAME_PATTERN: "${{ vars.QWEN_REVIEW_FLEET_NAME_PATTERN || '^ecs-qwen-hz[1-6]-\\d+$' }}"
run: |
node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL" "$REVIEW_DAY_RUNNER_COUNT"