mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
ci: reviewed run судится только release-потребителем (#573, ревью r1 M1)
`loadGithubProofContext` спрашивал объявленный `Baseline-Reviewed` run для любого потребителя, а `evaluateCiProof` судил его при `reviewedRun: null` — merge и review начинали зависеть от доступности старого run по чужой причине. Теперь запрос делается только с `withReviewedRun` (release-gate передаёт его вместе с ожиданиями), а проверка стоит внутри `if (expected)` — рядом со сверкой evidence, где ей и место. Тест: без ожиданий merge/review green при reviewedRun undefined/null/пустом; фейковый fetch доказывает, что запроса нет. Issue: #573 User-Visible: no
This commit is contained in:
+3
-1
@@ -501,7 +501,9 @@ executed or reused. Release consumers standing on the candidate checkout
|
||||
(`release-gate.mjs`, `release-prerelease.mjs`) recompute all of it locally and
|
||||
fail closed on any mismatch, on a reused marker whose key is not the
|
||||
candidate's, and on a declared review run that does not exist, was cancelled
|
||||
or is not a Validate run; a proof without the block is stale for them. The
|
||||
or is not a Validate run; a proof without the block is stale for them. Review
|
||||
and merge consumers pass no expectations, do not query the declared review run
|
||||
and keep the #541 semantics unchanged. The
|
||||
practical consequence is the beta.3 path: a candidate red only in golden,
|
||||
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
|
||||
|
||||
+19
-14
@@ -287,12 +287,23 @@ export function evaluateCiProof({
|
||||
return result('stale', 'run event differs from proof event');
|
||||
if (policy?.full && !asBool(proof.request?.full)) return result('stale', 'proof is light; full gates were not requested');
|
||||
if (policy?.mutants && !asBool(proof.request?.mutants)) return result('stale', 'proof has no requested mutant jobs');
|
||||
// #573: потребитель, у которого есть checkout кандидата, сверяет составное
|
||||
// evidence, а не верит ему. Proof без блока при наличии ожиданий устарел.
|
||||
// #573: потребитель, у которого есть checkout кандидата (release), сверяет
|
||||
// составное evidence, а не верит ему. Proof без блока при наличии ожиданий
|
||||
// устарел. Объявленный run просмотра кадров проверяется ТОЛЬКО здесь же:
|
||||
// review и merge ожиданий не передают и лишнего запроса к API не делают
|
||||
// (ревью r1, M1) — их семантика #541 не меняется.
|
||||
if (expected) {
|
||||
if (!proof.evidence) return result('stale', 'proof predates composite evidence (#573)');
|
||||
const mismatch = evidenceMismatch(proof.evidence, expected);
|
||||
if (mismatch) return result('failed', `evidence does not match the candidate checkout — ${mismatch}`);
|
||||
const declared = proof.evidence.baselines?.reviewedRun ?? null;
|
||||
if (declared) {
|
||||
const source = reviewedRun?.run;
|
||||
if (!source || runIdOf(source) !== declared || !/validate\.yml$/.test(String(source.path || source.workflow || 'validate.yml'))
|
||||
|| source.status !== 'completed' || source.conclusion === 'cancelled') {
|
||||
return result('failed', `Baseline-Reviewed run ${declared} is missing, cancelled or not a Validate run`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (proof.evidence) {
|
||||
for (const id of REUSE_JOBS) {
|
||||
@@ -300,14 +311,6 @@ export function evaluateCiProof({
|
||||
if (claim?.mode === 'reused' && claim.reuse?.key !== proof.evidence.keys?.[id])
|
||||
return result('failed', `${id}: reused marker key differs from the candidate content key`);
|
||||
}
|
||||
const declared = proof.evidence.baselines?.reviewedRun ?? null;
|
||||
if (declared && reviewedRun !== undefined) {
|
||||
const source = reviewedRun?.run;
|
||||
if (!source || runIdOf(source) !== declared || !/validate\.yml$/.test(String(source.path || source.workflow || 'validate.yml'))
|
||||
|| source.status !== 'completed' || source.conclusion === 'cancelled') {
|
||||
return result('failed', `Baseline-Reviewed run ${declared} is missing, cancelled or not a Validate run`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const derived = requiredCheckIds(proof);
|
||||
if (!sameSet(derived, proof.requiredChecks || []))
|
||||
@@ -404,7 +407,7 @@ export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch
|
||||
return row?.tree?.sha || null;
|
||||
}
|
||||
|
||||
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch }) {
|
||||
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch, withReviewedRun = false }) {
|
||||
const runId = runIdOf(run);
|
||||
const attempt = runAttemptOf(run);
|
||||
const name = ciProofArtifactName(runId, attempt);
|
||||
@@ -438,17 +441,19 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
|
||||
);
|
||||
reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] });
|
||||
}
|
||||
// #573: объявленный человеком run просмотра кадров обязан существовать.
|
||||
// #573: объявленный человеком run просмотра кадров обязан существовать —
|
||||
// спрашивает только release-потребитель (`withReviewedRun`); review и merge
|
||||
// этот запрос не делают и от доступности старого run не зависят.
|
||||
let reviewedRun;
|
||||
const declared = proof?.evidence?.baselines?.reviewedRun;
|
||||
if (declared) {
|
||||
if (withReviewedRun && declared) {
|
||||
try {
|
||||
reviewedRun = { run: await githubJson(`https://api.github.com/repos/${repo}/actions/runs/${declared}`, token, fetchImpl) };
|
||||
} catch {
|
||||
reviewedRun = null;
|
||||
}
|
||||
}
|
||||
return { proof, jobs, reuseRuns, reviewedRun };
|
||||
return { proof, jobs, reuseRuns, ...(reviewedRun !== undefined ? { reviewedRun } : {}) };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
|
||||
@@ -55,7 +55,8 @@ const newestFirst = (runs) => [...(Array.isArray(runs) ? runs : [])].sort((a, b)
|
||||
/** #541: proof-aware verdict shared with review and merge. */
|
||||
export async function classifyValidateProofs({
|
||||
runs, repo, sha, tree, token, fetchImpl = fetch, expected = null,
|
||||
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl }),
|
||||
// #573: reviewed run спрашивается у GitHub только вместе с ожиданиями (release)
|
||||
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl, withReviewedRun: Boolean(expected) }),
|
||||
}) {
|
||||
const evaluations = [];
|
||||
for (const run of newestFirst(runs)) {
|
||||
|
||||
+62
-4
@@ -5,8 +5,8 @@ import { deflateRawSync } from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, localEvidence, parseReuseMarker,
|
||||
productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
|
||||
CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence,
|
||||
parseReuseMarker, productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
|
||||
} from '../scripts/ci-proof.mjs';
|
||||
import { REUSE_JOBS } from '../scripts/check-inputs.mjs';
|
||||
import { reuseKey } from '../scripts/gate-reuse.mjs';
|
||||
@@ -14,6 +14,25 @@ import { reuseKey } from '../scripts/gate-reuse.mjs';
|
||||
export const SHA = 'a'.repeat(40);
|
||||
export const TREE = 'b'.repeat(40);
|
||||
|
||||
/** Минимальный ZIP с одним `proof.json` (stored), как читает readCiProofArtifact. */
|
||||
function zipWith(proof) {
|
||||
const body = Buffer.from(JSON.stringify(proof));
|
||||
const name = Buffer.from('proof.json');
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 8);
|
||||
local.writeUInt32LE(body.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(0, 10);
|
||||
central.writeUInt32LE(body.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(0, 42);
|
||||
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(local.length + name.length + body.length, 16);
|
||||
const bytes = Buffer.concat([local, name, body, central, name, eocd]);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
const names = {
|
||||
preflight: 'Предполёт: документация, провенанс, процесс',
|
||||
changes: 'Классификация изменённых файлов',
|
||||
@@ -262,8 +281,13 @@ test('#573 AC1: baseline-only коммит — reused smoke/perf из красн
|
||||
assert.equal(verdict.status, 'green', verdict.note);
|
||||
assert.deepEqual(fixture.proof.reusedChecks, ['performance_smoke', 'smoke']);
|
||||
assert.ok(fixture.proof.executedChecks.includes('golden'), 'golden сравнивает с новыми эталонами и перегоняется всегда');
|
||||
// те же семантики без ожиданий — review/merge потребители не ломаются
|
||||
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun: undefined, policy: CI_PROOF_POLICIES.merge }).status, 'green');
|
||||
// review/merge без ожиданий: reviewed run не спрашивается и не судится (ревью r1, M1) —
|
||||
// недоступный или пропавший старый run не закрывает merge по чужой причине
|
||||
for (const reviewedRun of [undefined, null, { run: null }]) {
|
||||
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun, policy: CI_PROOF_POLICIES.merge }).status, 'green',
|
||||
`merge без ожиданий при reviewedRun=${JSON.stringify(reviewedRun)}`);
|
||||
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun, policy: CI_PROOF_POLICIES.review }).status, 'green');
|
||||
}
|
||||
});
|
||||
|
||||
test('#573 AC3: красный smoke кандидата не прячется за зелёной golden — источник reuse обязан быть зелёной job', () => {
|
||||
@@ -307,6 +331,40 @@ test('#573 AC4: подмена content-ключа, product tree, overlay, инд
|
||||
assert.equal(evaluateCiProof({ ...fixture, proof: legacy, policy: CI_PROOF_POLICIES.release }).status, 'stale');
|
||||
});
|
||||
|
||||
test('#573 r1 M1: reviewed run спрашивается у GitHub только для release-потребителя с ожиданиями', async () => {
|
||||
const proof = { ...baselineOnlyFixture().proof };
|
||||
const urls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
urls.push(String(url));
|
||||
if (/\/artifacts\?name=/.test(url)) return { ok: true, json: async () => ({ artifacts: [] }) };
|
||||
if (/\/jobs\?/.test(url)) return { ok: true, json: async () => ({ jobs: [] }) };
|
||||
return { ok: true, json: async () => ({ id: 34853080375, path: '.github/workflows/validate.yml', status: 'completed', conclusion: 'failure' }) };
|
||||
};
|
||||
const run = { id: 20, run_attempt: 1 };
|
||||
// артефакта нет → proof null → reviewed run неизвестен и не спрашивается ни в одном режиме
|
||||
const bare = await loadGithubProofContext({ repo: 'x/y', run, token: 't', fetchImpl });
|
||||
assert.ok(!('reviewedRun' in bare));
|
||||
assert.ok(!urls.some((url) => url.endsWith('/actions/runs/34853080375')), 'без proof спрашивать нечего');
|
||||
// с proof: merge/review (withReviewedRun по умолчанию false) — запроса нет
|
||||
const withProof = (withReviewedRun) => loadGithubProofContext({
|
||||
repo: 'x/y', run, token: 't', withReviewedRun,
|
||||
fetchImpl: async (url) => (/\/artifacts\?name=/.test(url)
|
||||
? { ok: true, json: async () => ({ artifacts: [{ name: 'ci-proof-20-1', expired: false, archive_download_url: 'zip://proof' }] }) }
|
||||
: url === 'zip://proof'
|
||||
? { ok: true, arrayBuffer: async () => zipWith(proof) }
|
||||
: fetchImpl(url)),
|
||||
});
|
||||
urls.length = 0;
|
||||
const merge = await withProof(false);
|
||||
assert.deepEqual(merge.proof.evidence.baselines.reviewedRun, 34853080375);
|
||||
assert.ok(!('reviewedRun' in merge), 'merge/review не судят reviewed run');
|
||||
assert.ok(!urls.some((url) => url.endsWith('/actions/runs/34853080375')));
|
||||
urls.length = 0;
|
||||
const release = await withProof(true);
|
||||
assert.equal(release.reviewedRun.run.id, 34853080375);
|
||||
assert.ok(urls.some((url) => url.endsWith('/actions/runs/34853080375')), 'release спрашивает объявленный run');
|
||||
});
|
||||
|
||||
test('#573: identity продуктового дерева не видит overlay эталонов, но видит всё остальное', () => {
|
||||
const lines = [
|
||||
'100644 blob 1111\tsrc/logic.ts',
|
||||
|
||||
Reference in New Issue
Block a user