ci: the E2E gate recognises its run by the suites that install the tag

Two findings from the live run on v1.73.0 (houseplan-e2e run
34393136097): the upgrade job carries the previous stable's tag in its
name, so "any job with HP <tag>" let a gate for v1.72.0 adopt the
v1.73.0 run — recognition now keys on `journeys`/`first-run`; and the
first poll after a dispatch sees only the matrix-planning job, which
marked the run as foreign forever — a run without any `· HP … ·` job is
undecided and polled again. Live: v1.73.0 → green with the run link,
v1.72.0 → no run of its own.

Issue: #514
User-Visible: no
This commit is contained in:
Codex
2026-09-09 21:18:51 +00:00
committed by claude[bot]
parent 57b19f9914
commit 0796a01571
4 changed files with 42 additions and 11 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
Чистая функция `e2eGate({ tag, ops, appearMs, totalMs, pollMs })` поверх инъектируемых `ops` (образец — `validate-gate.mjs` #510) и `realOps({ repo: 'Matysh/houseplan-e2e', workflow: 'e2e.yml', token })` на `gh`.
1. `ops.dispatch(tag, upgradeFrom)` → `gh workflow run e2e.yml --repo Matysh/houseplan-e2e --ref main -f houseplan_ref=<tag> -f upgrade_from=<предыдущий stable> -f ha_version=stable`. `upgradeFrom` — новейший не-пре-релиз, не черновик, с тегом ≠ `<tag>` (`gh release list --repo Matysh/houseplan-card`); нет такого — `stable`. Уточнение после живого прогона в S6 (09.09): к моменту `release: published` сам тег — уже «stable», и `upgrade_from=stable` заставлял сьют `upgrade` обновлять v1.73.0 на v1.73.0 (`Expected: not "1.73.0"`). Ошибка запуска (403 — токен без `actions: write` на `houseplan-e2e`) → `result=error` с текстом «нужен секрет `E2E_DISPATCH_TOKEN` с правом Actions: write на houseplan-e2e»; ассет не публикуется.
2. Опознание своего прогона: `ops.listRuns()` (`gh run list --workflow e2e.yml --event workflow_dispatch --json databaseId,status,conclusion,url,createdAt --limit 10`) → кандидаты с `createdAt ≥ t0 − 60 с`; для каждого `ops.jobs(id)` — прогон **наш**, если хотя бы одна job называется `… · HP <tag> · …` (имя job в `e2e.yml` несёт `matrix.ref`). Первый подошедший — `tracked`; чужие dispatch (владелец запустил руками другой тег) игнорируются. Не появился за `appearMs` (3 мин) → `missing`.
2. Опознание своего прогона: `ops.listRuns()` (`gh run list --workflow e2e.yml --event workflow_dispatch --json databaseId,status,conclusion,url,createdAt --limit 10`) → кандидаты с `createdAt ≥ t0 − 60 с`; для каждого `ops.jobs(id)` — прогон **наш**, если job сьюта, ставящего сам тег (`journeys · HP <tag> · …` или `first-run · HP <tag> · …`), названа по нему (имя job в `e2e.yml` несёт `matrix.ref`; у `upgrade` это `upgrade_from` — тег предыдущего stable, по нему опознавать нельзя: гейт для v1.72.0 принял бы прогон v1.73.0 — живой прогон 09.09). Прогон, у которого пока видна только job «Матрица прогона», — не чужой, а ещё не решённый: опрос повторяется. Первый подошедший — `tracked`; чужие dispatch (владелец запустил руками другой тег) игнорируются. Не появился за `appearMs` (3 мин) → `missing`.
3. Ожидание завершения `tracked` до `totalMs` (45 мин), опрос каждые 20 с. `success` → `green`; `failure`/`timed_out` → `red`; `cancelled` → `red` с пометкой «отменён вручную» (concurrency-группа dispatch в `e2e.yml` не отменяет — `cancel-in-progress: false`, значит отмена рукотворная); таймаут → `red`.
4. CLI: `node scripts/e2e-gate.mjs --tag=<tag> [--repo=Matysh/houseplan-e2e]`, печатает `result=`, `url=`, `note=` (и в `$GITHUB_OUTPUT`), код выхода 0 только на `green`. Константы 3/45 мин — из `merge-candidate.mjs` (`VALIDATE_APPEAR_MS`, `VALIDATE_TOTAL_MS`).
+22 -8
View File
@@ -42,14 +42,27 @@ export function previousStable(releases, tag) {
}
/**
* Прогон — наш, если хотя бы одна job названа по нашему тегу: имя job в
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`. Сьют `upgrade` носит
* `upgrade_from` (stable), но `journeys`/`first-run` несут тег — этого
* достаточно, чтобы не принять чужой dispatch (владелец запустил другой тег).
* Прогон — наш, если сьют, ставящий сам тег, назван по нему: имя job в
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`, и у `journeys`/`first-run`
* `ref` — это `houseplan_ref`. Сьют `upgrade` носит `upgrade_from` — тег
* ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <tag>» приняла бы прогон нового
* релиза за прогон старого (живой прогон 09.09: v1.72.0 ← run для v1.73.0).
*/
export const TAG_SUITES = ['journeys', 'first-run'];
export function isOurRun(jobs, tag) {
const needle = ` · HP ${tag} · `;
return (Array.isArray(jobs) ? jobs : []).some((job) => String(job?.name || '').includes(needle));
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${tag} · `);
return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));
}
/**
* Прогон, у которого ещё нет ни одной job `· HP … ·`, решать рано: e2e.yml
* сначала планирует матрицу отдельной job, и первые секунды виден только
* «Матрица прогона». Живой прогон 09.09 записал такой run в чужие навсегда.
*/
export function classifyRun(jobs, tag) {
const named = (Array.isArray(jobs) ? jobs : []).filter((job) => / · HP .+ · /.test(String(job?.name || '')));
if (!named.length) return 'unknown';
return isOurRun(named, tag) ? 'ours' : 'foreign';
}
/**
@@ -77,8 +90,9 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
for (const candidate of runs) {
const createdAt = Date.parse(candidate.createdAt || '') || 0;
if (createdAt < started - CLOCK_SKEW_MS) continue;
if (isOurRun(await ops.jobs(candidate.databaseId), tag)) { run = candidate; break; }
foreign.add(candidate.databaseId);
const kind = classifyRun(await ops.jobs(candidate.databaseId), tag);
if (kind === 'ours') { run = candidate; break; }
if (kind === 'foreign' || candidate.status === 'completed') foreign.add(candidate.databaseId);
}
}
if (run) {
+1 -1
View File
@@ -8243,7 +8243,7 @@ const MUTANT_DEFINITIONS = [
+ 'a green run on another tag vouch for this release (#514 AC2)',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: " return (Array.isArray(jobs) ? jobs : []).some((job) => String(job?.name || '').includes(needle));",
find: " return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));",
replace: " return true; // mutant: every dispatch is ours",
}],
},
+18 -1
View File
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { e2eGate, isOurRun, previousStable, realOps, TOKEN_HINT } from '../scripts/e2e-gate.mjs';
import { classifyRun, e2eGate, isOurRun, previousStable, realOps, TOKEN_HINT } from '../scripts/e2e-gate.mjs';
const TAG = 'v1.74.0';
const ours = (suffix = '') => [{ name: `journeys · HP ${TAG} · HA stable${suffix}`, conclusion: 'success' }, { name: 'upgrade · HP stable · HA stable', conclusion: 'success' }];
@@ -34,6 +34,23 @@ test('#514 AC2: a run is ours only when a job carries our tag', () => {
assert.equal(isOurRun(theirs(), TAG), false);
assert.equal(isOurRun([{ name: `journeys · HP ${TAG}-beta.1 · HA stable` }], TAG), false, 'a prerelease of the same version is not the tag');
assert.equal(isOurRun([], TAG), false);
// live 09.09: the upgrade job carries the PREVIOUS stable's tag — a gate for v1.72.0 must not adopt the v1.73.0 run
assert.equal(isOurRun([{ name: 'journeys · HP v1.73.0 · HA stable' }, { name: 'upgrade · HP v1.72.0 · HA stable' }], 'v1.72.0'), false);
assert.equal(isOurRun([{ name: 'first-run · HP v1.73.0 · HA stable' }], 'v1.73.0'), true);
});
test('#514 AC2: a run that has only planned its matrix is undecided, not foreign', async () => {
assert.equal(classifyRun([{ name: 'Матрица прогона', conclusion: 'success' }], TAG), 'unknown');
assert.equal(classifyRun([{ name: 'Матрица прогона' }, ...ours()], TAG), 'ours');
assert.equal(classifyRun(theirs(), TAG), 'foreign');
// live 09.09: the first poll saw only the plan job — the run must still be recognised on the next poll
const planning = run({ databaseId: 5, status: 'in_progress', conclusion: null, url: 'https://e2e/run/5' });
let polls = 0;
const fake = fakeOps({ snapshots: [[planning], [planning], [run({ databaseId: 5, url: 'https://e2e/run/5' })]] });
fake.ops.jobs = async () => (polls++ === 0 ? [{ name: 'Матрица прогона', conclusion: 'success' }] : [{ name: 'Матрица прогона' }, ...ours()]);
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://e2e/run/5');
});
test('#514 AC1: dispatch, then the green run on the tag is accepted', async () => {