ci: the E2E gate fails loudly when the release list cannot be read

Code review r3 (M1): realOps.releases() swallowed a failing `gh release
list` into an empty list, so a fine-grained token scoped to houseplan-e2e
alone would have dispatched with upgrade_from=stable — the tag onto
itself — and the red run would look like the bug 4143f998 already fixed.
The call now throws like dispatch() and lands in the same catch: result
`error` with the token hint, which now names both repositories. L4:
PROCESS.md says who dispatches and who waits.

Issue: #514
User-Visible: no
This commit is contained in:
Codex
2026-09-09 21:18:51 +00:00
committed by claude[bot]
parent ed6d1d76db
commit 0ce5e4eed3
3 changed files with 24 additions and 5 deletions
+3 -3
View File
@@ -701,9 +701,9 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant
на теге (`houseplan-e2e`, запускает и ждёт `release.yml`, #514); статусов
issue не касается.
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
зелёного (#514); статусов issue не касается.
---
+9 -2
View File
@@ -25,7 +25,7 @@ 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';
export const TOKEN_HINT = 'нужен секрет E2E_DISPATCH_TOKEN: Actions: write на houseplan-e2e И чтение релизов houseplan-card (fine-grained PAT — оба репозитория в списке)';
export const CARD_REPO = 'Matysh/houseplan-card';
/**
@@ -74,6 +74,9 @@ export function classifyRun(jobs, tag) {
export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
const started = ops.now();
try {
// Список релизов читается ДО dispatch и обязан падать громко (ревью r3 M1):
// fine-grained токен «только houseplan-e2e» не видит houseplan-card, и
// тихий пустой список дал бы upgrade_from=stable — тег сам на себя.
await ops.dispatch(tag, previousStable(await ops.releases(), tag));
} catch (error) {
const message = String(error?.message || error);
@@ -116,7 +119,11 @@ export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = C
const fields = 'databaseId,status,conclusion,url,createdAt';
const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []);
return {
releases: async () => parse(exec('gh', ['release', 'list', '--repo', cardRepo, '--json', 'tagName,isDraft,isPrerelease', '--limit', '30'])),
releases: async () => {
const r = exec('gh', ['release', 'list', '--repo', cardRepo, '--json', 'tagName,isDraft,isPrerelease', '--limit', '30']);
if (r.status !== 0) throw new Error(`gh release list ${cardRepo}: ${(r.stderr || r.stdout || '').trim()}`);
return r.stdout ? JSON.parse(r.stdout) : [];
},
dispatch: async (tag, upgradeFrom = 'stable') => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
'-f', `houseplan_ref=${tag}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
+12
View File
@@ -114,6 +114,18 @@ 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 r3 M1: a token that cannot read houseplan-card releases fails loudly with the token hint, never dispatches with upgrade_from=stable', async () => {
const fake = fakeOps({ snapshots: [[]] });
fake.ops.releases = async () => { throw new Error('gh release list Matysh/houseplan-card: HTTP 404: Not Found'); };
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'error');
assert.deepEqual(fake.dispatched, [], 'no dispatch on a broken release list');
assert.ok(outcome.note.includes('gh release list'), outcome.note);
// realOps: a failing gh is an exception, not an empty list
const exec = () => ({ status: 1, stdout: '', stderr: 'HTTP 403: Resource not accessible by personal access token' });
await assert.rejects(() => realOps({ exec }).releases(), /403/);
});
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 },