mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +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:
@@ -0,0 +1,62 @@
|
||||
// #697, PROCESS.md §8: отпечаток и кадры скриншотов, эталоны golden —
|
||||
// одним коммитом бота на dev перед бетой, а не в каждой ветке задачи.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateCommitMessage } from '../scripts/validate-commit-provenance.mjs';
|
||||
import { isCandidateSubject } from '../scripts/bundle-policy.mjs';
|
||||
|
||||
const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/beta-derived.yml', import.meta.url)), 'utf8');
|
||||
const step = (name) => {
|
||||
const start = WORKFLOW.indexOf(` - name: ${name}\n`);
|
||||
assert.ok(start > 0, `нет шага ${name}`);
|
||||
const next = WORKFLOW.indexOf('\n - ', start + 10);
|
||||
return next < 0 ? WORKFLOW.slice(start) : WORKFLOW.slice(start, next);
|
||||
};
|
||||
|
||||
test('#697 бот: только по кнопке, прав на запись у job нет — пишет PAT одним push', () => {
|
||||
assert.match(WORKFLOW, /^on:\n workflow_dispatch:\n/m);
|
||||
assert.doesNotMatch(WORKFLOW, /^\s+(push|schedule|workflow_run):/m);
|
||||
assert.match(WORKFLOW, /permissions:\n\s+contents: read\n\s+actions: read\n/);
|
||||
assert.doesNotMatch(WORKFLOW, /contents: write/);
|
||||
const commit = step('Коммит в dev');
|
||||
assert.match(commit, /git push -q "https:\/\/x-access-token:\$TOKEN@github\.com\/\$\{\{ github\.repository \}\}" HEAD:dev/);
|
||||
assert.doesNotMatch(commit, /--force/, 'ушедший dev — перезапуск, а не перезапись');
|
||||
});
|
||||
|
||||
test('#697 бот: скриншоты принимаются по канону, изменённый кадр — только объявленный', () => {
|
||||
const docs = step('Кадры документации — съёмка и приёмка');
|
||||
assert.match(docs, /node demo\/docs\/capture\.mjs --stability=3/);
|
||||
assert.match(docs, /git checkout -- docs\/images\n\s+git clean -fdq -- docs\/images/, 'приёмка сравнивает с закоммиченными кадрами');
|
||||
assert.match(docs, /args=\(--reviewed "--from=\$cand"\)/);
|
||||
assert.match(docs, /--expect-change=\$EXPECT/);
|
||||
assert.match(docs, /EXPECT: \$\{\{ inputs\.docs_expect_change \}\}/);
|
||||
assert.match(WORKFLOW, /OXIPNG_VERSION: 10\.2\.0/, 'тот же упаковщик, что docs-screenshots.yml');
|
||||
const screenshots = readFileSync(fileURLToPath(new URL('../.github/workflows/docs-screenshots.yml', import.meta.url)), 'utf8');
|
||||
assert.match(screenshots, /OXIPNG_VERSION: 10\.2\.0/);
|
||||
});
|
||||
|
||||
test('#697 бот: эталоны golden — только из завершённого Validate на dev', () => {
|
||||
const golden = step('Эталоны golden из прогона Validate');
|
||||
assert.match(golden, /if: inputs\.golden_run != ''/);
|
||||
assert.match(golden, /\[ "\$path" != "\.github\/workflows\/validate\.yml" \] \|\| \[ "\$branch" != "dev" \] \|\| \[ "\$status" != "completed" \]/);
|
||||
assert.match(golden, /gh run download "\$RUN" --repo "\$\{\{ github\.repository \}\}" -n golden-images/);
|
||||
assert.match(golden, /node scripts\/golden-accept\.mjs "\$\{args\[@\]\}"/);
|
||||
});
|
||||
|
||||
test('#697 бот: сообщение коммита проходит провенанс и не выдаёт себя за кандидата', () => {
|
||||
const commit = step('Коммит в dev');
|
||||
assert.match(commit, /if \[ "\$GOLDEN_CHANGED" = "true" \]; then\n\s+echo "Release: \$TAG"\n\s+echo "Baseline-Reviewed: \$GOLDEN_URL"/,
|
||||
'эталоны без Release: и Baseline-Reviewed: провенанс отклонит');
|
||||
assert.match(commit, /echo "Issue: #697"\n\s+echo "User-Visible: no"/);
|
||||
const subject = 'docs: accept derived artifacts on dev for v1.79.0-beta.1';
|
||||
assert.match(commit, /echo "docs: accept derived artifacts on dev for \$TAG"/);
|
||||
assert.equal(isCandidateSubject(subject), false, 'бандл кандидата у коммита бота не сверяется');
|
||||
const golden = [subject, '', 'Производные артефакты беты.', '',
|
||||
'Release: v1.79.0-beta.1', 'Baseline-Reviewed: https://github.com/o/r/actions/runs/1', 'Issue: #697', 'User-Visible: no'].join('\n');
|
||||
assert.deepEqual(validateCommitMessage(golden, ['docs/images/screenshots.json', 'demo/golden/baselines/a.png']), []);
|
||||
const docsOnly = [subject, '', 'Производные артефакты беты.', '', 'Issue: #697', 'User-Visible: no'].join('\n');
|
||||
assert.deepEqual(validateCommitMessage(docsOnly, ['docs/images/screenshots.json']), []);
|
||||
assert.notDeepEqual(validateCommitMessage(docsOnly, ['demo/golden/baselines/a.png']), [], 'эталоны без провенанса — отказ');
|
||||
});
|
||||
@@ -236,3 +236,20 @@ test('#510 AC1 / #601 AC1: мутанты по диффу запрашивают
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), true);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true);
|
||||
});
|
||||
|
||||
test('#697: на ветке задачи трейлер Release: тяжёлый набор не включает, на dev — как прежде', () => {
|
||||
const acceptance = 'test: accept golden\n\nIssue: #687\nUser-Visible: no\nRelease: v1.78.0-beta.9\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1';
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), false);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'refs/heads/issue/687-x' }), false);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'dev' }), true, 'кандидат на dev');
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance }), true, 'без ветки — прежнее правило');
|
||||
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true', refName: 'issue/687-x' }), true,
|
||||
'ci:full/ci:golden — dispatch full=true на ветке задачи');
|
||||
assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), 'warn');
|
||||
const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], {
|
||||
encoding: 'utf8', env: { ...process.env, ...env },
|
||||
}).trim();
|
||||
assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: acceptance, REF_NAME: 'issue/687-x' }), 'heavy=false\nmutants_requested=false');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
assert.equal((workflow.match(/REF_NAME: \$\{\{ github\.ref_name \}\}/g) || []).length >= 2, true, 'оба вызова знают ветку');
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@ test('пакет задачи и конвейер читают трек одно
|
||||
|
||||
test('явная трековая метка главнее признака инфраструктуры (#696)', () => {
|
||||
const infra = ['scripts/x.mjs', '.github/workflows/y.yml'];
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, infrastructure: true });
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, infrastructure: false });
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, full: false, infrastructure: true });
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, full: false, infrastructure: false });
|
||||
assert.equal(resolveTrack({ labels: ['small'], files: ['src/a.ts'] }).track, 'show');
|
||||
});
|
||||
|
||||
@@ -168,3 +168,19 @@ test('конвейер: ship в рамках сливается без моде
|
||||
assert.match(env, /if: steps\.env_needs\.outputs\.deps == 'true'\n\s+run: npm ci/);
|
||||
assert.match(env, /if: steps\.env_needs\.outputs\.browser == 'true' && steps\.pw\.outputs\.cache-hit != 'true'/);
|
||||
});
|
||||
|
||||
test('#697: полный набор на ветке задачи — только по меткам ci:full и ci:golden', () => {
|
||||
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).full, false);
|
||||
assert.equal(resolveTrack({ labels: ['track:show', 'ci:golden'], files: ['src/a.ts'] }).full, true);
|
||||
assert.equal(resolveTrack({ labels: ['track:ask', 'ci:full'], files: ['src/a.ts'] }).full, true);
|
||||
assert.equal(resolveTrack({ labels: ['ci:mutants'], files: ['src/a.ts'] }).full, false, 'мутанты полного набора не заказывают');
|
||||
});
|
||||
|
||||
test('#697: конвейер передаёт полный набор гейту материала', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n'));
|
||||
assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/);
|
||||
assert.match(gate, /--full="\$\{FULL:-false\}"/);
|
||||
assert.match(workflow, /full=\$\(printf '%s\\n' "\$out" \| sed -n 's\/\^full=\/\/p'\)/);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
|
||||
});
|
||||
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
|
||||
},
|
||||
dispatch: async (ref, { mutants = true } = {}) => { dispatched.push(mutants ? ref : `${ref}:light`); },
|
||||
dispatch: async (ref, { mutants = true, full = false } = {}) => { dispatched.push(`${mutants ? ref : `${ref}:light`}${full ? ':full' : ''}`); },
|
||||
sleep: async (ms) => { clock += ms; },
|
||||
now: () => clock,
|
||||
},
|
||||
@@ -242,3 +242,17 @@ test('#696: a completed run beats a newer running dispatch only without mutants'
|
||||
assert.equal(proofCandidate(run({ event: 'push', status: 'in_progress' }), { mutants: false }), false);
|
||||
assert.equal(proofCandidate(run({ event: 'push' }), { mutants: true }), false);
|
||||
});
|
||||
|
||||
test('#697: ci:full/ci:golden — лёгкий push-прогон не доказательство, гейт диспатчит full=true', async () => {
|
||||
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
|
||||
const dispatchedRun = run({ databaseId: 8, status: 'in_progress', conclusion: null });
|
||||
const fake = fakeOps({ snapshots: [[done], [done], [done, dispatchedRun]], jobsById: { 7: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, full: true, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 8);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1:light:full']);
|
||||
// без метки тот же push-прогон — доказательство, и dispatch не нужен
|
||||
const light = fakeOps({ snapshots: [[done]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
|
||||
assert.deepEqual(light.dispatched, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user