ci: unify Validate proof across gates (#541)

Issue: #541
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 10:07:04 +03:00
parent 01ce4817c3
commit 9c269c302d
17 changed files with 991 additions and 101 deletions
+7 -4
View File
@@ -140,7 +140,7 @@ test('§8.1 представители: каждая категория кажд
'test/fixtures/real-plan-first-floor.json'],
config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'],
toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'],
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'],
protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'],
},
smoke: {
source: ['src/houseplan-card.ts', 'src/logic.ts'],
@@ -149,7 +149,8 @@ test('§8.1 представители: каждая категория кажд
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs'],
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs',
'scripts/ci-proof.mjs'],
},
golden: {
source: ['src/houseplan-card.ts'],
@@ -158,7 +159,8 @@ test('§8.1 представители: каждая категория кажд
'demo/fixtures/visual-matrix.mjs'],
config: ['rollup.config.mjs', 'tsconfig.json'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', 'demo/editor-runtime-compat.mjs'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs',
'demo/editor-runtime-compat.mjs', 'scripts/ci-proof.mjs'],
},
performance_smoke: {
source: ['src/houseplan-card.ts'],
@@ -167,7 +169,8 @@ test('§8.1 представители: каждая категория кажд
'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'],
config: ['rollup.config.mjs'],
toolchain: ['package.json', '.github/workflows/validate.yml'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs', 'demo/performance/evaluate.mjs'],
protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs',
'demo/performance/evaluate.mjs', 'scripts/ci-proof.mjs'],
},
};
for (const [job, categories] of Object.entries(expect)) {
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { deflateRawSync } from 'node:zlib';
import {
CI_PROOF_POLICIES, buildCiProof, evaluateCiProof, parseReuseMarker, readCiProofArtifact,
requiredCheckIds, selectCiProofVerdict,
} from '../scripts/ci-proof.mjs';
export const SHA = 'a'.repeat(40);
export const TREE = 'b'.repeat(40);
const names = {
preflight: 'Предполётные проверки: документация, провенанс, процесс',
changes: 'Классификация изменённых файлов',
reuse: 'Переиспользование: это дерево уже проверено',
frontend: 'Фронтенд: типы, юниты, мутанты, синхрон бандла',
hacs: 'HACS: валидация репозитория',
hassfest: 'Hassfest: манифест интеграции',
smokeDone: 'Смоки: все шарды зелёные',
golden: 'Golden-кадры против принятых эталонов',
performance: 'Перф-смок: бюджет времени кадра',
backend: 'Бэкенд: pytest в Home Assistant',
};
const success = (name) => ({ name, conclusion: 'success' });
const mutantJobs = () => Array.from({ length: 6 }, (_, index) => (
success(`Мутанты по диффу (${index + 1}/6): затронутые свидетели краснеют`)
));
const smokeJobs = () => Array.from({ length: 3 }, (_, index) => (
success(`Смоки в браузере (шард ${index + 1} из 3)`)
));
export function proofFixture({
id = 10, attempt = 2, full = true, mutants = true,
frontend = true, backend = true, integration = true, conclusion = 'success',
} = {}) {
const needs = {
preflight: { result: 'success' },
changes: {
result: 'success',
outputs: {
heavy: String(full), mutants_requested: String(mutants),
frontend: String(frontend), backend: String(backend), integration: String(integration),
},
},
reuse: { result: 'success', outputs: {} },
frontend: { result: frontend ? 'success' : 'skipped' },
hacs: { result: integration ? 'success' : 'skipped' },
hassfest: { result: integration ? 'success' : 'skipped' },
changed_mutants: { result: mutants ? 'success' : 'skipped' },
smoke: { result: full ? 'success' : 'skipped' },
smoke_done: { result: full ? 'success' : 'skipped' },
golden: { result: full ? 'success' : 'skipped' },
performance_smoke: { result: full ? 'success' : 'skipped' },
backend: { result: backend ? 'success' : 'skipped' },
};
const proof = buildCiProof({
candidateSha: SHA, candidateTree: TREE, runId: id, attempt,
event: 'workflow_dispatch', needs,
});
const jobs = [success(names.preflight), success(names.changes), success(names.reuse)];
if (frontend) jobs.push(success(names.frontend));
if (integration) jobs.push(success(names.hacs), success(names.hassfest));
if (mutants) jobs.push(...mutantJobs());
if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance));
if (backend) jobs.push(success(names.backend));
const run = {
databaseId: id, attempt, status: 'completed', conclusion,
event: 'workflow_dispatch', headSha: SHA, url: `https://run/${id}`,
};
return { run, proof, jobs, reuseRuns: new Map(), candidate: { sha: SHA, tree: TREE } };
}
test('#541: proof records candidate identity, request and exact required check set', () => {
const fixture = proofFixture();
assert.deepEqual(fixture.proof.candidate, { sha: SHA, tree: TREE });
assert.deepEqual(fixture.proof.run, {
id: 10, attempt: 2, workflow: 'validate.yml', event: 'workflow_dispatch',
});
assert.deepEqual(fixture.proof.requiredChecks, [
'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants',
'smoke', 'golden', 'performance_smoke', 'backend',
]);
assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof));
});
test('#541 AC: one state machine gives review, merge and release the same terminal semantics', () => {
const full = proofFixture();
for (const policy of Object.values(CI_PROOF_POLICIES)) {
assert.equal(evaluateCiProof({ ...full, policy }).status, 'green', policy.name);
assert.equal(evaluateCiProof({ ...full, run: null, policy }).status, 'missing', policy.name);
assert.equal(evaluateCiProof({ ...full, run: { ...full.run, status: 'in_progress' }, policy }).status, 'pending', policy.name);
assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'cancelled' }, policy }).status, 'cancelled', policy.name);
assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'failure' }, policy }).status, 'failed', policy.name);
assert.equal(evaluateCiProof({ ...full, candidate: { sha: SHA, tree: 'c'.repeat(40) }, policy }).status, 'stale', policy.name);
}
});
test('#541 AC: full red followed by light green still blocks release; a later full green refreshes it', () => {
const redFull = proofFixture({ id: 20, conclusion: 'failure' });
const lightGreen = proofFixture({ id: 21, full: false, backend: false, integration: false });
const red = evaluateCiProof({ ...redFull, policy: CI_PROOF_POLICIES.release });
const light = evaluateCiProof({ ...lightGreen, policy: CI_PROOF_POLICIES.release });
assert.equal(light.status, 'stale');
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');
});
test('#541 AC: green dispatch without six executed mutant jobs proves neither review nor merge', () => {
const fixture = proofFixture({ full: false, backend: false, integration: false });
fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith('Мутанты по диффу'));
for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) {
const verdict = evaluateCiProof({ ...fixture, policy });
assert.equal(verdict.status, 'failed', policy.name);
assert.match(verdict.note, /mutants: claimed execution/);
}
});
test('#541 AC: SHA, tree, run attempt, event and proof inventories cannot drift', () => {
const fixture = proofFixture();
assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, attempt: 3 }, policy: CI_PROOF_POLICIES.release }).status, 'stale');
assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, headSha: 'd'.repeat(40) }, policy: CI_PROOF_POLICIES.release }).status, 'stale');
assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, event: 'push' }, policy: CI_PROOF_POLICIES.release }).status, 'stale');
const forged = structuredClone(fixture.proof);
forged.executedChecks = forged.executedChecks.filter((id) => id !== 'backend');
assert.equal(evaluateCiProof({ ...fixture, proof: forged, policy: CI_PROOF_POLICIES.release }).status, 'failed');
});
test('#541 AC: reuse needs a content key, marker source SHA/run and the successful source job', () => {
const fixture = proofFixture();
fixture.proof.checks.golden = {
mode: 'reused', result: 'success',
reuse: { key: 'e'.repeat(64), sourceRun: 77, sourceAttempt: 3, sourceSha: 'f'.repeat(40) },
};
fixture.proof.executedChecks = fixture.proof.executedChecks.filter((id) => id !== 'golden');
fixture.proof.reusedChecks = ['golden'];
fixture.jobs = fixture.jobs.filter((job) => job.name !== names.golden);
fixture.reuseRuns.set('77:3', {
run: { id: 77, run_attempt: 3, status: 'completed', conclusion: 'failure', head_sha: 'f'.repeat(40) },
jobs: [success(names.golden)],
});
assert.equal(evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }).status, 'green',
'individual green job remains lawful even when an unrelated source job made its run red');
const noSource = new Map();
assert.equal(evaluateCiProof({ ...fixture, reuseRuns: noSource, policy: CI_PROOF_POLICIES.release }).status, 'failed');
const badKey = structuredClone(fixture.proof);
badKey.checks.golden.reuse.key = 'short';
assert.equal(evaluateCiProof({ ...fixture, proof: badKey, policy: CI_PROOF_POLICIES.release }).status, 'failed');
});
test('#541: reuse marker parser fails closed', () => {
assert.deepEqual(parseReuseMarker(
`golden прогнана успешно\nSHA: ${SHA}\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 4\n`,
), { sourceSha: SHA, sourceRun: 123, sourceAttempt: 4 });
assert.throws(() => parseReuseMarker('SHA: short\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 1\n'), /must contain/);
assert.throws(() => parseReuseMarker(`SHA: ${SHA}\n`), /must contain/);
});
test('#541: uploaded deflated artifact is read without an external ZIP dependency', () => {
const body = Buffer.from(JSON.stringify({ schema: 'test', ok: true }));
const compressed = deflateRawSync(body);
const name = Buffer.from('nested/proof.json');
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(8, 8);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(body.length, 22);
local.writeUInt16LE(name.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(8, 10);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(body.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(0, 42);
const directoryAt = local.length + name.length + compressed.length;
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(1, 8);
eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(central.length + name.length, 12);
eocd.writeUInt32LE(directoryAt, 16);
const zip = Buffer.concat([local, name, compressed, central, name, eocd]);
assert.deepEqual(readCiProofArtifact(zip), { schema: 'test', ok: true });
});
+72 -12
View File
@@ -6,6 +6,31 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
const mergeProofContext = (row, sha, tree) => {
const proof = buildCiProof({
candidateSha: sha, candidateTree: tree, runId: row.databaseId,
attempt: row.attempt ?? 1, event: row.event,
needs: {
preflight: { result: 'success' },
changes: { result: 'success', outputs: {
heavy: 'false', mutants_requested: 'true', frontend: 'true',
backend: 'false', integration: 'false',
} },
reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' },
changed_mutants: { result: 'success' },
},
});
const success = (name) => ({ name, conclusion: 'success' });
return { proof, reuseRuns: new Map(), jobs: [
success('Предполётные проверки: документация, провенанс, процесс'),
success('Классификация изменённых файлов'),
success('Переиспользование: это дерево уже проверено'),
success('Фронтенд: типы, юниты, мутанты, синхрон бандла'),
...Array.from({ length: 6 }, (_, i) => success(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)),
] };
};
// #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой
// функции; последовательность операций — на фальшивых git/gh; эксперимент
@@ -21,13 +46,13 @@ test('§8.4 таблица решений decideMerge', () => {
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { action: 'rereview', to: 'S7-code-review' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: null }), { action: 'validate' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'missing' }), { action: 'validation-missing', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'red' }), { action: 'validation-red', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'failed' }), { action: 'validation-red', to: 'S6-in-progress' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green' }), { action: 'push', to: 'S8-merged' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 1 }), { action: 'retry' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 2 }), { action: 'retry' });
assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: MAX_ATTEMPTS }), { action: 'give-up', to: 'S6-in-progress' });
// ни один исход не ведёт в S8 без зелёного Validate при движении dev
for (const validate of [null, 'missing', 'red']) {
for (const validate of [null, 'missing', 'failed', 'pending', 'cancelled', 'stale']) {
assert.notEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate }).to, 'S8-merged', String(validate));
}
assert.equal(MAX_ATTEMPTS, 3);
@@ -119,7 +144,7 @@ test('эксперимент аудита: dev двигался, ребейз ч
});
test('красный Validate на кандидате — S6, без push в dev', async () => {
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['red'] });
const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['failed'] });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'validation-red');
assert.equal(r.to, 'S6-in-progress');
@@ -246,26 +271,61 @@ function scriptedExec(snapshots) {
}
test('#510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement', async () => {
const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' };
const push = { databaseId: 2, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push' };
const replacement = { databaseId: 3, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch' };
const sha = 'c'.repeat(40);
const tree = 'd'.repeat(40);
const cancelled = { databaseId: 1, attempt: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch', headSha: sha };
const push = { databaseId: 2, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push', headSha: sha };
const replacement = { databaseId: 3, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch', headSha: sha };
const gh = scriptedExec([[cancelled, push], [cancelled, push], [replacement, cancelled, push]]);
let clock = 0;
const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock });
const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' });
assert.deepEqual(r, { result: 'green', url: 'https://run/3' });
const ops = realOps({
repo: 'x/y', token: 'none', exec: gh.exec,
sleep: async (ms) => { clock += ms; }, now: () => clock,
candidateTree: async () => tree,
proofContext: async (row) => row.databaseId === 3
? mergeProofContext(row, sha, tree) : { proof: null, jobs: [], reuseRuns: new Map() },
});
const r = await ops.waitValidate(sha, { event: 'workflow_dispatch' });
assert.equal(r.result, 'green');
assert.equal(r.url, 'https://run/3');
assert.equal(gh.calls(), 3, 'kept polling past the cancelled run instead of returning red on the first answer');
});
test('#510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red', async () => {
const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' };
const sha = 'c'.repeat(40);
const tree = 'd'.repeat(40);
const cancelled = { databaseId: 1, attempt: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch', headSha: sha };
const gh = scriptedExec([[cancelled]]);
let clock = 0;
const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock });
const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' });
const ops = realOps({
repo: 'x/y', token: 'none', exec: gh.exec,
sleep: async (ms) => { clock += ms; }, now: () => clock,
candidateTree: async () => tree,
proofContext: async () => ({ proof: null, jobs: [], reuseRuns: new Map() }),
});
const r = await ops.waitValidate(sha, { event: 'workflow_dispatch' });
assert.equal(r.result, 'missing');
});
test('#541: real merge waiter never accepts a successful dispatch without its proof artifact', async () => {
const sha = 'c'.repeat(40);
const tree = 'd'.repeat(40);
const unproved = {
databaseId: 4, attempt: 1, status: 'completed', conclusion: 'success',
url: 'https://run/4', event: 'workflow_dispatch', headSha: sha,
};
const gh = scriptedExec([[unproved]]);
let clock = 0;
const ops = realOps({
repo: 'x/y', token: 'none', exec: gh.exec,
sleep: async (ms) => { clock += ms; }, now: () => clock,
candidateTree: async () => tree,
proofContext: async () => ({ proof: null, jobs: [], reuseRuns: new Map() }),
});
const result = await ops.waitValidate(sha, { event: 'workflow_dispatch' });
assert.equal(result.result, 'missing');
});
// ---------- #516: the candidate carries its own review document; dev moves by other documents ----------
test('#516 AC1: dev moved only by review documents and the branch carries its own — patch-id equal, merge goes through Validate, not re-review', async () => {
+67 -4
View File
@@ -1,7 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { classifyValidateRuns, latestRelevantRun, workflowRunsUrl } from '../scripts/release-gate.mjs';
import {
classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl,
} from '../scripts/release-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
const greenJob = (name) => ({ name, conclusion: 'success' });
const proofContext = ({ id, full = true, conclusion = 'success' }) => {
const needs = {
preflight: { result: 'success' }, changes: { result: 'success', outputs: {
heavy: String(full), mutants_requested: 'true', frontend: 'true',
backend: String(full), integration: String(full),
} },
reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' },
changed_mutants: { result: 'success' }, hacs: { result: full ? 'success' : 'skipped' },
hassfest: { result: full ? 'success' : 'skipped' }, smoke: { result: full ? 'success' : 'skipped' },
smoke_done: { result: full ? 'success' : 'skipped' }, golden: { result: full ? 'success' : 'skipped' },
performance_smoke: { result: full ? 'success' : 'skipped' }, backend: { result: full ? 'success' : 'skipped' },
};
const proof = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: id, attempt: 1, event: 'workflow_dispatch', needs });
const jobs = [
greenJob('Предполётные проверки: документация, провенанс, процесс'),
greenJob('Классификация изменённых файлов'), greenJob('Переиспользование: это дерево уже проверено'),
greenJob('Фронтенд: типы, юниты, мутанты, синхрон бандла'),
...Array.from({ length: 6 }, (_, i) => greenJob(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)),
];
if (full) jobs.push(
greenJob('HACS: валидация репозитория'), greenJob('Hassfest: манифест интеграции'),
...Array.from({ length: 3 }, (_, i) => greenJob(`Смоки в браузере (шард ${i + 1} из 3)`)),
greenJob('Смоки: все шарды зелёные'), greenJob('Golden-кадры против принятых эталонов'),
greenJob('Перф-смок: бюджет времени кадра'), greenJob('Бэкенд: pytest в Home Assistant'),
);
const run = {
databaseId: id, attempt: 1, status: 'completed', conclusion, event: 'workflow_dispatch',
headSha: SHA, url: `https://run/${id}`, startedAt: `2026-09-13T10:${id}:00Z`,
};
return { run, context: { proof, jobs, reuseRuns: new Map() } };
};
test('release gate waits until an exact-SHA Validate exists and completes', () => {
assert.equal(classifyValidateRuns([]), 'wait');
@@ -47,6 +85,30 @@ test('#511: the latest non-cancelled run is the verdict; cancelled runs prove no
assert.equal(latestRelevantRun([]), null);
});
test('#541: release skips a newer light proof but does not let it hide an older full failure', async () => {
const older = proofContext({ id: 10, conclusion: 'failure' });
const newer = proofContext({ id: 11, full: false });
const contexts = new Map([[10, older.context], [11, newer.context]]);
const verdict = await classifyValidateProofs({
runs: [older.run, newer.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x',
loadContext: async (run) => contexts.get(run.databaseId),
});
assert.equal(verdict.status, 'failed');
assert.equal(verdict.url, 'https://run/10');
});
test('#541: a later complete full proof refreshes an older red release candidate', async () => {
const older = proofContext({ id: 10, conclusion: 'failure' });
const newer = proofContext({ id: 12 });
const contexts = new Map([[10, older.context], [12, newer.context]]);
const verdict = await classifyValidateProofs({
runs: [older.run, newer.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/12');
});
test('release gate can target the dedicated exact-SHA performance workflow', () => {
assert.equal(
workflowRunsUrl({ repo: 'Matysh/houseplan-card', workflow: 'performance.yml', sha: 'abc/123' }),
@@ -54,10 +116,11 @@ test('release gate can target the dedicated exact-SHA performance workflow', ()
);
});
test('#511 AC3: the release documents describe the latest-run semantics', () => {
test('#541: the release documents describe proof semantics', () => {
const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8');
assert.match(development, /latest non-cancelled Validate run of the\nSHA/);
assert.doesNotMatch(development, /A missing, failed,\ncancelled or one-hour-timed-out Validate withholds/);
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, /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/);
});
+29 -2
View File
@@ -3,12 +3,19 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
/** Fake gh: a scripted list of run snapshots per call, a virtual clock. */
const MUTANT_JOBS = [1, 2, 3, 4, 5, 6].map((n) => ({ name: `Мутанты по диффу (${n}/6): затронутые свидетели краснеют`, conclusion: 'success' }));
const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }];
const BASE_JOBS = [
{ name: 'Предполётные проверки: документация, провенанс, процесс', conclusion: 'success' },
{ name: 'Классификация изменённых файлов', conclusion: 'success' },
{ name: 'Переиспользование: это дерево уже проверено', conclusion: 'success' },
];
function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
let clock = 0;
@@ -19,6 +26,26 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; },
listRunsOnRef: async () => onRef,
jobs: async (id) => jobsById[id] ?? [...OTHER_JOBS, ...MUTANT_JOBS],
candidateTree: async () => TREE,
proof: async (row) => {
const selected = jobsById[row.databaseId] ?? [...OTHER_JOBS, ...MUTANT_JOBS];
const mutants = provesMutants(selected);
const proof = buildCiProof({
candidateSha: SHA, candidateTree: TREE, runId: row.databaseId,
attempt: row.attempt ?? 1, event: row.event,
needs: {
preflight: { result: 'success' },
changes: { result: 'success', outputs: {
heavy: 'false', mutants_requested: String(mutants),
frontend: 'true', backend: 'false', integration: 'false',
} },
reuse: { result: 'success', outputs: {} },
frontend: { result: 'success' },
changed_mutants: { result: mutants ? 'success' : 'skipped' },
},
});
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
},
dispatch: async (ref) => { dispatched.push(ref); },
sleep: async (ms) => { clock += ms; },
now: () => clock,
@@ -67,7 +94,7 @@ test('#510 AC2: a completed green dispatch run on the material is accepted witho
test('#510 AC2: a completed red dispatch run returns the task without review', async () => {
const fake = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops });
assert.equal(outcome.result, 'red');
assert.equal(outcome.result, 'failed');
assert.equal(outcome.url, 'https://run/red');
});
@@ -112,7 +139,7 @@ test('#510 AC2: a dispatch that never finishes is red after the total window', a
const running = [run({ status: 'in_progress', conclusion: null })];
const fake = fakeOps({ snapshots: [running] });
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, totalMs: 10_000, pollMs: 4000 });
assert.equal(outcome.result, 'red');
assert.equal(outcome.result, 'failed');
assert.match(outcome.note, /не завершился/);
assert.deepEqual(fake.dispatched, []);
});
+30
View File
@@ -453,3 +453,33 @@ test('журнал свидетелей changed_mutants: rerun продолжа
assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона');
});
test('#541: Validate всегда публикует proof точной попытки, а reuse раскрывает источник', () => {
const workflow = read('validate.yml');
const proofAt = workflow.indexOf('\n proof:\n');
assert.ok(proofAt > 0, 'финальная proof job существует');
const proof = workflow.slice(proofAt);
assert.match(proof, /if: always\(\)/, 'proof создаётся и на красном прогоне');
for (const dependency of [
'preflight', 'changes', 'reuse', 'hacs', 'hassfest', 'changed_mutants',
'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend',
]) assert.match(proof, new RegExp(`needs: \\[[^\\n]*\\b${dependency}\\b`), dependency);
assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/);
assert.match(proof, /CANDIDATE_TREE: \$\{\{ steps\.candidate\.outputs\.tree \}\}/);
assert.match(proof, /CI_RUN_ID: \$\{\{ github\.run_id \}\}/);
assert.match(proof, /CI_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/);
assert.match(proof, /REQUEST_FULL: \$\{\{ inputs\.full \}\}/);
assert.match(proof, /REQUEST_MUTANTS: \$\{\{ inputs\.mutants \}\}/);
assert.match(proof, /NEEDS_JSON: \$\{\{ toJSON\(needs\) \}\}/);
assert.match(proof, /name: ci-proof-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n'));
for (const id of ['smoke', 'golden', 'performance_smoke', 'backend']) {
assert.match(reuse, new RegExp(`${id}_source_run:`), `${id}: source run output`);
assert.match(reuse, new RegExp(`${id}_source_attempt:`), `${id}: source attempt output`);
assert.match(reuse, new RegExp(`${id}_source_sha:`), `${id}: source SHA output`);
}
assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 4);
assert.equal(reuse.includes('lookup-only: true'), false,
'marker contents must be restored and verified, not reduced to a cache-hit bit');
});