Files
houseplan-card/test/nightly-workflow.test.mjs
T
Claudeandclaude[bot] 17b7b0ad73 feat(process): a red night comments on the tasks merged since the last green one (#736)
A red nightly Validate was a signal "to the author of the latest dev
commits" that nobody received: the red run was visible only in Actions,
and nobody computed who the author was.

- scripts/night-red.mjs: acts only on conclusion=failure of the red run
  (cancelled, timed_out and the rest are a summary line). The last green
  night is the newest of the last 50 Validate workflow_dispatch runs on
  dev that completed successfully, was created before the red run, sits
  on an ancestor of the red SHA and has a green ci-proof under the
  release policy, so a light green run (stale) never counts. Suspects
  are the Issue: trailers of `git rev-list --no-merges G..R` commits that
  touch a class A/B file and carry no Release: trailer: docs-only and
  beta-candidate commits do not count, a branch merged by a merge commit
  brings its second-parent commits, a commit without a trailer is a
  "no task" summary line, an empty range means a likely flake. One
  comment per task names both runs, up to ten of its commits and the
  failed jobs, says "suspect, not guilty" and ends with the marker
  hp:night-red green=<G> red=<R> commits=<all sha12>. No comment goes to
  a closed task or to a task whose marker with the same green already
  lists all its current range commits: one comment per series of red
  nights until the task commits again; a green night starts a new series.
  Failures become a ::warning:: and a summary line, exit code 0.
- _nightly.yml: dispatch also outputs run_id; a new job night_red runs
  after it only when dispatch failed with a known run, continue-on-error,
  permissions actions: read and contents: read (the union with the other
  jobs is unchanged, thin files in main are untouched), checks out dev
  with full history without blobs, reads Actions with github.token and
  writes issues with HP_PROCESS_TOKEN. The header names the addressee.
- PROCESS.md §10.4: the "Красная ночь" paragraph next to the nightly
  ship review.

Tests run the scripted rules on real git in temporary repositories with
real ci-proof fixtures, and the workflow step on real bash with a local
Actions API server and a fake gh.

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

203 lines
14 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-ревью не запущено/);
});
// ---------- #736: красная ночь — комментарий в задачи диапазона ----------
test('#736 AC3: night_red после dispatch только при красном Validate с прогоном; права — чтение, issue — HP_PROCESS_TOKEN', () => {
const nightly = read('_nightly.yml');
const dispatch = job(nightly, 'dispatch');
const red = job(nightly, 'night_red');
// Прогон Validate — выход dispatch тем же шагом, что SHA, до ожидания.
assert.match(dispatch, /\n run_id: \$\{\{ steps\.validate\.outputs\.run_id \}\}\n/);
assert.match(red, /\n needs: dispatch\n/);
assert.match(red, /\n if: always\(\) && needs\.dispatch\.result == 'failure' && needs\.dispatch\.outputs\.run_id != ''\n/,
'только красный dispatch с найденным прогоном');
assert.match(red, /\n continue-on-error: true\n/, 'цвет ночи — цвет Validate');
assert.match(red, /\n permissions:\n actions: read\n contents: read\n steps:\n/, 'права job — только чтение');
assert.equal((red.match(/permissions:/g) || []).length, 1);
// Checkout dev со всей историей без блобов; скрипту npm ci не нужен.
assert.match(red, /uses: actions\/checkout@[0-9a-f]{40} # v\d+\n with:\n ref: dev\n fetch-depth: 0\n filter: blob:none\n persist-credentials: false\n/);
assert.match(red, /uses: actions\/setup-node@[0-9a-f]{40} # v\d+\n with:\n node-version: 22\n/);
assert.doesNotMatch(red.replace(/^\s*#.*$/gm, ''), /npm (ci|install)/);
// Actions — токеном ночи, issue — токеном процесса.
assert.match(red, /\n ACTIONS_TOKEN: \$\{\{ github\.token \}\}\n/);
assert.match(red, /\n GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}\n/);
assert.match(red, /\n RED_RUN: \$\{\{ needs\.dispatch\.outputs\.run_id \}\}\n/);
const body = stepRun(nightly, 'Комментарий в задачи диапазона от последней зелёной ночи до красной');
assert.match(body, /node scripts\/night-red\.mjs --repo="\$REPO" --red-run="\$RED_RUN"/);
assert.equal(spawnSync('bash', ['-n', '-c', body]).status, 0, 'bash -n');
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, 'heredoc в run');
// Канон: адресат сигнала в шапке и абзац в PROCESS.md §10.4.
assert.match(nightly.slice(0, nightly.indexOf('\nname:')), /Адресат сигнала \(#736\)[\s\S]*night_red/);
const canon = readFileSync(new URL('../PROCESS.md', import.meta.url), 'utf8');
const section = canon.slice(canon.indexOf('### 10.4 '), canon.indexOf('\n## 11. '));
const paragraph = section.slice(section.indexOf('**Красная ночь** (#736)'));
assert.ok(section.includes('**Красная ночь** (#736)'), 'абзац в §10.4');
for (const key of ['`night_red`', '`scripts/night-red.mjs`', 'conclusion: failure', '`ci-proof`', '`release`', '--no-merges',
'`Release:`', '`hp:night-red', '`HP_PROCESS_TOKEN`', 'серия']) {
assert.ok(paragraph.slice(0, paragraph.indexOf('\n\n')).includes(key), `абзац называет ${key}`);
}
});