Files
houseplan-card/test/beta-derived.test.mjs
T
Claudeandclaude[bot] d11ad9c1c2 ci: register ship-review and beta-derived as thin callers in main (#716)
`workflow_dispatch` runs the file from the chosen ref, but GitHub lists a
workflow and accepts a dispatch (button, `gh workflow run`, API) only when
its file exists on the default branch. `ship-review.yml` (#696) and
`beta-derived.yml` (#697) lived only in `dev`, so neither could be started
at all, and the comment "the file runs from `--ref dev`, no mirror in
`main` needed" was wrong. Both beta steps are needed before the next
promotion would bring them to `main`.

They now follow the #623 layout instead of a full copy in `main`: a thin
caller (trigger, dispatch inputs, run-name, permission ceiling, concurrency)
calls `_ship-review.yml` / `_beta-derived.yml` at `@dev` with
`secrets: inherit`. A full copy would either need a mirror on every edit or
drift silently, and a dispatch from `main` (the button's default) would run
the stale copy; the thin caller runs the dev body from any ref. The caller
ceiling is the union of the body jobs' permissions (#556): ship-review
`contents: read` + `issues: read`, beta-derived `contents: read` +
`actions: read`; writes to `dev` stay with HP_PROCESS_TOKEN as before.

`workflow_sync` in validate.yml now compares eight files, and
test/default-branch-workflows.test.mjs lists the two dispatch-only files
explicitly with the reason checked (only `workflow_dispatch`). Workflow
tests and the #697 provenance mutant read the bodies. PROCESS.md §10.4,
§8 and §11.7 say how these are run and that a new thin file is mirrored
into `main` before it is merged into `dev`.

Issue: #716
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 21:01:10 +00:00

70 lines
5.1 KiB
JavaScript

// #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 readWorkflow = (name) => readFileSync(fileURLToPath(new URL(`../.github/workflows/${name}`, import.meta.url)), 'utf8');
// #716: кнопка — у тонкого вызывающего, который лежит и в `main`; шаги — в теле из `dev`.
const CALLER = readWorkflow('beta-derived.yml');
const WORKFLOW = readWorkflow('_beta-derived.yml');
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(CALLER, /^on:\n workflow_dispatch:\n/m);
assert.match(CALLER, /uses: Matysh\/houseplan-card\/\.github\/workflows\/_beta-derived\.yml@dev\b/);
assert.match(WORKFLOW, /^on:\n(?: {2}#[^\n]*\n)* {2}workflow_call:\n/m);
for (const text of [CALLER, WORKFLOW]) {
assert.doesNotMatch(text, /^\s+(push|schedule|workflow_run):/m);
assert.match(text, /permissions:\n\s+contents: read\n\s+actions: read\n/);
assert.doesNotMatch(text, /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']), [], 'эталоны без провенанса — отказ');
});