Files
houseplan-card/test/rebase-generated.test.mjs
T
Claude 562313944f fix(process): ship review and beta-derived pushes tell a GitHub refusal from a moved dev (#730)
After #705 and #723 two more workflow bodies still treated every failed
push as a moved dev: the SHIP-REVIEW publication (_ship-review.yml) retried
three times with "dev went ahead", and the derived-artifacts bot commit
(_beta-derived.yml) told the release manager to rerun the workflow. A
refusal by GitHub itself - a token without the workflow right, a branch
rule, a hook - is cured by neither, and neither step said what GitHub
answered.

Both pushes now keep stderr and hand it to the #705 classifier through the
same CLI (merge-candidate.mjs --push-refusal). A stale lease keeps the old
behaviour: another attempt for the ship review, the rerun advice for the
derived artifacts. Any other outcome stops the step at once: the log gets
the git answer and the step summary gets the reason and the git answer
without secrets (--summary, refusalSummary with the new ship-review and
beta-derived labels). The classifier comes from dev, as for the other steps
of these bodies: both jobs check out dev, and the ship review resets to
origin/dev before every attempt. The ship review commit message is built
line by line into a file instead of a heredoc, as in #723. The thin callers
ship-review.yml and beta-derived.yml are untouched.

The rebase guard in _process.yml also writes the refusal reason to its step
summary now (--summary, label "rebase"); a stale lease writes none.

test/publish-push-refusal.test.mjs runs both steps as they are with real
bash and real git in temporary repositories (moved dev = a real neighbour
push, GitHub refusal = recorded stderr with a token, a credential URL and
an Authorization header); on the old bodies 10 of its 12 new tests fail.
The #705 execution tests of the rebase guard in rebase-generated.test.mjs
now also read the step summary. PROCESS.md names the two steps next to the

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

489 lines
31 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 { execFileSync, spawnSync } from 'node:child_process';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { REVIEWS_INDEX_PATH, UPSTREAM_WINS, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
import { PATCH_ID_EXCLUDES } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
// #643: doc-коммит ветки задачи конфликтует с dev только в генерируемом
// `docs/reviews/INDEX.md` — ребейз решает это пересборкой; любой другой
// конфликт — прежний отказ с перечнем. Сценарии — настоящий git во временных
// репозиториях.
//
// Окружение git — без единой GIT_* переменной родителя (урок #633): тест,
// запущенный из pre-push хука, иначе унаследует GIT_DIR и будет ребейзить
// репозиторий хука, а не временный. Личность и переводы строк — своим
// окружением, глобальный конфиг владельца не трогается (#496).
const cleanGitEnv = () => ({
...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: '2',
GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'core.eol', GIT_CONFIG_VALUE_1: 'lf',
});
const ENV = cleanGitEnv();
if (process.platform === 'win32') delete ENV.GIT_CONFIG_GLOBAL;
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', env: ENV, stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
const REVIEWS = 'docs/reviews';
const doc = (work, name, verdict = 'зелёный') => writeFileSync(join(work, REVIEWS, name), `# ${name}\nВердикт: **${verdict}** · High: 0 · Medium: 0\n`);
const reindex = (work) => writeFileSync(join(work, REVIEWS_INDEX_PATH), buildIndex(join(work, REVIEWS)));
const commitAll = (work, msg) => { git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', msg); };
/**
* dev: base (документ #1 + индекс) → документ #8 со своим индексом.
* ветка issue/9-fix от base: правка кода → документ #9 со своим индексом.
* `shared` — вдобавок оба doc-коммита правят один и тот же обычный файл.
*/
const SCRIPTS = fileURLToPath(new URL('../scripts/', import.meta.url));
/**
* Замыкание модуля: относительные импорты и скрипты, которые он запускает по
* `new URL('./x.mjs', import.meta.url)` (генератор индекса), — рекурсивно.
*/
function importClosure(entry, seen = new Set()) {
if (seen.has(entry)) return seen;
seen.add(entry);
const text = readFileSync(entry, 'utf8');
const specs = [
...text.matchAll(/^import[^'"]*['"](\.{1,2}\/[^'"]+)['"]/gm),
...text.matchAll(/new URL\('(\.{1,2}\/[^']+\.mjs)', import\.meta\.url\)/g),
].map((m) => m[1]);
for (const spec of specs) importClosure(resolve(dirname(entry), spec), seen);
return seen;
}
function scenario({ shared = false, tools = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-gen-'));
const origin = join(root, 'origin.git'); const work = join(root, 'work');
git(root, 'init', '--bare', '-q', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, REVIEWS), { recursive: true });
writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n');
writeFileSync(join(work, 'shared.txt'), 'base\n');
doc(work, 'CODE-REVIEW-1-r1.md');
reindex(work);
if (tools) {
// Шаг конвейера берёт помощника из dev: dev временного репозитория несёт
// его вместе с замыканием импортов.
mkdirSync(join(work, 'scripts'));
for (const file of importClosure(join(SCRIPTS, 'rebase-generated.mjs'))) {
// 'broken' — dev без зависимости помощника: Node упадёт на импорте с кодом 1.
if (tools === 'broken' && file.endsWith('spawn-portable.mjs')) continue;
copyFileSync(file, join(work, 'scripts', file.slice(SCRIPTS.length)));
}
}
commitAll(work, 'base');
git(work, 'push', '-q', '-u', 'origin', 'dev');
git(work, 'checkout', '-q', '-b', 'issue/9-fix');
writeFileSync(join(work, 'a.mjs'), 'export const a = 21;\n');
commitAll(work, 'fix');
doc(work, 'CODE-REVIEW-9-r1.md');
reindex(work);
if (shared) writeFileSync(join(work, 'shared.txt'), 'branch\n');
commitAll(work, 'docs: review document for #9');
const branchIndex = readFileSync(join(work, REVIEWS_INDEX_PATH), 'utf8');
git(work, 'checkout', '-q', 'dev');
doc(work, 'CODE-REVIEW-8-r1.md', 'жёлтый');
reindex(work);
if (shared) writeFileSync(join(work, 'shared.txt'), 'dev\n');
commitAll(work, 'docs: review document for #8');
git(work, 'push', '-q', 'origin', 'dev');
const devIndex = readFileSync(join(work, REVIEWS_INDEX_PATH), 'utf8');
git(work, 'checkout', '-q', 'issue/9-fix');
return { root, work, branchIndex, devIndex };
}
test('#643 planStop: разрешается только набор, где ВСЕ конфликты — индекс или объявленные вызывающим', () => {
assert.deepEqual(planStop([REVIEWS_INDEX_PATH, '', ` ${REVIEWS_INDEX_PATH}`]),
{ action: 'resolve', index: true, upstream: [], extra: [], conflicts: [REVIEWS_INDEX_PATH] });
const mixed = planStop([REVIEWS_INDEX_PATH, 'src/x.ts']);
assert.equal(mixed.action, 'abort');
assert.equal(mixed.reason, 'manual');
assert.deepEqual(mixed.manual, ['src/x.ts']);
assert.deepEqual(mixed.conflicts, [REVIEWS_INDEX_PATH, 'src/x.ts'], 'в перечне отказа — все пути, индекс тоже');
assert.equal(planStop(['docs/reviews/CODE-REVIEW-9-r1.md']).action, 'abort', 'документ ревью — не генерируемый файл');
assert.equal(planStop(['docs/reviews/sub/INDEX.md']).action, 'abort', 'другой INDEX.md — не индекс ревью');
assert.deepEqual(planStop([]), { action: 'abort', reason: 'no-conflicts', manual: [], conflicts: [] });
const bundle = planStop(['dist/a.js', REVIEWS_INDEX_PATH], { extra: (p) => p.startsWith('dist/') });
assert.deepEqual(bundle, { action: 'resolve', index: true, upstream: [], extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] });
});
test('#643 AC1: конфликт только в INDEX.md — ребейз проходит, индекс равен пересборке каталога', () => {
const { root, work, branchIndex, devIndex } = scenario();
try {
const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.ok(result.stops >= 1, 'конфликт действительно был — иначе тест ничего не доказывает');
assert.ok(result.resolved.includes(`${REVIEWS_INDEX_PATH} ← пересборка`));
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0', 'dev целиком под веткой');
assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '2', 'оба коммита ветки на месте');
const index = git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n';
assert.equal(index, buildIndex(join(work, REVIEWS)), 'индекс = пересборка по каталогу');
assert.notEqual(index, devIndex, 'не версия dev');
assert.notEqual(index, branchIndex, 'не версия ветки');
assert.match(index, /CODE-REVIEW-8-r1\.md/);
assert.match(index, /CODE-REVIEW-9-r1\.md/);
assert.match(git(work, 'show', 'HEAD:a.mjs'), /a = 21/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643 AC2: INDEX.md вместе с другим файлом — отказ с обоими путями, дерево и HEAD как были', () => {
const { root, work } = scenario({ shared: true });
try {
const before = git(work, 'rev-parse', 'HEAD');
const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV });
assert.equal(result.ok, false);
assert.equal(result.reason, 'manual');
assert.deepEqual(result.conflicts, [REVIEWS_INDEX_PATH, 'shared.txt']);
assert.deepEqual(result.manual, ['shared.txt']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(existsSync(join(work, '.git', 'rebase-merge')), false, 'ребейз отменён, не брошен');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643: индекс-коммит, ставший пустым после пересборки, пропускается, а не останавливает ребейз', () => {
const { root, work } = scenario();
try {
// Индекс-коммит поверх doc-коммита: шапка старого генератора. После
// ребейза пересборка совпадает с индексом предыдущего коммита.
const path = join(work, REVIEWS_INDEX_PATH);
writeFileSync(path, readFileSync(path, 'utf8').replace('Генерируется', 'Сгенерирован старым генератором'));
commitAll(work, 'docs(reviews): индекс после сдвига каталога (#9)');
const result = rebaseRegenerating({ onto: 'origin/dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.stops, 2);
assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '2', 'пустой индекс-коммит отброшен');
assert.equal(git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n', buildIndex(join(work, REVIEWS)));
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643: сбой пересборки посреди ребейза отменяет ребейз, а не оставляет дерево в полуребейзе', () => {
const { root, work } = scenario();
try {
const before = git(work, 'rev-parse', 'HEAD');
assert.throws(() => rebaseRegenerating({
onto: 'origin/dev', cwd: work, env: ENV, rebuildIndex: () => { throw new Error('генератор упал'); },
}), /генератор упал/);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643 CLI: отказ — код 3 и по строке на конфликтующий путь в stdout', () => {
const { root, work } = scenario({ shared: true });
try {
const script = fileURLToPath(new URL('../scripts/rebase-generated.mjs', import.meta.url));
let failure;
try {
execFileSync(process.execPath, [script, '--onto=origin/dev'],
{ cwd: work, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
} catch (error) { failure = error; }
assert.ok(failure, 'отказ — ненулевой код');
assert.equal(failure.status, 3, 'не 1: единицей Node выходит сам на сбое');
assert.equal(failure.stdout, `${REVIEWS_INDEX_PATH}\nshared.txt\n`);
assert.match(failure.stderr, /конфликт вне генерируемых путей — shared\.txt/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
// ---------- проводка в process.yml: свидетели и настоящий bash ----------
const WORKFLOW = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const rebaseStep = () => WORKFLOW.slice(
WORKFLOW.indexOf(' - name: Привести ветку к dev\n'),
WORKFLOW.indexOf(' - name: Зафиксировать SHA материала ревью'),
);
test('#643 process.yml: шаг «Привести ветку к dev» ребейзит помощником из dev и сохраняет прежние гарантии', () => {
const step = rebaseStep();
assert.ok(step.length > 0, 'шаг найден');
assert.doesNotMatch(step, /^\s+if ! git rebase origin\/dev/m, 'голого git rebase больше нет');
assert.match(step, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'помощник — из dev, не из отставшей ветки');
assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт');
assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts<<EOF_FILES'/, 'выход conflict/conflicts на месте');
// Порядок: ребейз → push с lease → ожидание ссылки. Индекс в ветке задачи
// не пересобирается (#657, 1б): его догоняет слияние кандидата в dev.
assert.doesNotMatch(step, /--commit-if-stale/);
const order = ['rebase-generated.mjs', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
const at = order.map((needle) => step.indexOf(needle));
assert.ok(at.every((i) => i >= 0), JSON.stringify(at));
assert.deepEqual([...at].sort((a, b) => a - b), at, 'порядок шагов сохранён');
});
test('#643: замыкание импортов помощника не выходит из scripts/ — архива scripts достаточно', () => {
const closure = importClosure(join(SCRIPTS, 'rebase-generated.mjs'));
assert.ok(closure.has(join(SCRIPTS, 'reviews-index.mjs')), 'генератор индекса в замыкании');
for (const file of closure) {
assert.ok(file.startsWith(SCRIPTS), `${file} вне scripts/`);
}
});
/** Исполнить ребейзную часть шага как есть (bash -eo pipefail, как у Actions). */
function runStepRebase(work) {
const step = rebaseStep();
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"');
const to = body.indexOf('# #657 (1б): индекс ревью в ветке');
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
try {
const output = join(temp, 'output');
writeFileSync(output, '');
const script = `${body.slice(from, to)}\necho REBASED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
});
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
} finally {
rmSync(temp, { recursive: true, force: true });
}
}
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
&& spawnSync('tar', ['--version']).status === 0;
test('#643 process.yml на настоящем bash: конфликт только в индексе — ветка приведена, conflict не выставлен', (t) => {
if (!hasBash()) { t.skip('bash/tar недоступны'); return; }
const { root, work } = scenario({ tools: true });
try {
const r = runStepRebase(work);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /REBASED/);
assert.equal(r.output, '', 'conflict не выставлен');
assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0');
assert.equal(git(work, 'show', `HEAD:${REVIEWS_INDEX_PATH}`) + '\n', buildIndex(join(work, REVIEWS)));
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643 process.yml на настоящем bash: индекс + другой файл — conflict=true и оба пути в conflicts', (t) => {
if (!hasBash()) { t.skip('bash/tar недоступны'); return; }
const { root, work } = scenario({ tools: true, shared: true });
try {
const before = git(work, 'rev-parse', 'HEAD');
const r = runStepRebase(work);
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stdout, /REBASED/, 'шаг завершился на отказе');
assert.equal(r.output, `conflict=true\nconflicts<<EOF_FILES\n${REVIEWS_INDEX_PATH}\nshared.txt\nEOF_FILES\n`);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#643 process.yml на настоящем bash: сбой помощника (Node вышел с 1) — ошибка шага, а не conflict', (t) => {
if (!hasBash()) { t.skip('bash/tar недоступны'); return; }
const { root, work } = scenario({ tools: 'broken' });
try {
const r = runStepRebase(work);
assert.equal(r.status, 1);
assert.equal(r.output, '', 'сбой не выдаётся за конфликт ветки');
assert.match(r.stdout, /::error::помощник ребейза упал/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
// ---------- #698: ченджлог объединяется, база метрик берётся из dev ----------
function sharedFilesScenario({ baselineConflict = false, alsoCode = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-698-'));
const work = join(root, 'work');
mkdirSync(join(work, 'docs'), { recursive: true });
mkdirSync(join(work, 'scripts'), { recursive: true });
git(root, 'init', '-q', '-b', 'dev', work);
copyFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), join(work, '.gitattributes'));
const log = (lines) => writeFileSync(join(work, 'docs', 'CHANGELOG.md'), `# Changelog\n\n## Unreleased\n\n${lines.join('\n')}\n\n## 1.0.0\n\n- first\n`);
const baseline = (hostRefs) => writeFileSync(join(work, 'scripts', 'monolith-baseline.json'), `${JSON.stringify({ delegates: 1, hostRefs }, null, 2)}\n`);
log([]); baseline(100);
writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n');
commitAll(work, 'base');
git(work, 'checkout', '-q', '-b', 'issue/9-x');
log(['- task nine']);
if (baselineConflict) baseline(95);
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 9;\n');
commitAll(work, 'task');
git(work, 'checkout', '-q', 'dev');
log(['- task eight']);
if (baselineConflict) baseline(103);
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 8;\n');
commitAll(work, 'neighbour');
git(work, 'checkout', '-q', 'issue/9-x');
return { root, work };
}
test('#698: записи ченджлога двух задач объединяются при ребейзе, конфликта нет', (t) => {
const { root, work } = sharedFilesScenario();
t.after(() => rmSync(root, { recursive: true, force: true }));
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.stops, 0, 'union — это не остановка ребейза');
const text = readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8');
assert.match(text, /- task eight\n- task nine\n/, 'обе записи в Unreleased, сторона dev первой');
assert.doesNotMatch(text, /^(<<<<<<<|=======|>>>>>>>)/m);
});
test('#698: конфликт в базе метрик монолита решается в пользу dev', (t) => {
const { root, work } = sharedFilesScenario({ baselineConflict: true });
t.after(() => rmSync(root, { recursive: true, force: true }));
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.deepEqual(result.resolved, ['scripts/monolith-baseline.json ← dev']);
assert.equal(JSON.parse(readFileSync(join(work, 'scripts', 'monolith-baseline.json'), 'utf8')).hostRefs, 103);
assert.match(readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8'), /- task nine/);
assert.deepEqual(UPSTREAM_WINS, ['scripts/monolith-baseline.json']);
});
test('#698: база метрик вместе с конфликтом в коде — прежний отказ с перечнем', (t) => {
const { root, work } = sharedFilesScenario({ baselineConflict: true, alsoCode: true });
t.after(() => rmSync(root, { recursive: true, force: true }));
const before = git(work, 'rev-parse', 'HEAD');
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, false);
assert.deepEqual(result.manual, ['a.mjs']);
assert.deepEqual(result.conflicts, ['a.mjs', 'scripts/monolith-baseline.json']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before, 'ребейз отменён, HEAD как был');
});
test('#698: patch-id кандидата не видит того, что ребейз сливает сам', () => {
for (const path of ['docs/reviews', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', 'scripts/monolith-baseline.json']) {
assert.ok(PATCH_ID_EXCLUDES.includes(`:!${path}`), path);
}
const attrs = readFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), 'utf8');
assert.match(attrs, /^docs\/CHANGELOG\.md merge=union$/m);
assert.match(attrs, /^docs\/CHANGELOG\.ru\.md merge=union$/m);
});
// ---------- #705: отказ push стража ребейза разбирает код слияния ----------
//
// Прежде любой ненулевой push приведённой ветки печатал «ветка изменилась во
// время ребейза», хотя GitHub мог отказать сам — например, коммиту, меняющему
// `.github/workflows/`, от токена без права на workflow (#700). Шаг исполняется
// как есть, на настоящем bash; git подменён: push отвечает заданным stderr.
const FAKE_TOKEN_705 = 'ghs_' + 'Z9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4J3i2';
const refusalStderr = (reason) => `To https://github.com/o/r\n ! [remote rejected] HEAD -> issue/9-fix (${reason})\n`
+ `error: failed to push some refs to 'https://x-access-token:${FAKE_TOKEN_705}@github.com/o/r'\n`;
function runStepPush(pushStderr) {
const step = rebaseStep();
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
const from = body.indexOf('push_err="$RUNNER_TEMP/rebase-push.stderr"');
const to = body.indexOf('# #539: ссылка на стороне GitHub');
assert.ok(from >= 0 && to > from, 'push-часть шага найдена');
const context = { repository: 'o/r', server_url: 'https://github.com', run_id: '42' };
const block = body.slice(from, to).replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => context[key]);
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-705-'));
try {
const bin = join(temp, 'bin');
mkdirSync(bin);
writeFileSync(join(bin, 'git'), [
'#!/bin/sh',
'case "$1" in',
' push) cat "$FAKE_PUSH_STDERR" >&2; exit 1 ;;',
` rev-parse) echo ${'c'.repeat(40)}; exit 0 ;;`,
'esac',
'echo "unexpected git $*" >&2; exit 97',
'',
].join('\n'), { mode: 0o755 });
writeFileSync(join(temp, 'push.stderr'), pushStderr);
const output = join(temp, 'output');
writeFileSync(output, '');
// #730: сводка шага, куда код слияния пишет причину отказа.
const summary = join(temp, 'summary.md');
writeFileSync(summary, '');
const script = `tools=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
encoding: 'utf8',
env: {
...ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary,
BRANCH: 'issue/9-fix', NUM: '9', TOKEN: FAKE_TOKEN_705, FAKE_PUSH_STDERR: join(temp, 'push.stderr'),
},
});
const commentPath = join(temp, 'push-refusal.md');
return {
status: r.status, stdout: r.stdout, stderr: r.stderr,
output: readFileSync(output, 'utf8').replaceAll(temp, '$RUNNER_TEMP'),
summary: readFileSync(summary, 'utf8'),
comment: existsSync(commentPath) ? readFileSync(commentPath, 'utf8') : null,
};
} finally {
rmSync(temp, { recursive: true, force: true });
}
}
test('#705 process.yml: push ребейза отклонён по праву на workflow — refused=workflow, комментарий готов, ревью не идёт', (t) => {
if (!hasBash()) { t.skip('bash/tar недоступны'); return; }
const r = runStepPush(refusalStderr('refusing to allow a Personal Access Token to create or update workflow `.github/workflows/validate.yml` without `workflow` scope'));
assert.equal(r.status, 0, r.stderr);
assert.doesNotMatch(r.stdout, /PUSHED/, 'шаг закончился на отказе');
assert.equal(r.output, 'refused=workflow\nrefusal_comment=$RUNNER_TEMP/push-refusal.md\n');
assert.match(r.stdout, /::warning::GitHub не принял push ребейза issue\/9-fix/);
assert.doesNotMatch(r.stdout, /изменилась во время ребейза/, 'не #312');
assert.match(r.comment, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл, токен конвейера не может его опубликовать: ребейз и push делает автор, либо владелец выдаёт право/);
assert.match(r.comment, /`\.github\/workflows\/validate\.yml`/);
assert.match(r.comment, /\[Прогон конвейера\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/42\)/);
assert.match(r.stderr, /refusing to allow a Personal Access Token/, 'ответ git — в журнале');
// #730 AC2: причина и ответ git — ещё и в сводке шага.
assert.match(r.summary, /^### git push в `issue\/9-fix` отклонён: workflow \(#723\)\n\nРебейз ветки на dev не опубликован в `issue\/9-fix`\. GitHub отклонил push по праву на workflow/);
assert.match(r.summary, /Файлы: `\.github\/workflows\/validate\.yml`\./);
assert.match(r.summary, /Ответ git:\n\n```\n[\s\S]*refusing to allow a Personal Access Token[\s\S]*\n```\n$/);
for (const text of [r.stdout, r.stderr, r.comment, r.summary]) assert.ok(!text.includes(FAKE_TOKEN_705), 'токен вырезан');
});
test('#705 process.yml: устаревший lease — прежняя ошибка «ветка изменилась», прочий отказ GitHub — своя', (t) => {
if (!hasBash()) { t.skip('bash/tar недоступны'); return; }
const stale = runStepPush(` ! [rejected] HEAD -> issue/9-fix (stale info)\nerror: failed to push some refs to 'https://github.com/o/r'\n`);
assert.equal(stale.status, 1);
assert.match(stale.stdout, /::error::ветка issue\/9-fix изменилась во время ребейза — прогон прерван/);
assert.equal(stale.output, '');
assert.equal(stale.comment, null);
assert.equal(stale.summary, '', '#730: устаревший lease сводку об отказе GitHub не пишет');
assert.match(stale.stderr, /\(stale info\)/, 'ответ git — в журнале');
const hook = runStepPush(refusalStderr('protected branch hook declined'));
assert.equal(hook.status, 1);
assert.match(hook.stdout, /::error::GitHub отклонил push ребейза issue\/9-fix \(remote-rejected\) — это не изменение ветки автором/);
assert.doesNotMatch(hook.stdout, /изменилась во время ребейза/);
assert.equal(hook.output, '');
assert.match(hook.stderr, /protected branch hook declined/);
// #730 AC2: прочий отказ GitHub — тоже в сводке, с причиной и без токена.
assert.match(hook.summary, /^### git push в `issue\/9-fix` отклонён: remote-rejected \(#723\)$/m);
assert.ok(hook.summary.includes('Причина, которую назвал GitHub: «protected branch hook declined»'), hook.summary);
assert.ok(!hook.stderr.includes(FAKE_TOKEN_705) && !hook.stdout.includes(FAKE_TOKEN_705) && !hook.summary.includes(FAKE_TOKEN_705));
});
test('#705 process.yml: отказ по праву на workflow возвращает задачу в S6 без ревью и без Validate на неопубликованном ребейзе', () => {
const at = (marker) => { const i = WORKFLOW.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const step = rebaseStep();
assert.match(step, /"HEAD:refs\/heads\/\$BRANCH" 2> "\$push_err"; then/, 'stderr push идёт в разбор, а не мимо');
assert.match(step, /kind=\$\(node "\$tools\/scripts\/merge-candidate\.mjs" --push-refusal="\$push_err"/, 'разбор — кодом слияния из dev');
const back = WORKFLOW.slice(at(' - name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"\n'),
at(' - name: Validate на материале\n'));
assert.match(back, /if: steps\.rebase\.outputs\.refused == 'workflow'\n/);
assert.match(back, /--body-file "\$COMMENT"/);
assert.match(back, /COMMENT: \$\{\{ steps\.rebase\.outputs\.refusal_comment \}\}/);
assert.match(back, /--add-label S6-in-progress --remove-label S7-code-review/);
// материал, reuse и гейт не берут локальный ребейз, которого нет на ветке
for (const [name, id] of [['Зафиксировать SHA материала ревью', 'material'], ['"Зелёный вердикт прошлого захода применим без ревью (#499)"', 'reuse'], ['Validate на материале', 'gate']]) {
const block = WORKFLOW.slice(at(` - name: ${name}\n`));
assert.match(block.slice(0, block.indexOf('\n run:')), new RegExp(`id: ${id}\\n if: steps\\.rebase\\.outputs\\.conflict != 'true' && steps\\.rebase\\.outputs\\.refused == ''`), id);
}
});
test('#705: замыкание импортов кода слияния не выходит из scripts/ — страж ребейза берёт его архивом из dev', () => {
for (const file of importClosure(join(SCRIPTS, 'merge-candidate.mjs'))) assert.ok(file.startsWith(SCRIPTS), `${file} вне scripts/`);
});