mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix(process): integrate runs every pipeline script from one dev snapshot (#749)
The body of _process.yml is read from dev (@dev, #623), so the flags and formats it passes to scripts are dev's. After "Опубликовать документ ревью" the working copy of job integrate is the task branch, and a show/ship branch with a clean merge is not rebased before review: its scripts/ may lag dev by days. review-doc-guard.mjs silently ignores unknown flags (the anchor lost #726 route and #737 usage), and a stale merge-candidate.mjs merges the old way. Only two calls (#723 push refusal, #726 route) were taken from dev, each with its own extraction, and on ship/reuse the remaining ones ran dev's version anyway: the script version depended on the path. Now one step right after setup-node extracts `git archive origin/dev scripts .github/workflows/validate.yml` into $RUNNER_TEMP/dev-tools and every repo script of the job runs from there via TOOLS (review-result-gate, review-doc-guard, reviews-index, merge-candidate, process-track route, status-label). validate.yml is part of the snapshot because workflow-jobs.mjs reads it relative to itself; without it ci-proof answers `failed (#622)` and every code merge would return to S6. The working copy stays the material: git, the document and paths are judged there. PROCESS.md §10.4 gets the paragraph "Скрипты конвейера — из dev": the model_review exception, merges of pipeline changes judged by dev's version, and compatible edits of the Validate proof contract. Tests: test/process-integrate-tools.test.mjs is the job contract (no step calls scripts/ from the working copy, every call goes through the snapshot, one archive from origin/dev with validate.yml, and the step as is yields a directory where ci-proof resolves the job contract); publish-push-refusal runs the publish step and the #413 step on real bash with a task branch whose review-doc-guard.mjs exits 7 (red with the old call). Existing harnesses take the snapshot step before the publish and decide steps; the #706 mutant anchor follows the status-label call. Issue: #749 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// #749: job `integrate` исполняет скрипты конвейера одним снимком `dev`.
|
||||
//
|
||||
// Тело `_process.yml` читается из `dev` (`@dev`, #623), значит, и флаги с
|
||||
// форматами, с которыми оно зовёт скрипты, — версии `dev`. Рабочая копия после
|
||||
// публикации документа — ветка задачи, а ветка show/ship с чистым слиянием до
|
||||
// ревью не ребейзится (§10.4) и может нести `scripts/`, отставшие на дни.
|
||||
// Здесь — контракт job: ни один шаг не зовёт скрипт из рабочей копии, каждый
|
||||
// вызов идёт через каталог снимка, снимок берётся из `origin/dev` и несёт
|
||||
// `validate.yml`, без которого `ci-proof.mjs` отвечает `failed (#622)` на
|
||||
// каждом слиянии. Самодостаточность снимка проверяется исполнением: шаг как
|
||||
// есть, настоящим bash и git, на временном origin с нынешними `scripts/`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml');
|
||||
const TOOLS_STEP = 'Скрипты конвейера — из dev (#749)';
|
||||
const TOOLS_ENV = 'TOOLS: ${{ steps.tools.outputs.dir }}';
|
||||
|
||||
/** Блок job: от ` <id>:` до следующей job на том же отступе. */
|
||||
function jobBlock(text, id) {
|
||||
const start = text.indexOf(`\n ${id}:\n`);
|
||||
assert.ok(start >= 0, `job ${id}`);
|
||||
const rest = text.slice(start + 1);
|
||||
const next = rest.slice(1).search(/\n {2}[\w-]+:\n/);
|
||||
return next < 0 ? rest : rest.slice(0, next + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаги job: имя, id, if, env и тело `run` так, как его прочтёт YAML (блок
|
||||
* кончается на первой непустой строке с отступом меньше тела). Комментарии
|
||||
* тела отброшены: судится то, что исполняется.
|
||||
*/
|
||||
function stepsOf(job) {
|
||||
const lines = job.slice(job.indexOf('\n steps:\n') + 1).split('\n').slice(1);
|
||||
const steps = [];
|
||||
for (const line of lines) {
|
||||
if (/^ {6}- /.test(line)) steps.push([line]);
|
||||
else if (steps.length) steps.at(-1).push(line);
|
||||
}
|
||||
return steps.map((stepLines) => {
|
||||
const text = stepLines.join('\n');
|
||||
const field = (key) => text.match(new RegExp(`^ {6}(?:- | {2})${key}: (.+)$`, 'm'))?.[1];
|
||||
const env = [];
|
||||
const envAt = stepLines.indexOf(' env:');
|
||||
if (envAt >= 0) {
|
||||
for (const line of stepLines.slice(envAt + 1)) {
|
||||
if (/^ {10}#/.test(line)) continue;
|
||||
if (!/^ {10}\S/.test(line)) break;
|
||||
env.push(line.trim());
|
||||
}
|
||||
}
|
||||
let run = '';
|
||||
const runAt = stepLines.indexOf(' run: |');
|
||||
if (runAt >= 0) {
|
||||
const body = [];
|
||||
for (const line of stepLines.slice(runAt + 1)) {
|
||||
if (line.trim() && !/^ {10}/.test(line)) break;
|
||||
body.push(line.slice(10));
|
||||
}
|
||||
run = body.join('\n');
|
||||
} else {
|
||||
run = field('run') ?? '';
|
||||
}
|
||||
const code = run.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
|
||||
return { text, name: field('name') ?? field('uses'), id: field('id'), if: field('if'), env, run, code };
|
||||
});
|
||||
}
|
||||
|
||||
const integrateSteps = () => stepsOf(jobBlock(readFileSync(WORKFLOW, 'utf8'), 'integrate'));
|
||||
|
||||
test('#749 AC2: в job integrate ни один шаг не зовёт скрипт из рабочей копии — только из снимка dev', () => {
|
||||
const steps = integrateSteps();
|
||||
const calls = [];
|
||||
for (const step of steps) {
|
||||
assert.doesNotMatch(step.code, /(?<!\$TOOLS\/)\bscripts\/[\w.-]+/,
|
||||
`«${step.name}»: repo-скрипт мимо снимка — рабочая копия здесь ветка задачи`);
|
||||
assert.doesNotMatch(step.code, /import\(\s*['"`]\.{0,2}\/?scripts\//, `«${step.name}»: import() скрипта рабочей копии`);
|
||||
if (step.name !== TOOLS_STEP) {
|
||||
assert.doesNotMatch(step.code, /\$tools\b|publish-tools|route-tools/, `«${step.name}»: своё извлечение скриптов вместо снимка job`);
|
||||
}
|
||||
const used = [...step.code.matchAll(/node "\$TOOLS\/scripts\/([\w.-]+\.mjs)"/g)].map((m) => m[1]);
|
||||
if (used.length || /\$TOOLS\b/.test(step.code)) {
|
||||
assert.ok(step.env.includes(TOOLS_ENV), `«${step.name}»: каталог снимка — из выхода шага tools`);
|
||||
}
|
||||
for (const script of used) calls.push(`${step.name}: ${script}`);
|
||||
}
|
||||
// Все вызовы, которые issue называет, — через снимок (#749 К2).
|
||||
const byScript = (name) => calls.filter((call) => call.endsWith(`: ${name}`)).length;
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(['review-result-gate.mjs', 'review-doc-guard.mjs', 'reviews-index.mjs', 'merge-candidate.mjs', 'process-track.mjs', 'status-label.mjs']
|
||||
.map((name) => [name, byScript(name)])),
|
||||
// review-doc-guard: якорь, рубеж индекса, два рубежа диапазона (до и после
|
||||
// ребейза) и --doc=- шага #413; merge-candidate: два разбора отказа push и слияние.
|
||||
{ 'review-result-gate.mjs': 1, 'review-doc-guard.mjs': 5, 'reviews-index.mjs': 1, 'merge-candidate.mjs': 3, 'process-track.mjs': 1, 'status-label.mjs': 1 },
|
||||
calls.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('#749 AC2: снимок — один на job, из origin/dev, с validate.yml, до первого шага со скриптом', () => {
|
||||
const steps = integrateSteps();
|
||||
const at = steps.findIndex((step) => step.name === TOOLS_STEP);
|
||||
assert.ok(at >= 0, `шаг «${TOOLS_STEP}»`);
|
||||
const snapshot = steps[at];
|
||||
assert.equal(snapshot.id, 'tools');
|
||||
const checkout = steps.find((step) => /^actions\/checkout@/.test(step.name));
|
||||
assert.equal(snapshot.if, checkout.if, 'снимок есть всякий раз, когда есть рабочая копия');
|
||||
assert.ok(steps.findIndex((step) => /^actions\/setup-node@/.test(step.name)) < at, 'после setup-node');
|
||||
const firstUser = steps.findIndex((step) => /\$TOOLS\b/.test(step.code));
|
||||
assert.ok(firstUser > at, 'до первого шага, который зовёт скрипт');
|
||||
assert.match(snapshot.code, /^set -euo pipefail$/m, 'сбой git archive не проходит молча через | tar');
|
||||
assert.match(snapshot.code, /^git fetch -q origin dev$/m);
|
||||
assert.match(snapshot.code, /^git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"$/m,
|
||||
'снимок из origin/dev; validate.yml читает workflow-jobs.mjs по пути от себя');
|
||||
assert.match(snapshot.code, /^echo "dir=\$tools" >> "\$GITHUB_OUTPUT"$/m);
|
||||
const archives = steps.flatMap((step) => [...step.code.matchAll(/git archive/g)].map(() => step.name));
|
||||
assert.deepEqual(archives, [TOOLS_STEP], 'одна версия скриптов на весь заход');
|
||||
});
|
||||
|
||||
const hasTools = () => process.platform !== 'win32'
|
||||
&& ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0);
|
||||
|
||||
// Окружение git без GIT_* родителя и без глобального конфига (урок #633, #496).
|
||||
const GIT_ENV = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
|
||||
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev',
|
||||
};
|
||||
|
||||
test('#749 AC2: снимок самодостаточен — шаг как есть даёт каталог, из которого ci-proof читает контракт validate.yml', async (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-749-tools-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const git = (cwd, ...args) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV });
|
||||
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
const origin = join(root, 'origin.git');
|
||||
const work = join(root, 'work');
|
||||
const temp = join(root, 'runner');
|
||||
mkdirSync(temp);
|
||||
git(root, 'init', '--bare', '-q', origin);
|
||||
git(root, 'clone', '-q', origin, work);
|
||||
git(work, 'checkout', '-q', '-b', 'dev');
|
||||
// dev временного origin несёт нынешние scripts/ и validate.yml — то, что
|
||||
// снимок возьмёт из dev настоящего.
|
||||
cpSync(join(ROOT, 'scripts'), join(work, 'scripts'), {
|
||||
recursive: true, filter: (src) => !['node_modules', '__pycache__'].includes(basename(src)),
|
||||
});
|
||||
mkdirSync(join(work, '.github', 'workflows'), { recursive: true });
|
||||
cpSync(join(ROOT, '.github', 'workflows', 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml'));
|
||||
git(work, 'add', '-A');
|
||||
git(work, 'commit', '-q', '-m', 'dev');
|
||||
git(work, 'push', '-q', 'origin', 'dev');
|
||||
const snapshot = integrateSteps().find((step) => step.name === TOOLS_STEP);
|
||||
assert.ok(snapshot, `шаг «${TOOLS_STEP}»`);
|
||||
const output = join(temp, 'output');
|
||||
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', snapshot.run], {
|
||||
cwd: work, encoding: 'utf8', env: { ...GIT_ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output },
|
||||
});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const dir = readFileSync(output, 'utf8').match(/^dir=(.+)$/m)?.[1];
|
||||
assert.ok(dir, 'шаг назвал каталог снимка');
|
||||
assert.ok(!dir.startsWith(work), 'снимок — вне рабочей копии');
|
||||
assert.ok(existsSync(join(dir, '.github', 'workflows', 'validate.yml')), 'validate.yml в снимке');
|
||||
const { resolveJobRules } = await import(pathToFileURL(join(dir, 'scripts', 'ci-proof.mjs')).href);
|
||||
assert.doesNotThrow(() => resolveJobRules(), 'контракт имён job читается из validate.yml снимка (#622)');
|
||||
// Каждый скрипт, который зовут шаги, импортируется из снимка без node_modules.
|
||||
for (const name of ['review-result-gate', 'review-doc-guard', 'reviews-index', 'merge-candidate', 'process-track', 'status-label']) {
|
||||
await import(pathToFileURL(join(dir, 'scripts', `${name}.mjs`)).href);
|
||||
}
|
||||
});
|
||||
@@ -611,6 +611,7 @@ function stepRun(workflow, marker) {
|
||||
const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n';
|
||||
const GUARD_STEP = ' - id: decide\n';
|
||||
const DECIDE_STEP = ' - name: Решение по вердикту\n';
|
||||
const TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#749)\n';
|
||||
const PUBLISH_STEP = ' - name: Опубликовать документ ревью\n';
|
||||
|
||||
test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => {
|
||||
@@ -713,6 +714,9 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
assert.ok(file.startsWith(SCRIPTS_DIR), `${file} вне scripts/`);
|
||||
writeFileSync(join(work, 'scripts', file.slice(SCRIPTS_DIR.length + 1)), readFileSync(file));
|
||||
}
|
||||
// #749: снимок скриптов integrate берёт из dev и validate.yml.
|
||||
mkdirSync(join(work, '.github', 'workflows'), { recursive: true });
|
||||
writeFileSync(join(work, '.github', 'workflows', 'validate.yml'), readFileSync(join(dirname(WORKFLOW), 'validate.yml')));
|
||||
mkdirSync(join(work, 'src', 'styles'), { recursive: true });
|
||||
writeFileSync(join(work, 'src', 'pointer-modality.ts'), 'export const a = 1;\nexport const b = 2;\n');
|
||||
writeFileSync(join(work, 'src', 'styles', 'plan.styles.ts'), 'export const css = `\n .x { color: red; }\n`;\n');
|
||||
@@ -740,6 +744,7 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
||||
let tools = '';
|
||||
return {
|
||||
work, fake,
|
||||
run(script, env) {
|
||||
@@ -749,7 +754,7 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
cwd: work, encoding: 'utf8',
|
||||
env: {
|
||||
...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, FAKE_DIR: fake, GH_TOKEN: 'x', NUM: '7',
|
||||
GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...env,
|
||||
GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...(tools ? { TOOLS: tools } : {}), ...env,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -766,6 +771,14 @@ function trackSandbox(t, { change, base = () => {} }) {
|
||||
if (list === null) rmSync(join(fake, 'labels'), { force: true });
|
||||
else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`);
|
||||
},
|
||||
/** #749: шаг снимка job integrate как есть; его каталог дальше идёт шагам как TOOLS. */
|
||||
snapshot() {
|
||||
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), TOOLS_STEP), {});
|
||||
assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}`);
|
||||
assert.ok(r.output.dir && existsSync(join(r.output.dir, 'scripts', 'process-track.mjs')), 'снимок несёт скрипт трека');
|
||||
tools = r.output.dir;
|
||||
return tools;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1107,8 +1120,9 @@ test('#726 AC5: шаг решения — один вызов process-track.mjs
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const run = stepRun(workflow, DECIDE_STEP);
|
||||
assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта');
|
||||
assert.match(run, /route=\$\(node "\$tools\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/);
|
||||
assert.match(run, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev');
|
||||
assert.match(run, /route=\$\(node "\$TOOLS\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/);
|
||||
// #749: скрипт — из снимка dev на всю job, своего извлечения у шага нет.
|
||||
assert.doesNotMatch(run, /git archive/, 'скрипт — из снимка dev');
|
||||
assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте');
|
||||
assert.match(run, /if \[ -n "\$add" \]; then edit\+=\(--add-label "\$add"\); fi\n\s+if \[ -n "\$remove" \]; then edit\+=\(--remove-label "\$remove"\); fi/);
|
||||
assert.match(run, /add=\$\(field add_labels\); remove=\$\(field remove_labels\); comment=\$\(field comment\)/);
|
||||
@@ -1121,7 +1135,7 @@ test('#726 AC5: шаг решения — один вызов process-track.mjs
|
||||
const step = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд'));
|
||||
for (const env of ['TRACK: ${{ needs.prepare.outputs.track }}', 'CONFIRMED: ${{ needs.prepare.outputs.confirmed }}',
|
||||
'SPENT: ${{ needs.guard.outputs.spent }}', 'LIMIT: ${{ needs.guard.outputs.limit }}', 'CYCLE: ${{ needs.guard.outputs.cycle }}',
|
||||
'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}']) {
|
||||
'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}', 'TOOLS: ${{ steps.tools.outputs.dir }}']) {
|
||||
assert.ok(step.includes(` ${env}\n`), env);
|
||||
}
|
||||
// Многострочного текста в новой ветке нет: heredoc — только прежний комментарий слияния ship.
|
||||
@@ -1147,6 +1161,7 @@ const LABELS_VIEW = 'issue view 7 --repo o/r --json labels --jq [.labels[].name]
|
||||
test('#726 AC5: шаг решения на настоящем bash — reclassify: ask, S3-spec, комментарий с hp:route и перечнем CODE-REVIEW', async (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
|
||||
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
|
||||
box.snapshot();
|
||||
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
|
||||
box.labels(['track:show', 'S7-code-review', 'P2']);
|
||||
const r = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) }));
|
||||
@@ -1177,6 +1192,7 @@ test('#726 AC5: шаг решения на настоящем bash — reclassif
|
||||
test('#726 AC5: шаг решения на настоящем bash — исчерпание, вопрос владельцу, fix, зелёный и сбой скрипта', async (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
|
||||
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
|
||||
box.snapshot();
|
||||
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
|
||||
box.labels(['track:show', 'S7-code-review']);
|
||||
const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md');
|
||||
|
||||
@@ -99,6 +99,9 @@ function stepRun(file, name) {
|
||||
|
||||
const RELEASE_STEP = () => stepRun('release-review.yml', 'Опубликовать документ');
|
||||
const REVIEW_DOC_STEP = () => stepRun('_process.yml', 'Опубликовать документ ревью');
|
||||
// #749: скрипты job integrate — одним снимком dev; шаги получают каталог выходом `dir`.
|
||||
const TOOLS_STEP = () => stepRun('_process.yml', 'Скрипты конвейера — из dev (#749)');
|
||||
const REPRO_STEP = () => stepRun('_process.yml', '"Материал раунда воспроизводим (#413)"');
|
||||
|
||||
/**
|
||||
* Песочница: bare origin, рабочая копия, соседний клон и bin с подменами.
|
||||
@@ -126,6 +129,9 @@ function sandbox(root) {
|
||||
// Скрипты шага — из репозитория как есть: их несёт dev временного origin.
|
||||
mkdirSync(join(work, 'scripts'));
|
||||
for (const file of STEP_SCRIPTS) copyFileSync(file, join(work, 'scripts', relative(SCRIPTS, file)));
|
||||
// #749: снимок скриптов integrate берёт из dev и validate.yml (его читает workflow-jobs.mjs).
|
||||
mkdirSync(join(work, '.github', 'workflows'), { recursive: true });
|
||||
copyFileSync(join(WORKFLOWS, 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml'));
|
||||
mkdirSync(join(work, 'docs', 'reviews'), { recursive: true });
|
||||
writeFileSync(join(work, 'docs', 'reviews', 'CODE-REVIEW-1-r1.md'), '# CODE-REVIEW-1-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
|
||||
writeFileSync(join(work, 'docs', 'reviews', 'INDEX.md'), buildIndex(join(work, 'docs', 'reviews')));
|
||||
@@ -303,12 +309,24 @@ function taskBranch(box) {
|
||||
git(box.work, 'checkout', '-q', 'dev');
|
||||
}
|
||||
|
||||
/**
|
||||
* #749: шаг снимка как есть — на рабочей копии dev, как после checkout в
|
||||
* integrate. Возвращает каталог из его выхода `dir`: его шаги получают env TOOLS.
|
||||
*/
|
||||
function devTools(box) {
|
||||
const r = box.run(TOOLS_STEP(), {});
|
||||
assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}${r.stdout}`);
|
||||
const dir = readFileSync(join(box.temp, 'output'), 'utf8').match(/^dir=(.+)$/m)?.[1];
|
||||
assert.ok(dir && existsSync(join(dir, 'scripts')), 'снимок назвал каталог со scripts/');
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runReviewDoc(box, out = '{"verdict":"green","high":0}', extra = {}) {
|
||||
const source = join(box.temp, 'review-result', 'review-document.md');
|
||||
mkdirSync(join(box.temp, 'review-result'));
|
||||
writeFileSync(source, '# Код-ревью #9, раунд 1\n\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
|
||||
return box.run(REVIEW_DOC_STEP(), {
|
||||
BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source,
|
||||
BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source, TOOLS: extra.TOOLS ?? devTools(box),
|
||||
MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`),
|
||||
MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out, ...extra,
|
||||
});
|
||||
@@ -429,6 +447,50 @@ test('#737 AC3 _process.yml на настоящем bash: ребейз и вто
|
||||
assert.equal(doc.split(USAGE).length - 1, 1, 'строка одна');
|
||||
});
|
||||
|
||||
// ---------- #749: скрипты job integrate — из снимка dev ----------
|
||||
|
||||
// Ветка show/ship с чистым слиянием до ревью не ребейзится и может нести
|
||||
// отставший review-doc-guard.mjs: такой молча терял флаги якоря (маршрут #726,
|
||||
// расход #737). Здесь её версия громкая — пишет маркер в документ и выходит 7.
|
||||
const BRANCH_GUARD = [
|
||||
"import { appendFileSync } from 'node:fs';",
|
||||
"const anchor = process.argv.find((arg) => arg.startsWith('--anchor='));",
|
||||
"if (anchor) appendFileSync(anchor.slice('--anchor='.length), '\\nBRANCH-VERSION\\n');",
|
||||
"appendFileSync(`${process.env.RUNNER_TEMP}/branch-version.log`, `BRANCH-VERSION ${process.argv.slice(2).join(' ')}\\n`);",
|
||||
'process.exit(7);',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('#749 AC1 _process.yml на настоящем bash: якорь и проверка #413 — версия dev, скрипт ветки задачи не исполняется', async (t) => {
|
||||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||||
const { ANCHOR_MARKER, materialAnchorsFrom } = await import('../scripts/review-doc-guard.mjs');
|
||||
const box = sandbox(tempRoot(t, 'hp-749-doc-'));
|
||||
git(box.work, 'checkout', '-q', '-b', BRANCH);
|
||||
writeFileSync(join(box.work, 'a.mjs'), 'export const a = 9;\n');
|
||||
writeFileSync(join(box.work, 'scripts', 'review-doc-guard.mjs'), BRANCH_GUARD);
|
||||
commitAll(box.work, 'fix: a (#9)');
|
||||
git(box.work, 'push', '-q', 'origin', BRANCH);
|
||||
git(box.work, 'checkout', '-q', 'dev');
|
||||
const tree = git(box.origin, 'rev-parse', `${BRANCH}^{tree}`);
|
||||
const branchLog = join(box.temp, 'branch-version.log');
|
||||
// Один снимок на job: его каталог получают и публикация, и шаг #413.
|
||||
const tools = devTools(box);
|
||||
const out = JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', route: 'reclassify', criterion: 'undocumented' });
|
||||
const r = runReviewDoc(box, out, { USAGE, TOOLS: tools });
|
||||
assert.equal(r.status, 0, r.stderr + r.stdout);
|
||||
assert.ok(!existsSync(branchLog), `скрипт ветки задачи исполнялся: ${existsSync(branchLog) ? readFileSync(branchLog, 'utf8') : ''}`);
|
||||
const doc = git(box.origin, 'show', `${BRANCH}:${REVIEW_DOC}`);
|
||||
assert.doesNotMatch(doc, /BRANCH-VERSION/);
|
||||
assert.ok(doc.includes(ANCHOR_MARKER), 'машинный блок якорей');
|
||||
assert.match(doc, /^- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` \(критерий `undocumented`\)$/m, 'маршрут — из OUT');
|
||||
assert.deepEqual(materialAnchorsFrom(doc), [tree]);
|
||||
assert.equal(lastLine(doc), USAGE, 'флаг --usage понят: версия dev');
|
||||
// Шаг #413 на том же origin: опубликованный документ судит та же версия dev.
|
||||
const repro = box.run(REPRO_STEP(), { NUM: '9', STAGE: 'code', CYCLE: '1', BRANCH, TOOLS: tools });
|
||||
assert.equal(repro.status, 0, repro.stderr + repro.stdout);
|
||||
assert.ok(!existsSync(branchLog), 'шаг #413 не исполнял скрипт ветки задачи');
|
||||
});
|
||||
|
||||
// ---------- #730 _ship-review.yml: SHIP-REVIEW в dev ----------
|
||||
|
||||
const SHIP_STEP = () => stepRun('_ship-review.yml', 'Опубликовать документ');
|
||||
@@ -601,14 +663,15 @@ for (const [label, stderr, kind, reason] of [
|
||||
// ---------- AC3 и разбор: тексты — из кода, не из run ----------
|
||||
|
||||
test('#723 AC3: в run обоих шагов нет многострочного текста и heredoc; отказ разбирает код слияния', () => {
|
||||
for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$tools/scripts']]) {
|
||||
for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$TOOLS/scripts']]) {
|
||||
assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${label}: heredoc в run`);
|
||||
assert.ok(body.includes(`kind=$(node ${tools}/merge-candidate.mjs`), `${label}: разбор — merge-candidate.mjs --push-refusal`);
|
||||
assert.match(body, /--push-refusal="\$push_err"[^\n]*\\\n[^\n]*--summary="\$GITHUB_STEP_SUMMARY"\) \|\| kind=unknown/, `${label}: сводку пишет код`);
|
||||
assert.match(body, /2> "\$push_err"; then/, `${label}: stderr push идёт в разбор`);
|
||||
}
|
||||
// Шаг _process.yml берёт разбор из dev: ветка задачи, отставшая от dev, его может не нести.
|
||||
assert.match(REVIEW_DOC_STEP(), /git archive origin\/dev scripts \| tar -x -C "\$tools"/);
|
||||
// Шаг _process.yml берёт разбор из снимка dev (#749): ветка задачи, отставшая от dev, его может не нести.
|
||||
assert.doesNotMatch(REVIEW_DOC_STEP(), /git archive/, 'своего извлечения у шага нет — снимок job');
|
||||
assert.match(TOOLS_STEP(), /git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"/);
|
||||
// Блок run не обрезан: последняя строка каждого шага на месте.
|
||||
assert.match(RELEASE_STEP(), /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
|
||||
assert.match(REVIEW_DOC_STEP(), /echo "документ опубликован в \$target: \$doc"\n*$/);
|
||||
|
||||
@@ -77,7 +77,7 @@ test('шаг публикации в конвейере проверяет и и
|
||||
// Два рубежа: что проиндексировано и что пуш добавит в ветку. Расходились они
|
||||
// именно тогда, когда база оказывалась не той.
|
||||
assert.equal(
|
||||
(step.match(/git diff --cached --name-only \| node scripts\/review-doc-guard\.mjs/g) || []).length,
|
||||
(step.match(/git diff --cached --name-only \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length,
|
||||
1, 'индекс проверяется один раз, перед коммитом',
|
||||
);
|
||||
// Дважды: push делается из двух мест — сразу и после ребейза при гонке. Одна
|
||||
@@ -85,7 +85,7 @@ test('шаг публикации в конвейере проверяет и и
|
||||
// именно он срабатывает, когда dev ушёл вперёд — то есть в тех самых
|
||||
// условиях, при которых случился bb2919f.
|
||||
assert.equal(
|
||||
(step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node scripts\/review-doc-guard\.mjs/g) || []).length,
|
||||
(step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length,
|
||||
2, 'диапазон проверяется перед каждым push',
|
||||
);
|
||||
// Свежая база вместо той, что лежала здесь сорок минут назад.
|
||||
@@ -903,7 +903,7 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
// YAML поставить было некуда (`test/review-result-gate.test.mjs`). Здесь
|
||||
// проверяется, что привилегированная стадия ходит через него и передаёт ему
|
||||
// весь паспорт, а не его часть.
|
||||
assert.match(integrate, /node scripts\/review-result-gate\.mjs --dir="\$dir"/);
|
||||
assert.match(integrate, /node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"/);
|
||||
for (const field of ['MATERIAL_SHA', 'MATERIAL_TREE', 'STAGE', 'CYCLE', 'BRANCH', 'ISSUE']) {
|
||||
assert.match(integrate, new RegExp(`^\\s+${field}: `, 'm'), `${field} передаётся гейту`);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ test('#556: integrate пропускает artifact только через ге
|
||||
integrate.indexOf(' # Ревьюер пишет только в docs/reviews/.'),
|
||||
);
|
||||
assert.ok(step.length > 0, 'шаг проверки найден');
|
||||
assert.match(step, /^\s+node scripts\/review-result-gate\.mjs --dir="\$dir"$/m);
|
||||
assert.match(step, /^\s+node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"$/m);
|
||||
for (const field of PASSPORT_FIELDS) {
|
||||
if (field === 'run_id' || field === 'run_attempt') continue; // приходят из GITHUB_*
|
||||
assert.match(step, new RegExp(`^\\s+${field.toUpperCase()}: `, 'm'), `${field} передаётся гейту`);
|
||||
|
||||
@@ -145,8 +145,8 @@ test('#635/#657 (1б): индекс пересобирается только к
|
||||
const wf = new URL('../.github/workflows/_process.yml', import.meta.url);
|
||||
const text = readFileSync(wf, 'utf8');
|
||||
// Публикация документа: индекс — тем же коммитом, только если цель — dev
|
||||
// (ревью ТЗ). В ветку задачи — один документ.
|
||||
assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/);
|
||||
// (ревью ТЗ). В ветку задачи — один документ. Скрипт — из снимка dev (#749).
|
||||
assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node "\$TOOLS\/scripts\/reviews-index\.mjs" --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/);
|
||||
// Приведение ветки к dev индекс больше не коммитит: ветка задачи его не несёт.
|
||||
const rebase = text.slice(text.indexOf('- name: Привести ветку к dev'), text.indexOf('- name: Зафиксировать SHA материала ревью'));
|
||||
assert.doesNotMatch(rebase, /reviews-index\.mjs/, 'в ветке задачи индекс не пересобирается (#657)');
|
||||
|
||||
@@ -51,6 +51,7 @@ test('#706 шаг конвейера переставляет метку чер
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_process.yml', import.meta.url)), 'utf8');
|
||||
const step = workflow.slice(workflow.indexOf('- name: Переставить метку'), workflow.indexOf('- name: Сводка длительности стадий'));
|
||||
assert.ok(step.length > 0, 'шаг найден');
|
||||
assert.match(step, /node scripts\/status-label\.mjs --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/);
|
||||
// #749: скрипт — из снимка dev job integrate, не из рабочей копии ветки задачи.
|
||||
assert.match(step, /node "\$TOOLS\/scripts\/status-label\.mjs" --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/);
|
||||
assert.doesNotMatch(step, /gh issue edit/, 'совмещённый вызов снимал ту же метку, которую ставил');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user