Files
houseplan-card/test/nightly-workflow.test.mjs
T
Claudeandclaude[bot] e58d7d06f8 feat(process): nightly ship batch review, reused by the beta gate by patch set (#727)
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
2026-10-01 03:25:24 +00:00

165 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-ревью не запущено/);
});