mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
`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
119 lines
8.6 KiB
JavaScript
119 lines
8.6 KiB
JavaScript
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
|
||
// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
|
||
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath,
|
||
shipReviewProblems, specSection,
|
||
} from '../scripts/ship-review.mjs';
|
||
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
|
||
import { archivePlan } from '../scripts/reviews-archive.mjs';
|
||
|
||
const sha = (c) => c.repeat(40);
|
||
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
|
||
|
||
test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => {
|
||
assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md');
|
||
assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md');
|
||
for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) {
|
||
assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad);
|
||
}
|
||
});
|
||
|
||
test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => {
|
||
assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true);
|
||
assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true);
|
||
assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false);
|
||
assert.equal(isShipIssue({}), false);
|
||
});
|
||
|
||
test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => {
|
||
const commits = [
|
||
{ sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' },
|
||
{ sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' },
|
||
{ sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' },
|
||
{ sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' },
|
||
];
|
||
const data = {
|
||
701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] },
|
||
702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] },
|
||
};
|
||
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null });
|
||
assert.deepEqual(ship.map((i) => i.number), [701]);
|
||
assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории');
|
||
assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.');
|
||
const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship });
|
||
assert.match(brief, /### #701 · Опечатка/);
|
||
assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/);
|
||
assert.match(brief, new RegExp(sha('a')));
|
||
assert.equal(specSection('без раздела'), '');
|
||
});
|
||
|
||
test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => {
|
||
assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []);
|
||
const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null });
|
||
assert.match(problem, /#701/);
|
||
assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/);
|
||
});
|
||
|
||
test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => {
|
||
const tag = 'v1.79.0-beta.1';
|
||
const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`;
|
||
assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), {
|
||
tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2,
|
||
});
|
||
assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []);
|
||
const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) });
|
||
assert.equal(partial.length, 1);
|
||
assert.match(partial[0], /не покрывает ship-задачи #709/);
|
||
const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) });
|
||
assert.equal(high.length, 1);
|
||
assert.match(high[0], /High 2/);
|
||
assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/);
|
||
assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/);
|
||
});
|
||
|
||
test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => {
|
||
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' });
|
||
const md = renderIndex({ entries: [
|
||
{ name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] },
|
||
{ name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] },
|
||
{ name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] },
|
||
] });
|
||
const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия'));
|
||
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']);
|
||
assert.match(rows[1], /пакетное ревью ship · —/);
|
||
const plan = archivePlan({
|
||
names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'],
|
||
lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0',
|
||
});
|
||
assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']);
|
||
assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md'));
|
||
});
|
||
|
||
test('#696 _ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => {
|
||
// #716: тело из dev; тонкий `ship-review.yml` в main — кнопка и потолок прав.
|
||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url)), 'utf8');
|
||
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
|
||
assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает');
|
||
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');
|
||
const publish = workflow.slice(workflow.indexOf('\n publish:'));
|
||
assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель');
|
||
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/);
|
||
assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/);
|
||
assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/);
|
||
});
|
||
|
||
test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => {
|
||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8');
|
||
const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:'));
|
||
assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации');
|
||
const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
|
||
const main = local.slice(local.indexOf('const main = async'));
|
||
const check = main.indexOf("'scripts/ship-review.mjs', 'check'");
|
||
assert.ok(check > 0, 'локальная публикация зовёт тот же гейт');
|
||
assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже');
|
||
});
|