mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
Ship tasks merge without a model review and their code was first read by the batch review right before a beta: one session over the whole range, ten to forty-five minutes on the release path, days after the merge. The gate also knew a single document (SHIP-REVIEW-<tag>.md) and covered tasks by number only, so a commit that landed after the review under the same trailer still counted as read. - scripts/ship-review.mjs: the patch set of a task is the sorted `git patch-id --stable` of its range commits, without `Release:` commits (the beta candidate carries every Issue: of the line) and commits touching only docs/reviews/**; the diff options are explicit so a local git config cannot change it. shipCoverage rates every ship task from the documents of the same base (candidate and origin/dev, latest publication wins): clean, high, stale, none; documents without `patches` cover by number. `tag=nightly` is a reserved mode: the candidate is required, the document is SHIP-REVIEW-<base>-dev-<sha12>.md, only none/stale tasks are read and nothing runs when nothing is uncovered. The beta reads the same delta (force=true reads everything, as before); the brief names what the night already read. The gate refuses none/stale with the command and keeps the High refusal with force=true; all clean passes without a tag document. The machine block gains `mode` and `patches` at its end. comment-high writes one line per task of a nightly document with High, once per document (hp:ship-review-high). - _ship-review.yml: prepare refuses nightly without a candidate before defaulting to the dev tip, computes the document from base and SHA and no longer reads a prepare failure behind `| tee` as "no ship tasks"; publish takes mode and patches from prepare, never from the model result; a new step comments High at night with HP_PROCESS_TOKEN. - _nightly.yml: the Validate run SHA is a separate step output before the wait; a new job dispatches ship-review.yml -f tag=nightly on it whatever Validate's outcome, waits only for the run to appear and never colours the night. Thin files in main are unchanged. - reviews-index/reviews-archive: the nightly name is a ship document with nightly: true; a beta base archives with its line, a stable base with the nearest archived line newer than the base, or stays. - PROCESS.md §11.7, §10.4 and REVIEWER.md describe the nightly mode, patch set, coverage and beta delta; the digest test pins the key rule. Tests run the prepare, publish and comment steps and the nightly steps on real bash with real git in temporary repositories; only push transport and gh are faked. Issue: #727 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
165 lines
11 KiB
JavaScript
165 lines
11 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его
|
||
// исход — успешный dispatch не равен успешной проверке.
|
||
|
||
const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8');
|
||
|
||
test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => {
|
||
const nightly = read('_nightly.yml');
|
||
assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/);
|
||
// найти именно свой прогон: dispatch на dev, созданный не раньше запуска
|
||
assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/);
|
||
assert.match(nightly, /--event workflow_dispatch/);
|
||
assert.match(nightly, /createdAt >= /);
|
||
// отсутствие прогона — ошибка, не тихий успех
|
||
assert.match(nightly, /прогон Validate не появился[^\n]*\n\s+exit 1/);
|
||
// ждать с наследованием кода возврата
|
||
assert.match(nightly, /gh run watch "\$run_id" --repo "\$REPO" --exit-status/);
|
||
assert.match(nightly, /timeout-minutes: 90/);
|
||
assert.match(nightly, /set -euo pipefail/);
|
||
});
|
||
|
||
test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => {
|
||
const nightly = read('_nightly.yml');
|
||
assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/);
|
||
assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом');
|
||
});
|
||
|
||
// ---------- #727 К6: ночное пакетное ревью ship после Validate ----------
|
||
|
||
/** Блок job верхнего уровня `jobs:` по имени. */
|
||
function job(text, name) {
|
||
const start = text.indexOf(`\n ${name}:\n`);
|
||
assert.ok(start >= 0, `job ${name}`);
|
||
const rest = text.slice(start + 1);
|
||
const end = rest.slice(1).search(/\n [a-z_]+:\n/);
|
||
return end < 0 ? rest : rest.slice(0, end + 1);
|
||
}
|
||
|
||
/** Тело `run:` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */
|
||
function stepRun(text, name) {
|
||
const start = text.indexOf(` - name: "${name}"\n`);
|
||
assert.ok(start >= 0, `шаг «${name}»`);
|
||
const lines = text.slice(start).split('\n');
|
||
const from = lines.indexOf(' run: |');
|
||
assert.ok(from > 0, `у шага «${name}» есть run: |`);
|
||
const body = [];
|
||
for (const line of lines.slice(from + 1)) {
|
||
if (line.trim() && !/^ {10}/.test(line)) break;
|
||
body.push(line.replace(/^ {10}/, ''));
|
||
}
|
||
return body.join('\n').replace(/\$\{\{ github\.server_url \}\}/g, 'https://github.com');
|
||
}
|
||
|
||
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
|
||
|
||
/**
|
||
* Шаг на настоящем bash с подменённым `gh`: `workflow run` — в журнал (или отказ
|
||
* при FAKE_DISPATCH=fail), `run list --jq …` — FAKE_RUN_ID (то, что вернул бы
|
||
* фильтр), `run view --jq .headSha` — FAKE_HEAD, `run watch` — код FAKE_WATCH.
|
||
*/
|
||
function runStep(t, script, env = {}) {
|
||
const root = mkdtempSync(join(tmpdir(), 'hp-727-night-'));
|
||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||
const bin = join(root, 'bin');
|
||
mkdirSync(bin);
|
||
writeFileSync(join(bin, 'gh'), [
|
||
'#!/usr/bin/env bash',
|
||
'echo "gh $*" >> "$FAKE_LOG"',
|
||
'case "$1 $2" in',
|
||
' "workflow run") [ "${FAKE_DISPATCH:-ok}" = ok ] || { echo "HTTP 403" >&2; exit 1; } ;;',
|
||
' "run list") printf "%s\\n" "${FAKE_RUN_ID:-}" ;;',
|
||
' "run view") printf "%s\\n" "$FAKE_HEAD" ;;',
|
||
' "run watch") exit "${FAKE_WATCH:-0}" ;;',
|
||
' *) echo "unexpected gh $*" >&2; exit 1 ;;',
|
||
'esac',
|
||
'',
|
||
].join('\n'), { mode: 0o755 });
|
||
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "sleep $*" >> "$FAKE_LOG"\n', { mode: 0o755 });
|
||
const files = { log: join(root, 'log'), output: join(root, 'output'), summary: join(root, 'summary.md') };
|
||
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', script], {
|
||
encoding: 'utf8',
|
||
env: {
|
||
...process.env, PATH: `${bin}:${process.env.PATH}`, REPO: 'o/r', GH_TOKEN: 'x',
|
||
FAKE_LOG: files.log, GITHUB_OUTPUT: files.output, GITHUB_STEP_SUMMARY: files.summary, ...env,
|
||
},
|
||
});
|
||
const text = (path) => { try { return readFileSync(path, 'utf8'); } catch { return ''; } };
|
||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, log: text(files.log).split('\n').filter(Boolean), output: text(files.output), summary: text(files.summary) };
|
||
}
|
||
|
||
const HEAD = 'abcdef0123456789abcdef0123456789abcdef01';
|
||
|
||
test('#727 AC6 К6: ночь после Validate при любом его исходе запускает ship-ревью на SHA прогона Validate', () => {
|
||
const nightly = read('_nightly.yml');
|
||
const dispatch = job(nightly, 'dispatch');
|
||
const ship = job(nightly, 'ship_review');
|
||
assert.match(ship, /\n needs: dispatch\n/);
|
||
assert.match(ship, /\n if: always\(\)\n/, 'при любом исходе Validate');
|
||
assert.match(ship, /\n continue-on-error: true\n/, 'цвет ночи — цвет Validate');
|
||
assert.match(ship, /gh workflow run ship-review\.yml --ref dev -f tag=nightly -f candidate="\$CANDIDATE"/);
|
||
assert.match(ship, /CANDIDATE: \$\{\{ needs\.dispatch\.outputs\.head_sha \}\}/, 'SHA — из прогона Validate');
|
||
assert.doesNotMatch(ship, /gh run watch/, 'ждёт только появления прогона, не конца');
|
||
assert.match(ship, /for _ in \$\(seq 1 18\); do\n\s+sleep 10/, 'до трёх минут, как у Validate');
|
||
assert.doesNotMatch(ship, /permissions:/, 'права — ночи (actions: write), тонкий файл не меняется');
|
||
// SHA прогона Validate выводится отдельным шагом до ожидания.
|
||
assert.match(dispatch, /head_sha: \$\{\{ steps\.validate\.outputs\.head_sha \}\}/);
|
||
const found = dispatch.indexOf(' - name: "Запустить Validate и найти его прогон"\n id: validate\n');
|
||
const watch = dispatch.indexOf(' - name: "Дождаться Validate"');
|
||
assert.ok(found > 0 && watch > found, 'вывод SHA — до шага ожидания');
|
||
assert.match(stepRun(nightly, 'Запустить Validate и найти его прогон'), /head_sha=\$\(gh run view "\$run_id" --repo "\$REPO" --json headSha --jq \.headSha\)\necho "run_id=\$run_id" >> "\$GITHUB_OUTPUT"\necho "head_sha=\$head_sha" >> "\$GITHUB_OUTPUT"/);
|
||
assert.doesNotMatch(stepRun(nightly, 'Запустить Validate и найти его прогон'), /gh run watch/);
|
||
for (const name of ['Запустить Validate и найти его прогон', 'Дождаться Validate', 'Запустить ship-ревью и дождаться появления прогона']) {
|
||
const body = stepRun(nightly, name);
|
||
assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, `bash -n: ${name}`);
|
||
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${name}: heredoc в run`);
|
||
}
|
||
});
|
||
|
||
test('#727 AC6 на настоящем bash: SHA прогона Validate — в выходах до ожидания; красный Validate — красная job ожидания', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const nightly = read('_nightly.yml');
|
||
const found = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '42', FAKE_HEAD: HEAD });
|
||
assert.equal(found.status, 0, found.stderr);
|
||
assert.match(found.output, /^run_id=42$/m);
|
||
assert.match(found.output, new RegExp(`^head_sha=${HEAD}$`, 'm'));
|
||
assert.ok(found.log.includes('gh workflow run validate.yml --repo o/r --ref dev -f full=true'));
|
||
assert.ok(!found.log.some((call) => call.startsWith('gh run watch')), 'шаг вывода не ждёт');
|
||
const red = runStep(t, stepRun(nightly, 'Дождаться Validate'), { RUN_ID: '42', FAKE_WATCH: '1' });
|
||
assert.equal(red.status, 1, 'красный Validate — красная ночь');
|
||
assert.ok(red.log.includes('gh run watch 42 --repo o/r --exit-status --interval 30'));
|
||
const lost = runStep(t, stepRun(nightly, 'Запустить Validate и найти его прогон'), { FAKE_RUN_ID: '' });
|
||
assert.equal(lost.status, 1);
|
||
assert.match(lost.stdout, /::error::прогон Validate не появился за 3 минуты/);
|
||
});
|
||
|
||
test('#727 AC6 на настоящем bash: ship-ревью ночью — dispatch с tag=nightly, ждёт только появления; сбой — предупреждение', (t) => {
|
||
if (!hasBash()) { t.skip('bash недоступен'); return; }
|
||
const step = stepRun(read('_nightly.yml'), 'Запустить ship-ревью и дождаться появления прогона');
|
||
const ok = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '77' });
|
||
assert.equal(ok.status, 0, ok.stderr);
|
||
assert.equal(ok.log[0], `gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate=${HEAD} --repo o/r`);
|
||
assert.ok(ok.log.some((call) => call.startsWith('gh run list --repo o/r --workflow ship-review.yml --branch dev --event workflow_dispatch')));
|
||
assert.ok(!ok.log.some((call) => call.startsWith('gh run watch')), 'конца прогона не ждёт');
|
||
assert.equal(ok.summary, `- Ship-ревью ночью (\`${HEAD}\`): https://github.com/o/r/actions/runs/77\n`);
|
||
// Прогон не появился за три минуты — предупреждение, не красная ночь.
|
||
const late = runStep(t, step, { CANDIDATE: HEAD, FAKE_RUN_ID: '' });
|
||
assert.equal(late.status, 0);
|
||
assert.equal(late.log.filter((call) => call === 'sleep 10').length, 18);
|
||
assert.match(late.stdout, /::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится/);
|
||
// Dispatch отклонён — предупреждение; SHA нет (Validate не появился) — dispatch не делается.
|
||
const refused = runStep(t, step, { CANDIDATE: HEAD, FAKE_DISPATCH: 'fail' });
|
||
assert.equal(refused.status, 0);
|
||
assert.match(refused.stdout, /::warning::ship-ревью ночью не запущено: dispatch отклонён/);
|
||
const empty = runStep(t, step, { CANDIDATE: '' });
|
||
assert.equal(empty.status, 0);
|
||
assert.deepEqual(empty.log, []);
|
||
assert.match(empty.stdout, /::warning::нет SHA прогона Validate — ночное ship-ревью не запущено/);
|
||
});
|