ci: the E2E gate upgrades from the previous stable, not from the tag under test

Live run on v1.73.0 (houseplan-e2e run 34392391382): at `release:
published` the new tag is already the newest stable, so
`upgrade_from=stable` made the upgrade suite update v1.73.0 onto itself
and fail with `Expected: not "1.73.0"`. The gate now resolves the newest
non-prerelease, non-draft release other than the tag from `gh release
list` and passes it as `upgrade_from`; the first stable ever falls back
to `stable`. Spec §4/§6 record the change and the matrix-planning job in
houseplan-e2e (a job-level `if` cannot read `matrix.*`).

Mutant: release-upgrades-stable-onto-itself.

Issue: #514
User-Visible: no
This commit is contained in:
Codex
2026-09-09 21:18:51 +00:00
committed by claude[bot]
parent c50458a098
commit 57b19f9914
4 changed files with 59 additions and 17 deletions
+4 -4
View File
@@ -35,7 +35,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)` → `gh workflow run e2e.yml --repo Matysh/houseplan-e2e --ref main -f houseplan_ref=<tag> -f upgrade_from=stable -f ha_version=stable`. Ошибка запуска (403 — токен без `actions: write` на `houseplan-e2e`) → `result=error` с текстом «нужен секрет `E2E_DISPATCH_TOKEN` с правом Actions: write на houseplan-e2e»; ассет не публикуется.
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`.
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`).
@@ -59,13 +59,13 @@ Job `gate`, после «Require full performance for a stable release», с т
## 6. `houseplan-e2e/e2e.yml`
Сьют `journeys-dev` (снимок `dev`) не относится к тегу и может краснеть по причинам, не связанным со stable: job получает `if: matrix.suite != 'journeys-dev' || github.event_name == 'schedule'`. Остальные три сьюта на dispatch с `houseplan_ref=<tag>`: `journeys` и `first-run` — тег, `upgrade` — со stable (предыдущий) на тег. Отдельный коммит в `houseplan-e2e` (там процесс не ведётся; ссылка на коммит — в хендоффе).
Сьют `journeys-dev` (снимок `dev`) не относится к тегу и может краснеть по причинам, не связанным со stable: job получает `if: matrix.suite != 'journeys-dev' || github.event_name == 'schedule'`. Остальные три сьюта на dispatch с `houseplan_ref=<tag>`: `journeys` и `first-run` — тег, `upgrade` — с предыдущего stable (`upgrade_from` вычисляет гейт, §4 п.1) на тег. Условие «только по расписанию» для `journeys-dev` реализуется job `plan`, собирающей матрицу: job-level `if` не читает `matrix.*` (первая правка упала на парсинге workflow). Отдельный коммит в `houseplan-e2e` (там процесс не ведётся; ссылка на коммит — в хендоффе).
## 7. Тесты и мутанты
- `test/e2e-gate.test.mjs` (новый, fake ops с снимками и `jobsById`): dispatch и ожидание; чужой dispatch без `HP <tag>` в именах job игнорируется, свой отслеживается; red на failure; missing по `appearMs`; red по `totalMs`; `cancelled` → red с пометкой; ошибка dispatch → `error` с текстом про секрет.
- `test/release-workflow.test.mjs` (новый): шаг есть, стоит после Full Performance, условие `!prerelease`, токен с фолбэком, вызывает `scripts/e2e-gate.mjs --tag`.
- Мутанты (`scripts/mutation-gate.mjs`, гард `node --test test/e2e-gate.test.mjs`): `release-ships-on-red-e2e` (failure читается как green), `release-trusts-foreign-e2e-run` (опознание по имени job снято — любой dispatch считается своим). Каждый — отрицательным прогоном штатным раннером.
- Мутанты (`scripts/mutation-gate.mjs`, гард `node --test test/e2e-gate.test.mjs`): `release-ships-on-red-e2e` (failure читается как green), `release-trusts-foreign-e2e-run` (опознание по имени job снято — любой dispatch считается своим), `release-upgrades-stable-onto-itself` (`upgrade_from` всегда `stable`). Каждый — отрицательным прогоном штатным раннером.
## 8. Документация
@@ -81,7 +81,7 @@ Job `gate`, после «Require full performance for a stable release», с т
- AC2. Гейт опознаёт **свой** прогон по `HP <tag>` в именах job и не принимает чужой dispatch (тест + мутант `release-trusts-foreign-e2e-run`).
- AC3. Пре-релизы (`prerelease: true`) шаг не выполняют (условие в yml, тест).
- AC4. `journeys-dev` не бежит на dispatch (коммит в houseplan-e2e, ссылка в хендоффе; проверка — dispatch e2e.yml на `v1.73.0` показывает 3 job).
- AC5. Оба мутанта §7 пойманы штатным раннером.
- AC5. Все три мутанта §7 пойманы штатным раннером.
- AC6. Документы §8 обновлены; `User-Visible: no`; UX/i18n/модель данных/перф не затронуты.
## 10.0. UX, модель данных, i18n
+22 -7
View File
@@ -26,6 +26,20 @@ export const E2E_WORKFLOW = 'e2e.yml';
/** Допуск на расхождение часов раннера и GitHub при отборе «свежих» прогонов. */
export const CLOCK_SKEW_MS = 60_000;
export const TOKEN_HINT = 'нужен секрет E2E_DISPATCH_TOKEN с правом Actions: write на houseplan-e2e';
export const CARD_REPO = 'Matysh/houseplan-card';
/**
* Откуда обновляться в сьюте `upgrade`: предыдущий stable, не `stable`.
* К моменту события `release: published` новый тег — уже самый свежий
* не-пре-релиз, и `upgrade_from=stable` означало бы «обновиться с v1.74.0 на
* v1.74.0» — сьют честно краснеет (`Expected: not "1.73.0"`, живой прогон
* 09.09). Первый stable в истории обновляться неоткуда — тогда `stable`.
*/
export function previousStable(releases, tag) {
const prior = (Array.isArray(releases) ? releases : [])
.filter((r) => r && !r.isDraft && !r.isPrerelease && r.tagName && r.tagName !== tag);
return prior[0]?.tagName || 'stable';
}
/**
* Прогон — наш, если хотя бы одна job названа по нашему тегу: имя job в
@@ -41,13 +55,13 @@ export function isOurRun(jobs, tag) {
/**
* @param {object} p
* @param {string} p.tag тег релиза (houseplan_ref для e2e.yml)
* @param {object} p.ops { dispatch(tag), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(tag, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
* @returns {Promise<{result:'green'|'red'|'missing'|'error', url:string|null, note:string}>}
*/
export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
const started = ops.now();
try {
await ops.dispatch(tag);
await ops.dispatch(tag, previousStable(await ops.releases(), tag));
} catch (error) {
const message = String(error?.message || error);
const forbidden = /403|Resource not accessible|not accessible by/i.test(message);
@@ -84,13 +98,14 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, exec = sh } = {}) {
export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = CARD_REPO, exec = sh } = {}) {
const fields = 'databaseId,status,conclusion,url,createdAt';
const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []);
return {
dispatch: async (tag) => {
releases: async () => parse(exec('gh', ['release', 'list', '--repo', cardRepo, '--json', 'tagName,isDraft,isPrerelease', '--limit', '30'])),
dispatch: async (tag, upgradeFrom = 'stable') => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
'-f', `houseplan_ref=${tag}`, '-f', 'upgrade_from=stable', '-f', 'ha_version=stable']);
'-f', `houseplan_ref=${tag}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
if (r.status !== 0) throw new Error(`gh workflow run: ${(r.stderr || r.stdout || '').trim()}`);
},
listRuns: async () => parse(exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'workflow_dispatch', '--json', fields, '--limit', '10'])),
@@ -108,10 +123,10 @@ if (invokedDirectly) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const tag = arg('tag');
if (!tag) {
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]');
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
process.exit(2);
}
const outcome = await e2eGate({ tag, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW }) });
const outcome = await e2eGate({ tag, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
+11
View File
@@ -8225,6 +8225,17 @@ const MUTANT_DEFINITIONS = [
replace: " return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` }; // mutant: completed means green",
}],
},
{
id: 'release-upgrades-stable-onto-itself',
guard: 'node --test test/e2e-gate.test.mjs',
because: 'at release time the tag under test is already the newest stable, so upgrade_from=stable '
+ 'makes the upgrade suite update a version onto itself and go red (#514, live run 09.09)',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: " return prior[0]?.tagName || 'stable';",
replace: " return 'stable'; // mutant: always the newest stable, i.e. the tag itself",
}],
},
{
id: 'release-trusts-foreign-e2e-run',
guard: 'node --test test/e2e-gate.test.mjs',
+22 -6
View File
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { e2eGate, isOurRun, realOps, TOKEN_HINT } from '../scripts/e2e-gate.mjs';
import { 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' }];
@@ -15,7 +15,8 @@ function fakeOps({ snapshots, jobsById = {}, dispatchError = null, startedAt = 1
const dispatched = [];
return {
ops: {
dispatch: async (tag) => { if (dispatchError) throw new Error(dispatchError); dispatched.push(tag); },
releases: async () => [{ tagName: TAG, isDraft: false, isPrerelease: false }, { tagName: 'v1.74.0-beta.2', isPrerelease: true }, { tagName: 'v1.73.0', isDraft: false, isPrerelease: false }],
dispatch: async (tag, upgradeFrom) => { if (dispatchError) throw new Error(dispatchError); dispatched.push([tag, upgradeFrom]); },
listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; },
jobs: async (id) => jobsById[id] ?? [],
sleep: async (ms) => { clock += ms; },
@@ -40,7 +41,7 @@ test('#514 AC1: dispatch, then the green run on the tag is accepted', async () =
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.equal(outcome.url, 'https://e2e/run/1');
assert.deepEqual(fake.dispatched, [TAG], 'exactly one dispatch with the release tag');
assert.deepEqual(fake.dispatched, [[TAG, 'v1.73.0']], 'exactly one dispatch with the release tag, upgrading from the previous stable');
});
test('#514 AC1: a red run withholds the assets and names the run', async () => {
@@ -96,13 +97,28 @@ test('#514 AC1: a dispatch refused by the token is an error that names the missi
assert.equal(fake.calls(), 0, 'no polling after a failed dispatch');
});
test('#514: realOps dispatches e2e.yml on main with the tag and the stable baseline', async () => {
test('#514: the upgrade suite starts from the previous stable, never from the tag under test', () => {
const releases = [
{ tagName: 'v1.74.0', isDraft: false, isPrerelease: false },
{ tagName: 'v1.74.0-beta.3', isDraft: false, isPrerelease: true },
{ tagName: 'v1.73.0', isDraft: false, isPrerelease: false },
{ tagName: 'v1.72.0', isDraft: false, isPrerelease: false },
];
assert.equal(previousStable(releases, 'v1.74.0'), 'v1.73.0', 'the tag itself is already the newest stable at release time');
assert.equal(previousStable(releases, 'v1.73.0'), 'v1.74.0', 'a re-gated older tag still upgrades from another stable');
assert.equal(previousStable([{ tagName: 'v1.0.0', isPrerelease: false }], 'v1.0.0'), 'stable', 'the first stable ever falls back to stable');
assert.equal(previousStable([{ tagName: 'v1.74.0', isDraft: true, isPrerelease: false }, { tagName: 'v1.73.0', isPrerelease: false }], 'v1.75.0'), 'v1.73.0', 'drafts are not releases');
});
test('#514: realOps dispatches e2e.yml on main with the tag and the previous stable', async () => {
const calls = [];
const exec = (cmd, args) => { calls.push([cmd, ...args]); return { status: 0, stdout: '[]', stderr: '' }; };
const ops = realOps({ exec });
await ops.dispatch(TAG);
await ops.dispatch(TAG, 'v1.73.0');
assert.deepEqual(calls[0], ['gh', 'workflow', 'run', 'e2e.yml', '--repo', 'Matysh/houseplan-e2e', '--ref', 'main',
'-f', `houseplan_ref=${TAG}`, '-f', 'upgrade_from=stable', '-f', 'ha_version=stable']);
'-f', `houseplan_ref=${TAG}`, '-f', 'upgrade_from=v1.73.0', '-f', 'ha_version=stable']);
await ops.listRuns();
assert.ok(calls[1].includes('--event') && calls[1].includes('workflow_dispatch'));
await ops.releases();
assert.deepEqual(calls[2].slice(0, 5), ['gh', 'release', 'list', '--repo', 'Matysh/houseplan-card']);
});