fix(ci): стабилизировать релизный proof на main (#619)

Issue: #619
User-Visible: no
This commit is contained in:
Claude
2026-09-23 06:09:08 +03:00
parent 0e0a0e54e5
commit 0d047450ce
7 changed files with 67 additions and 23 deletions
+13 -11
View File
@@ -243,7 +243,7 @@ jobs:
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
base: ${{ steps.base.outputs.base }}
# `base` — до какого коммита классифицировать файлы ветки (#387),
# `range_base` — от какого судить диапазон на dev (#388).
# `range_base` — от какого судить диапазон на dev/main (#388, #619).
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
@@ -283,9 +283,10 @@ jobs:
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$BRANCH" -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
if [ "$REF" = "refs/heads/dev" ]; then
# На dev классифицировать нечего (всё true), но база диапазона
# нужна гейту «новый код не добавляет any» в job frontend (#388).
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
# На интеграционных ветках классифицировать нечего (всё true), но
# база диапазона нужна гейту «новый код не добавляет any» в job
# frontend (#388, #619).
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
exit 0
@@ -302,8 +303,8 @@ jobs:
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
echo "dev/main: без фильтров, всё true"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -713,11 +714,12 @@ jobs:
git fetch -q origin dev
zero=$(printf '%040d' 0)
base=""
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
# его прогон штатно отменяется следующим, и добавленные им строки не
# судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
# Пуш прямо в dev/main: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388, #619), а не с головы предыдущего
# пуша: его прогон штатно отменяется следующим, и добавленные им
# строки не судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] \
&& { [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; }; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
+3 -2
View File
@@ -509,8 +509,9 @@ then a baseline-only commit that reuses smoke, performance smoke, parity and
backend from the candidate's green jobs, skips every caught witness in the
mutation ledger and re-runs golden, preflight and frontend only. Review, merge and release use the
same `missing` / `pending` / `cancelled` / `stale` / `failed` state machine. A
cancelled or light run is not a release verdict and cannot hide an older full
failure; a later complete full proof can refresh it (#511). The release also requires Full
cancelled or light run is not a release verdict. Any complete green full proof on the exact SHA
is sufficient: its content-addressed evidence remains valid
even when a later duplicate run fails (#511, #619). The release also requires Full
Performance and a green E2E run on a
real Home Assistant — `e2e-gate.mjs --ref=<sha>` dispatches `e2e.yml` in
`Matysh/houseplan-e2e` on the **candidate commit**, whose
+11 -5
View File
@@ -355,12 +355,18 @@ export function evaluateCiProof({
return result('green', `${policy?.name || 'consumer'} proof is complete`);
}
/** Newest relevant proof wins; cancelled and policy-inadequate stale runs do not. */
/**
* A complete green proof is content-addressed evidence for the candidate and
* remains valid regardless of a later duplicate run (#619). When no green
* proof exists, keep the newest decisive state so failures still fail closed.
*/
export function selectCiProofVerdict(evaluations) {
for (const item of evaluations || []) {
if (item?.status === 'cancelled' || item?.status === 'stale') continue;
return item;
}
const relevant = (evaluations || []).filter(
(item) => item?.status !== 'cancelled' && item?.status !== 'stale',
);
const green = relevant.find((item) => item?.status === 'green');
if (green) return green;
if (relevant.length) return relevant[0];
return { status: 'missing', note: 'no run carries a proof for the requested policy', url: null };
}
+5 -1
View File
@@ -76,7 +76,11 @@ export async function classifyValidateProofs({
}
}
const current = evaluations.at(-1);
if (current.status !== 'cancelled' && current.status !== 'stale') break;
// #619: a complete proof is immutable evidence for this exact SHA/tree.
// A later duplicate may fail for workflow topology rather than product
// content, so only a green proof ends the search; failures remain the
// fallback verdict when no run proves the candidate green.
if (current.status === 'green') break;
}
return selectCiProofVerdict(evaluations);
}
+2
View File
@@ -133,6 +133,8 @@ test('#541 AC: full red followed by light green still blocks release; a later fu
assert.equal(selectCiProofVerdict([light, red]).status, 'failed');
const newerFull = evaluateCiProof({ ...proofFixture({ id: 22 }), policy: CI_PROOF_POLICIES.release });
assert.equal(selectCiProofVerdict([newerFull, light, red]).status, 'green');
assert.equal(selectCiProofVerdict([red, newerFull]).status, 'green',
'#619: a failed duplicate cannot hide a complete green proof for the same candidate');
});
test('#601 AC3: release policy accepts a full proof without requested mutants; light stays stale; review/merge still demand them', () => {
+13 -1
View File
@@ -111,6 +111,18 @@ test('#541: a later complete full proof refreshes an older red release candidate
assert.equal(verdict.url, 'https://run/12');
});
test('#619: any complete green proof on the exact SHA survives a newer failed duplicate', async () => {
const older = proofContext({ id: 13 });
const newer = proofContext({ id: 14, conclusion: 'failure' });
const contexts = new Map([[13, older.context], [14, newer.context]]);
const verdict = await classifyValidateProofs({
runs: [newer.run, older.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x',
loadContext: async (run) => contexts.get(run.databaseId),
});
assert.equal(verdict.status, 'green');
assert.equal(verdict.url, 'https://run/13');
});
test('release gate can target the dedicated exact-SHA performance workflow', () => {
assert.equal(
workflowRunsUrl({ repo: 'Matysh/houseplan-card', workflow: 'performance.yml', sha: 'abc/123' }),
@@ -121,7 +133,7 @@ test('release gate can target the dedicated exact-SHA performance workflow', ()
test('#541: the release documents describe proof semantics', () => {
const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8');
assert.match(development, /requires a complete Validate proof for its SHA and\nGit tree/);
assert.match(development, /cancelled or light run is not a release verdict and cannot hide an older full\nfailure/);
assert.match(development, /Any complete green full proof on the exact SHA\n\s*is sufficient/);
assert.match(development, /Review, merge and release use the\nsame `missing` \/ `pending` \/ `cancelled` \/ `stale` \/ `failed` state machine/);
const performance = readFileSync(new URL('../demo/performance/README.md', import.meta.url), 'utf8');
assert.match(performance, /latest\nnon-cancelled run on the SHA/);
+20 -3
View File
@@ -167,9 +167,9 @@ test('классификация опирается на завершённый
assert.match(changes, /actions: read/, 'чтение прогонов требует прав');
// У PR диапазон задан событием, считать его нечем и незачем.
assert.match(changes, /if: github\.event_name != 'pull_request'/);
// На dev классификации нет вовсе — там всё true; шаг там считает базу
// диапазона для другого потребителя (#388), и это разные выходы.
assert.match(changes, /dev: без фильтров, всё true/);
// На dev/main классификации нет вовсе — там всё true; шаг там считает базу
// диапазона для другого потребителя (#388, #619), и это разные выходы.
assert.match(changes, /dev\/main: без фильтров, всё true/);
assert.match(changes, /--name=range_base/);
// Пустая база означает «доказательства нет» и обязана вести к полному
// прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось».
@@ -178,6 +178,23 @@ test('классификация опирается на завершённый
'без базы классификация обязана раскрываться в полный прогон');
});
test('#619: Validate на main раскрывает полный набор так же, как на dev', () => {
const workflow = read('validate.yml');
const changes = workflow.slice(
workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'),
);
const integrationRefs = /\[ "\$REF" = "refs\/heads\/dev" \] \|\| \[ "\$REF" = "refs\/heads\/main" \]/g;
assert.equal(changes.match(integrationRefs)?.length, 2,
'base и classify обязаны одинаково распознавать dev/main');
assert.match(changes, /dev\/main: без фильтров, всё true/);
const frontend = workflow.slice(
workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n smoke:\n'),
);
assert.match(frontend, integrationRefs,
'no-new-any на main обязан судить диапазон от доказанного предка, а не HEAD..HEAD');
});
test('перф-смок добавляет профиль ровно при своём выходе changes (#473 AC3)', () => {
const workflow = read('validate.yml');
const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));