From 0796a015711ec2b8c091da300e1a33e3d0be9ee6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 22:17:10 +0300 Subject: [PATCH] ci: the E2E gate recognises its run by the suites that install the tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- docs/specs/514-e2e-stable-release-gate.md | 2 +- scripts/e2e-gate.mjs | 30 +++++++++++++++++------ scripts/mutation-gate.mjs | 2 +- test/e2e-gate.test.mjs | 19 +++++++++++++- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/specs/514-e2e-stable-release-gate.md b/docs/specs/514-e2e-stable-release-gate.md index 22e546f1..30795778 100755 --- a/docs/specs/514-e2e-stable-release-gate.md +++ b/docs/specs/514-e2e-stable-release-gate.md @@ -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= -f upgrade_from=<предыдущий stable> -f ha_version=stable`. `upgradeFrom` — новейший не-пре-релиз, не черновик, с тегом ≠ `` (`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 · …` (имя 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 · …` или `first-run · HP · …`), названа по нему (имя 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= [--repo=Matysh/houseplan-e2e]`, печатает `result=`, `url=`, `note=` (и в `$GITHUB_OUTPUT`), код выхода 0 только на `green`. Константы 3/45 мин — из `merge-candidate.mjs` (`VALIDATE_APPEAR_MS`, `VALIDATE_TOTAL_MS`). diff --git a/scripts/e2e-gate.mjs b/scripts/e2e-gate.mjs index 5f463fcd..9abec3f1 100755 --- a/scripts/e2e-gate.mjs +++ b/scripts/e2e-gate.mjs @@ -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 » приняла бы прогон нового + * релиза за прогон старого (живой прогон 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) { diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 22f653e4..21028c59 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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", }], }, diff --git a/test/e2e-gate.test.mjs b/test/e2e-gate.test.mjs index 3b2de429..e2145961 100755 --- a/test/e2e-gate.test.mjs +++ b/test/e2e-gate.test.mjs @@ -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 () => {