ci: a stable release waits for a green E2E run on a real Home Assistant

The stable gate proved Validate and Full Performance on the exact SHA but
never ran the release in Home Assistant itself. houseplan-e2e installs
the release's houseplan.zip — the bytes HACS ships — into HA in docker
and walks the sidebar page, dashboards, roles, PDF, restart and the
stable→tag upgrade. release.yml now dispatches e2e.yml on the tag for
`!prerelease` releases and waits for it (scripts/e2e-gate.mjs, modelled
on validate-gate.mjs): the gate recognises its own run by `HP <tag>` in
the job names, ignores foreign dispatches, and reports red / missing /
cancelled / token error with the run link. Betas are untouched.

Mutants: release-ships-on-red-e2e, release-trusts-foreign-e2e-run.

Issue: #514
User-Visible: no
This commit is contained in:
Codex
2026-09-09 21:18:51 +00:00
committed by claude[bot]
parent d20ef60037
commit c50458a098
9 changed files with 309 additions and 3 deletions
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* E2E на реальном Home Assistant как гейт стабильного релиза (#514).
*
* Стабильный релиз проходил Validate и Full Performance на точном SHA, но ни
* разу не запускался в настоящем HA. Репозиторий houseplan-e2e ставит House
* Plan из `houseplan.zip` релиза — те же байты, что скачивает HACS, — и гоняет
* 13 сценариев Playwright. Этот скрипт запускает его workflow на теге и ждёт
* зелёного; `release.yml` вызывает его для `!prerelease` после Full Performance.
*
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
*
* Печатает `result=green|red|missing|error`, `url=…`, `note=…` (и в
* $GITHUB_OUTPUT), код выхода 0 только при green. Логика — чистая функция
* `e2eGate` поверх инъектируемых `ops` (образец — validate-gate.mjs, #510).
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
export const POLL_MS = 20_000;
export const E2E_REPO = 'Matysh/houseplan-e2e';
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';
/**
* Прогон — наш, если хотя бы одна job названа по нашему тегу: имя job в
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`. Сьют `upgrade` носит
* `upgrade_from` (stable), но `journeys`/`first-run` несут тег — этого
* достаточно, чтобы не принять чужой dispatch (владелец запустил другой тег).
*/
export function isOurRun(jobs, tag) {
const needle = ` · HP ${tag} · `;
return (Array.isArray(jobs) ? jobs : []).some((job) => String(job?.name || '').includes(needle));
}
/**
* @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() }
* @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);
} catch (error) {
const message = String(error?.message || error);
const forbidden = /403|Resource not accessible|not accessible by/i.test(message);
return { result: 'error', url: null, note: `запуск e2e.yml не удался: ${message}${forbidden ? ` — ${TOKEN_HINT}` : ''}` };
}
const foreign = new Set(); // dispatch-прогоны без нашего тега в именах job
let tracked = null;
while (ops.now() - started < totalMs) {
const runs = (await ops.listRuns()).filter((run) => !foreign.has(run.databaseId));
let run = tracked ? runs.find((x) => x.databaseId === tracked) : null;
if (!run) {
// Опознание: свежий dispatch, чьи job носят наш тег.
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);
}
}
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${tag} отменён вручную — перезапустите гейт` };
return { result: 'red', url: run.url, note: `E2E на ${tag} завершился: ${run.conclusion}` };
}
} else if (ops.now() - started > appearMs) {
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${tag} не появился за ${Math.round(appearMs / 60000)} мин` };
}
await ops.sleep(pollMs);
}
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${tag} не завершился за ${Math.round(totalMs / 60000)} мин` };
}
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, 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) => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
'-f', `houseplan_ref=${tag}`, '-f', 'upgrade_from=stable', '-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'])),
jobs: async (runId) => {
const r = exec('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']);
return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : [];
},
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
now: () => Date.now(),
};
}
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
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]');
process.exit(2);
}
const outcome = await e2eGate({ tag, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW }) });
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`);
process.exit(outcome.result === 'green' ? 0 : 1);
}
+22
View File
@@ -8214,6 +8214,28 @@ const MUTANT_DEFINITIONS = [
replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red",
}],
},
{
id: 'release-ships-on-red-e2e',
guard: 'node --test test/e2e-gate.test.mjs',
because: 'a stable release must wait for a green E2E on a real Home Assistant; a gate that reads '
+ 'a failed run as green ships the assets the run just rejected (#514 AC1)',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };",
replace: " return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` }; // mutant: completed means green",
}],
},
{
id: 'release-trusts-foreign-e2e-run',
guard: 'node --test test/e2e-gate.test.mjs',
because: 'the gate must follow the dispatch it made for this tag; accepting any dispatch run lets '
+ '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));",
replace: " return true; // mutant: every dispatch is ours",
}],
},
{
id: 'review-trusts-push-run-without-mutants',
guard: 'node --test test/validate-gate.test.mjs',