mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
ci: unify Validate proof across gates (#541)
Issue: #541 User-Visible: no
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user