mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
ci: a cancelled Validate dispatch proves nothing to the review gate
Code review r1 (M1): validate-gate.mjs and merge-candidate's waitValidate read a `cancelled` dispatch run on the material as red, so a dispatch replaced by the next one in the `validate-dispatch-<ref>` concurrency group would have returned the task S7→S6 for nothing — the same class #511 fixed in release-gate.mjs. Cancelled runs are now ignored: the gate follows the replacement dispatch, or starts its own when there is none. Mutant: review-returns-task-on-cancelled-dispatch. Issue: #510 User-Visible: no
This commit is contained in:
@@ -58,7 +58,7 @@ CLI: `node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sh
|
||||
Алгоритм (`ops` инъекция как в `merge-candidate.mjs`):
|
||||
|
||||
1. `gh run list --workflow validate.yml --commit <sha> --json databaseId,status,conclusion,url,event,createdAt --limit 20`.
|
||||
2. Кандидат в доказательства — `event == 'workflow_dispatch'` (только там мутанты могли быть запрошены). Завершённый не-success → `red`. Завершённый success — доказательство **только если** job «Мутанты по диффу» в нём исполнены и зелёные (`gh run view --json jobs`: есть ≥1 job с таким префиксом и все `success`); зелёный dispatch со `skipped` мутантами (чужой запуск с `mutants=false`) не доказательство — он игнорируется, и гейт запускает свой (ревью ТЗ r1, Medium 2). Незавершённый → ждать его.
|
||||
2. Кандидат в доказательства — `event == 'workflow_dispatch'` (только там мутанты могли быть запрошены). Завершённый не-success → `red`. Завершённый success — доказательство **только если** job «Мутанты по диффу» в нём исполнены и зелёные (`gh run view --json jobs`: есть ≥1 job с таким префиксом и все `success`); зелёный dispatch со `skipped` мутантами (чужой запуск с `mutants=false`) не доказательство — он игнорируется, и гейт запускает свой (ревью ТЗ r1, Medium 2). Незавершённый → ждать его. Отменённый (`cancelled`) не доказывает ничего (#511, ревью кода r1 M1): его заменил следующий dispatch в той же concurrency-группе — гейт ждёт замену, а без неё запускает свой; то же в `waitValidate` слияния.
|
||||
3. Нет подходящего → `gh workflow run validate.yml --ref <ref> -f full=false -f mutants=true`; затем ждать появления dispatch-прогона на `<sha>` до `VALIDATE_APPEAR_MS` (3 мин; используются константы `merge-candidate.mjs`). Если голова ветки за это время сменилась (появился dispatch-прогон на другом SHA) — `missing` с пояснением «материал сменился».
|
||||
4. Ждать завершения до `VALIDATE_TOTAL_MS` (45 мин), опрос каждые 20 с; таймаут → `red` («не завершился за 45 минут»).
|
||||
|
||||
|
||||
@@ -140,7 +140,9 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
|
||||
while (now() - started < VALIDATE_TOTAL_MS) {
|
||||
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
|
||||
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
|
||||
const runs = all.filter((x) => !event || x.event === event);
|
||||
// Отменённый прогон ничего не доказывает (#511): его заменил следующий
|
||||
// dispatch на той же ветке — ждём его, а не красим кандидата.
|
||||
const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
|
||||
const run = runs.find((x) => x.databaseId === runId) || runs[0];
|
||||
if (run) {
|
||||
runId = run.databaseId;
|
||||
|
||||
@@ -8147,6 +8147,17 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " // mutant: completed means green — a red dispatch falls through to the job check",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-returns-task-on-cancelled-dispatch',
|
||||
guard: 'node --test test/validate-gate.test.mjs',
|
||||
because: 'a dispatch cancelled by its replacement in the same concurrency group proves nothing; '
|
||||
+ 'reading it as red sends the task back to S6 for no reason (#510 review r1 M1, #511)',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " if (run.conclusion === 'cancelled') {",
|
||||
replace: " if (false) { // mutant: cancelled counts as red",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-trusts-push-run-without-mutants',
|
||||
guard: 'node --test test/validate-gate.test.mjs',
|
||||
|
||||
@@ -48,7 +48,7 @@ export function provesMutants(jobs) {
|
||||
*/
|
||||
export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||||
const started = ops.now();
|
||||
const ignored = new Set(); // завершённые зелёные dispatch без исполненных мутантов
|
||||
const ignored = new Set(); // завершённые dispatch, которые ничего не доказывают: отменённые и зелёные без мутантов
|
||||
let tracked = null;
|
||||
let dispatchedAt = null;
|
||||
while (ops.now() - started < totalMs) {
|
||||
@@ -57,6 +57,14 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
|
||||
if (run) {
|
||||
tracked = run.databaseId;
|
||||
if (run.status === 'completed') {
|
||||
if (run.conclusion === 'cancelled') {
|
||||
// Отменённый прогон ничего не доказывает (#511, ревью r1 M1): его
|
||||
// заменил другой dispatch в той же concurrency-группе — ждём его,
|
||||
// а если замены нет, запускаем свой.
|
||||
ignored.add(run.databaseId);
|
||||
tracked = null;
|
||||
continue;
|
||||
}
|
||||
if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };
|
||||
if (provesMutants(await ops.jobs(run.databaseId))) return { result: 'green', url: run.url, note: 'dispatch-прогон с исполненными мутантами зелёный' };
|
||||
// зелёный, но мутанты не исполнялись (чужой dispatch без mutants=true) — не доказательство
|
||||
|
||||
@@ -105,3 +105,21 @@ test('#510: the tracked dispatch run is followed even when a newer dispatch appe
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.equal(outcome.url, 'https://run/1');
|
||||
});
|
||||
|
||||
test('#510 r1 M1: a cancelled dispatch proves nothing — the gate waits for its replacement instead of returning the task', async () => {
|
||||
const cancelled = run({ databaseId: 1, conclusion: 'cancelled', url: 'https://run/cancelled' });
|
||||
const replacement = run({ databaseId: 2, url: 'https://run/2' });
|
||||
const fake = fakeOps({ snapshots: [[cancelled], [replacement, cancelled]] });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.equal(outcome.url, 'https://run/2');
|
||||
assert.deepEqual(fake.dispatched, [], 'the replacement was already there — no extra dispatch');
|
||||
});
|
||||
|
||||
test('#510 r1 M1: a cancelled dispatch with no replacement gets one dispatch, not a red verdict', async () => {
|
||||
const cancelled = run({ databaseId: 1, conclusion: 'cancelled' });
|
||||
const fake = fakeOps({ snapshots: [[cancelled], [cancelled], [run({ databaseId: 3, url: 'https://run/3' }), cancelled]] });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.deepEqual(fake.dispatched, ['issue/1']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user