Files
houseplan-card/test/rebase-generated.test.mjs
T
Claudeandclaude[bot] e5c217111c fix(process): a GitHub push refusal is not a stale lease (#705)
merge-candidate treated any push stderr containing "rejected" as a stale
lease. A `! [remote rejected]` from GitHub itself - in #700 the rebased
candidate changed .github/workflows/ and the conveyor token has no workflow
permission (runs 36484993494, 36487044060) - became "the branch moved after
the reviewed material (#312)", and the stderr was never printed, so the
author was sent to look for a commit that did not exist.

classifyPushRefusal now tells three outcomes apart: a stale lease
(`[rejected] (stale info)`, `fetch first`, a server-side lock race) keeps
the old behaviour; GitHub's workflow refusal (PAT, OAuth App, GitHub App,
bot and integration wordings) and any other `[remote rejected]` get their
own outcome, S6-in-progress and a comment naming the reason. The workflow
comment says what to do: the author rebases and pushes, or the owner grants
the permission. The git answer goes to the log and the comment with tokens
and credential URLs cut out; the merge-step failure comment is redacted too.

The rebase guard in _process.yml parses its push refusal with the same code
(`merge-candidate.mjs --push-refusal`): a stale lease is the old error, a
workflow refusal returns the task to S6 without review like a conflict, and
material/reuse/gate skip the rebase that never reached the branch.

Mutant push-refusal-kinds-glued restores the old regex; guard: #705 AC1.

Issue: #705
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 20:25:08 +00:00

477 lines
30 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, '');
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,
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'),
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 — в журнале');
for (const text of [r.stdout, r.stderr, r.comment]) 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.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/);
assert.ok(!hook.stderr.includes(FAKE_TOKEN_705) && !hook.stdout.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/`);
});