mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
process: derived artifacts are accepted on dev once per beta (#697)
The screenshot fingerprint and golden baselines stop being a tax on every task branch: - Task branches no longer commit docs/images/** or golden baselines. On a branch the screenshot freshness stays a preflight warning; the review prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate. - beta-derived.yml refreshes them on dev in one bot commit before the beta candidate: canonical docs capture + docs:accept --reviewed, golden from the golden-images artifact of a completed Validate on dev + golden:accept --reviewed. A changed frame or scene is accepted only when named in the inputs; undeclared differences refuse. Baseline commits carry Release: and Baseline-Reviewed:; the subject is not a candidate subject. - classify-changes: the Release: trailer on an issue/* branch no longer switches on the heavy set. ci:full / ci:golden do: process-track emits full=true, the review gate dispatches Validate with full=true and does not accept a light proof. Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md. Issue: #697 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -88,13 +88,23 @@ export function classifyAll() {
|
||||
* - `workflow_dispatch` с `full=true` — ночной прогон (nightly.yml) и ручной;
|
||||
* - pull_request — там Validate единственный сигнал.
|
||||
*/
|
||||
export function heavyGatesRequested({ eventName, headMessage, fullInput } = {}) {
|
||||
export function heavyGatesRequested({ eventName, headMessage, fullInput, refName } = {}) {
|
||||
if (eventName === 'pull_request') return true;
|
||||
if (eventName === 'workflow_dispatch') return String(fullInput) === 'true';
|
||||
if (eventName === 'schedule') return true;
|
||||
// #697: на ветке задачи трейлер `Release:` тяжёлый набор больше не включает.
|
||||
// Его там носит только приёмка эталонов, а golden на ветке задачи — по метке
|
||||
// `ci:golden`: конвейер ревью диспатчит Validate с `full=true` сам. Кандидат
|
||||
// беты и релиза собирается на `dev` — там трейлер работает как прежде.
|
||||
if (isTaskBranch(refName)) return false;
|
||||
return hasReleaseTrailer(headMessage);
|
||||
}
|
||||
|
||||
/** Ветка задачи — `issue/<NN>-…` (PROCESS §3). */
|
||||
export function isTaskBranch(refName) {
|
||||
return /^issue\//.test(String(refName || '').replace(/^refs\/heads\//, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужны ли мутанты по диффу (#510, сужено в #601). За 08–09.09 они съели 86 %
|
||||
* job-минут Validate, потому что бежали на каждом промежуточном пуше и
|
||||
@@ -159,6 +169,7 @@ if (invokedDirectly) {
|
||||
eventName: process.env.EVENT_NAME,
|
||||
headMessage: process.env.HEAD_MESSAGE,
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
refName: process.env.REF_NAME,
|
||||
})}\n`);
|
||||
} else if (process.argv.includes('--heavy')) {
|
||||
// Отдельный вызов: у `heavy` другие входы (событие, сообщение head-коммита),
|
||||
@@ -167,6 +178,7 @@ if (invokedDirectly) {
|
||||
eventName: process.env.EVENT_NAME,
|
||||
headMessage: process.env.HEAD_MESSAGE,
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
refName: process.env.REF_NAME,
|
||||
});
|
||||
process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`);
|
||||
const mutants = mutantsRequested({
|
||||
|
||||
@@ -13455,6 +13455,51 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' else if (false && block.high > 0) problems.push(',
|
||||
}],
|
||||
},
|
||||
// #697: производные артефакты — на dev, тяжёлое на ветке задачи — по меткам.
|
||||
{
|
||||
id: 'task-branch-release-trailer-heavy-again',
|
||||
guard: 'node --test --test-name-pattern="#697" test/classify-changes.test.mjs',
|
||||
because: '#697: on a task branch the Release: trailer only marks a baseline acceptance; '
|
||||
+ 'it must not switch on smokes, golden and perf again — ci:full/ci:golden do that',
|
||||
patches: [{
|
||||
file: 'scripts/classify-changes.mjs',
|
||||
find: ' if (isTaskBranch(refName)) return false;\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ci-golden-label-does-not-order-full-set',
|
||||
guard: 'node --test --test-name-pattern="#697" test/process-track.test.mjs',
|
||||
because: '#697: a task that changes visuals on purpose sets ci:golden; without the full set '
|
||||
+ 'on its review material its shifted frames surface in the next task again',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: " const full = labels.includes('ci:full') || labels.includes('ci:golden');",
|
||||
replace: " const full = labels.includes('ci:full'); // mutant: ci:golden ignored",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-gate-accepts-light-proof-for-full',
|
||||
guard: 'node --test --test-name-pattern="#697" test/validate-gate.test.mjs',
|
||||
because: '#697: with ci:full/ci:golden a light push run proves nothing about smokes and golden; '
|
||||
+ 'the gate must dispatch full=true instead of accepting it',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;",
|
||||
replace: ' const policy = base; // mutant: full never required',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'bot-golden-commit-without-provenance',
|
||||
guard: 'node --test --test-name-pattern="провенанс" test/beta-derived.test.mjs',
|
||||
because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, '
|
||||
+ 'otherwise validate-commit-provenance turns dev red on the push',
|
||||
patches: [{
|
||||
file: '.github/workflows/beta-derived.yml',
|
||||
find: ' echo "Release: $TAG"\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
|
||||
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
|
||||
*
|
||||
* `resolve` печатает `track=ship|show|ask` и `mutants=true|false` — то, что
|
||||
* конвейер ревью читает, решая, сколько стоит заход: мутанты по диффу нужны
|
||||
* только `ask` и метке `ci:mutants`. Инфраструктурная задача без трековой
|
||||
* `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и
|
||||
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
|
||||
* заход: мутанты по диффу нужны только `ask` и метке `ci:mutants`; полный
|
||||
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
|
||||
* `ci:golden` (#697). Инфраструктурная задача без трековой
|
||||
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
||||
* диффе ни одного файла класса A.
|
||||
*
|
||||
@@ -46,12 +48,14 @@ export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'trac
|
||||
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
||||
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
|
||||
* `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`.
|
||||
* Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697).
|
||||
*/
|
||||
export function resolveTrack({ labels = [], files = [] } = {}) {
|
||||
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
|
||||
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
|
||||
const mutants = track === 'ask' || labels.includes('ci:mutants');
|
||||
return { track, mutants, infrastructure };
|
||||
const full = labels.includes('ci:full') || labels.includes('ci:golden');
|
||||
return { track, mutants, full, infrastructure };
|
||||
}
|
||||
|
||||
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
||||
@@ -120,8 +124,8 @@ if (isMainModule(import.meta.url)) {
|
||||
if (command === 'resolve') {
|
||||
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
|
||||
const { track, mutants } = resolveTrack({ labels, files });
|
||||
emit([`track=${track}`, `mutants=${mutants}`]);
|
||||
const { track, mutants, full } = resolveTrack({ labels, files });
|
||||
emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]);
|
||||
} else if (command === 'ship-limits') {
|
||||
if (!range) throw new Error('--base is required');
|
||||
const violations = shipLimitViolations({
|
||||
|
||||
@@ -85,10 +85,14 @@ export function provesMutants(jobs) {
|
||||
*/
|
||||
export async function validateGate({
|
||||
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
|
||||
mutants = true,
|
||||
mutants = true, full = false,
|
||||
}) {
|
||||
const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
|
||||
const label = mutants ? 'Validate с мутантами' : 'Validate';
|
||||
// #697: метки `ci:full`/`ci:golden` заказывают полный набор на материале —
|
||||
// смоки, golden, perf. Лёгкий push-прогон его не несёт и доказательством не
|
||||
// считается (policy.full), поэтому гейт диспатчит `full=true` сам.
|
||||
const base = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
|
||||
const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;
|
||||
const label = `${mutants ? 'Validate с мутантами' : 'Validate'}${full ? ' (полный набор)' : ''}`;
|
||||
const started = ops.now();
|
||||
const candidateTree = await ops.candidateTree(sha);
|
||||
const ignored = new Set(); // завершённые dispatch без применимого proof
|
||||
@@ -125,7 +129,7 @@ export async function validateGate({
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref, { mutants });
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts = 1;
|
||||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||||
@@ -137,7 +141,7 @@ export async function validateGate({
|
||||
// чужой коммит переживёт и вторую попытку.
|
||||
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
|
||||
if (elsewhere && attempts < DISPATCH_ATTEMPTS) {
|
||||
await ops.dispatch(ref, { mutants });
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts += 1;
|
||||
await ops.sleep(pollMs);
|
||||
@@ -172,8 +176,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G
|
||||
catch { return { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
},
|
||||
listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])),
|
||||
dispatch: async (ref, { mutants = true } = {}) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${mutants ? 'true' : 'false'}`]);
|
||||
dispatch: async (ref, { mutants = true, full = false } = {}) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', `full=${full ? 'true' : 'false'}`, '-f', `mutants=${mutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
||||
@@ -188,13 +192,15 @@ if (invokedDirectly) {
|
||||
const ref = arg('ref');
|
||||
const sha = arg('sha');
|
||||
if (!repo || !ref || !sha) {
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml] [--mutants=false] [--no-wait]');
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml] [--mutants=false] [--full=true] [--no-wait]');
|
||||
process.exit(2);
|
||||
}
|
||||
const wait = !process.argv.includes('--no-wait');
|
||||
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
const outcome = await validateGate({ ref, sha, wait, mutants, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
// #697: `--full=true` — метки ci:full/ci:golden.
|
||||
const full = arg('full') === 'true';
|
||||
const outcome = await validateGate({ ref, sha, wait, mutants, full, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
|
||||
Reference in New Issue
Block a user