mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
Two steps publish a commit and treated every failed push as a moved branch: the release review job (release-review.yml) retried three times with "dev went ahead", and the review document step (_process.yml) rebased and pushed again. A refusal by GitHub itself - a token without the workflow right, a branch rule, a hook - cannot be cured by a retry or a rebase, and the step never said what GitHub answered. Both pushes now keep stderr and hand it to the #705 classifier through the same CLI the rebase guard uses (merge-candidate.mjs --push-refusal). Only a stale lease (rejected / fetch first / stale info) keeps the old retry or rebase. Any other outcome stops the step at once, without retries: the log gets the git answer and the step summary gets the reason and the git answer, both passed through redactSecrets (token, credential URL, Authorization). The review document step takes the classifier from dev, as the rebase guard does: a task branch behind dev may not carry it. The summary text is written by the new --summary option (refusalSummary), not by a multi-line string in run:, and both commit messages are now built line by line into a file instead of a heredoc (PROCESS.md §10.4 item 4). release-review.yml is dispatch-only and is not mirrored to main. PROCESS.md names the rule next to the rebase guard; the #638 trailer witness in test/release-review.test.mjs follows the line-by-line message. test/publish-push-refusal.test.mjs runs both steps as they are with real bash and real git in temporary repositories; only the push transport is replaced: a moved branch is a real neighbour push, a GitHub refusal is a recorded stderr carrying a token, a credential URL and an Authorization header. On the old steps 9 of its 11 tests fail. Issue: #723 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
396 lines
25 KiB
JavaScript
396 lines
25 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync, spawnSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join, relative, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { PUSH_REFUSAL, classifyPushRefusal, refusalSummary } from '../scripts/merge-candidate.mjs';
|
||
import { buildIndex } from '../scripts/reviews-index.mjs';
|
||
|
||
// #723: два шага публикуют коммит и прежде любой отказ push считали сдвигом
|
||
// ветки — документ ревью релиза в `dev` (release-review.yml, три попытки) и
|
||
// документ ревью в ветку задачи (_process.yml, ребейз и второй push). Теперь
|
||
// отказ разбирает код слияния (`merge-candidate.mjs --push-refusal`, #705):
|
||
// устаревший lease — прежний повтор/ребейз, отказ GitHub — остановка без
|
||
// повторов, причина и ответ git без токена — в журнале и в сводке шага.
|
||
//
|
||
// Шаги исполняются как есть, настоящим bash и настоящим git во временных
|
||
// репозиториях. Подменён только транспорт: `git push` на github.com уходит в
|
||
// локальный origin, а заданный отказ GitHub отвечает записанным stderr. Сдвиг
|
||
// ветки — настоящий: сосед пушит в origin до push шага, и git сам отвечает
|
||
// `! [rejected] … (fetch first)`.
|
||
|
||
const SCRIPTS = fileURLToPath(new URL('../scripts', import.meta.url));
|
||
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
|
||
|
||
// Окружение git без GIT_* родителя (урок #633) и без глобального конфига (#496).
|
||
const 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: '3',
|
||
GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false',
|
||
GIT_CONFIG_KEY_1: 'core.eol', GIT_CONFIG_VALUE_1: 'lf',
|
||
GIT_CONFIG_KEY_2: 'init.defaultBranch', GIT_CONFIG_VALUE_2: 'dev',
|
||
};
|
||
const git = (cwd, ...args) => execFileSync('git', args, {
|
||
cwd, encoding: 'utf8', env: ENV, stdio: ['ignore', 'pipe', 'pipe'],
|
||
}).trim();
|
||
const commitAll = (cwd, msg) => { git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', msg); };
|
||
|
||
/** Замыкание относительных импортов модуля (и скриптов, которые он зовёт по `new 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;
|
||
}
|
||
/** Скрипты, которые зовут оба шага, — с замыканием импортов. */
|
||
const STEP_SCRIPTS = ['release-review.mjs', 'reviews-index.mjs', 'review-doc-guard.mjs', 'merge-candidate.mjs']
|
||
.reduce((seen, name) => importClosure(join(SCRIPTS, name), seen), new Set());
|
||
|
||
const hasTools = () => process.platform !== 'win32'
|
||
&& ['bash', 'tar', 'jq', 'sha256sum'].every((tool) => spawnSync(tool, ['--version']).status === 0);
|
||
|
||
const FAKE_TOKEN = 'ghs_' + 'Q1w2E3r4T5y6U7i8O9p0A1s2D3f4G5h6J7k8';
|
||
const OTHER_TOKEN = 'ghp_' + 'M1n2B3v4C5x6Z7l8K9j0H1g2F3d4S5a6P7o8';
|
||
const SECRETS = [FAKE_TOKEN, OTHER_TOKEN, 'dG9rZW46c2VjcmV0'];
|
||
const remoteRejected = (ref, reason) => `To https://github.com/o/r\n ! [remote rejected] HEAD -> ${ref} (${reason})\n`
|
||
+ `error: failed to push some refs to 'https://x-access-token:${FAKE_TOKEN}@github.com/o/r'\n`;
|
||
const WORKFLOW_REASON = 'refusing to allow a Personal Access Token to create or update workflow `.github/workflows/validate.yml` without `workflow` scope';
|
||
// Ответ с заголовком и чужим токеном: так выглядит stderr при GIT_CURL_VERBOSE / GIT_TRACE.
|
||
const noisyRejected = (ref) => `> Authorization: Basic dG9rZW46c2VjcmV0\n`
|
||
+ `remote: token ${OTHER_TOKEN} is not allowed here\n${remoteRejected(ref, 'protected branch hook declined')}`;
|
||
const NETWORK = `fatal: unable to access 'https://x-access-token:${FAKE_TOKEN}@github.com/o/r/': The requested URL returned error: 403\n`;
|
||
|
||
/**
|
||
* Тело `run:` шага так, как его прочтёт YAML: блок кончается на первой
|
||
* непустой строке с отступом меньше десяти (PROCESS.md §10.4 п.4) — обрезанный
|
||
* скрипт тест увидит, а не пропустит.
|
||
*/
|
||
function stepRun(file, name) {
|
||
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
|
||
const start = text.indexOf(` - name: ${name}\n`);
|
||
assert.ok(start >= 0, `шаг «${name}» в ${file}`);
|
||
const lines = text.slice(start).split('\n');
|
||
const from = lines.indexOf(' run: |');
|
||
assert.ok(from > 0, 'у шага есть 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\.repository \}\}/g, 'o/r');
|
||
}
|
||
|
||
const RELEASE_STEP = () => stepRun('release-review.yml', 'Опубликовать документ');
|
||
const REVIEW_DOC_STEP = () => stepRun('_process.yml', 'Опубликовать документ ревью');
|
||
|
||
/**
|
||
* Песочница: bare origin, рабочая копия, соседний клон и bin с подменами.
|
||
* `git` — настоящий, кроме транспорта push: `push-N.stderr` — ответ N-го push
|
||
* (отказ GitHub), `before-push-N` — сосед пушит свой коммит перед N-м push.
|
||
*/
|
||
/** Временный корень теста, убираемый after-хуком (#646). */
|
||
function tempRoot(t, prefix) {
|
||
const root = mkdtempSync(join(tmpdir(), prefix));
|
||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||
return root;
|
||
}
|
||
|
||
function sandbox(root) {
|
||
const origin = join(root, 'origin.git');
|
||
const work = join(root, 'work');
|
||
const other = join(root, 'other');
|
||
const temp = join(root, 'runner');
|
||
const fake = join(root, 'fake');
|
||
const bin = join(root, 'bin');
|
||
for (const dir of [temp, fake, bin]) mkdirSync(dir);
|
||
git(root, 'init', '--bare', '-q', origin);
|
||
git(root, 'clone', '-q', origin, work);
|
||
git(work, 'checkout', '-q', '-b', 'dev');
|
||
// Скрипты шага — из репозитория как есть: их несёт dev временного origin.
|
||
mkdirSync(join(work, 'scripts'));
|
||
for (const file of STEP_SCRIPTS) copyFileSync(file, join(work, 'scripts', relative(SCRIPTS, file)));
|
||
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')));
|
||
writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n');
|
||
commitAll(work, 'base');
|
||
git(work, 'push', '-q', 'origin', 'dev');
|
||
const realGit = execFileSync('bash', ['-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
||
writeFileSync(join(bin, 'git'), [
|
||
'#!/usr/bin/env bash',
|
||
'echo "$*" >> "$FAKE_DIR/calls"',
|
||
'if [ "$1" = push ]; then',
|
||
' n=$(( $(cat "$FAKE_DIR/pushes" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$FAKE_DIR/pushes"',
|
||
' if [ -f "$FAKE_DIR/before-push-$n" ]; then "$REAL_GIT" -C "$FAKE_OTHER" push -q origin "$(cat "$FAKE_DIR/before-push-$n")"; fi',
|
||
' if [ -f "$FAKE_DIR/push-$n.stderr" ]; then cat "$FAKE_DIR/push-$n.stderr" >&2; exit 1; fi',
|
||
' args=(); for a in "$@"; do case "$a" in https://*) args+=("$FAKE_ORIGIN") ;; *) args+=("$a") ;; esac; done',
|
||
' exec "$REAL_GIT" "${args[@]}"',
|
||
'fi',
|
||
'exec "$REAL_GIT" "$@"',
|
||
'',
|
||
].join('\n'), { mode: 0o755 });
|
||
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\necho "$*" >> "$FAKE_DIR/sleeps"\n', { mode: 0o755 });
|
||
const box = {
|
||
root, origin, work, other, temp, fake,
|
||
/** Сосед: коммит в `ref` origin, который уйдёт туда перед push номер `n`. */
|
||
neighbour(ref, n, file, content) {
|
||
if (!existsSync(other)) git(root, 'clone', '-q', origin, other);
|
||
git(other, 'fetch', '-q', 'origin');
|
||
git(other, 'checkout', '-q', '-B', ref, `origin/${ref}`);
|
||
writeFileSync(join(other, file), content);
|
||
commitAll(other, `neighbour ${file}`);
|
||
writeFileSync(join(fake, `before-push-${n}`), `HEAD:${ref}`);
|
||
},
|
||
refuse(n, stderr) { writeFileSync(join(fake, `push-${n}.stderr`), stderr); },
|
||
run(script, env) {
|
||
const summary = join(temp, 'summary.md');
|
||
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
||
cwd: work, encoding: 'utf8',
|
||
env: {
|
||
...ENV, ...env, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, GITHUB_STEP_SUMMARY: summary,
|
||
GITHUB_OUTPUT: join(temp, 'output'), TOKEN: FAKE_TOKEN,
|
||
FAKE_DIR: fake, FAKE_ORIGIN: origin, FAKE_OTHER: other, REAL_GIT: realGit,
|
||
},
|
||
});
|
||
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
||
return {
|
||
status: r.status, stdout: r.stdout, stderr: r.stderr, summary: read(summary),
|
||
pushes: Number(read(join(fake, 'pushes')) || 0),
|
||
calls: read(join(fake, 'calls')).split('\n').filter(Boolean),
|
||
sleeps: read(join(fake, 'sleeps')).split('\n').filter(Boolean),
|
||
};
|
||
},
|
||
};
|
||
return box;
|
||
}
|
||
|
||
const noSecrets = (r) => {
|
||
for (const [where, text] of Object.entries({ stdout: r.stdout, stderr: r.stderr, summary: r.summary })) {
|
||
for (const secret of SECRETS) assert.ok(!text.includes(secret), `${where}: секрет в тексте`);
|
||
assert.doesNotMatch(text, /x-access-token:(?!\*\*\*@)/, `${where}: URL с учётными данными`);
|
||
}
|
||
};
|
||
|
||
// ---------- release-review.yml: документ ревью релиза в dev ----------
|
||
|
||
const TAG = 'v1.78.0';
|
||
const RELEASE_DOC = `docs/reviews/RELEASE-REVIEW-${TAG}.md`;
|
||
|
||
function runRelease(box) {
|
||
const dir = join(box.temp, 'release-review-result');
|
||
mkdirSync(dir);
|
||
const files = {
|
||
'release-review.md': `# Независимое ревью ${TAG}\n\nИтог: High 0 · Medium 1 · Low 2\n`,
|
||
'result.json': JSON.stringify({ high: 0, medium: 1, low: 2, summary: 'ok' }),
|
||
};
|
||
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
|
||
writeFileSync(join(dir, 'manifest.sha256'), Object.entries(files)
|
||
.map(([name, text]) => `${createHash('sha256').update(text).digest('hex')} ${name}\n`).join(''));
|
||
return box.run(RELEASE_STEP(), {
|
||
TAG, DOC: RELEASE_DOC, CANDIDATE: 'c'.repeat(40), BASE: 'v1.77.0', ISSUES: '701,702',
|
||
RUN_URL: 'https://github.com/o/r/actions/runs/42',
|
||
});
|
||
}
|
||
|
||
test('#723 release-review.yml на настоящем bash: dev ушёл вперёд — прежний повтор, документ собран заново поверх соседа', (t) => {
|
||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||
const box = sandbox(tempRoot(t, 'hp-723-release-'));
|
||
box.neighbour('dev', 1, 'docs/reviews/CODE-REVIEW-8-r1.md', '# CODE-REVIEW-8-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n');
|
||
const r = runRelease(box);
|
||
assert.equal(r.status, 0, r.stderr + r.stdout);
|
||
assert.equal(r.pushes, 2, 'повтор после устаревшего lease');
|
||
assert.deepEqual(r.sleeps, ['10']);
|
||
assert.match(r.stdout, /::warning::dev ушёл вперёд — попытка 1 из 3/);
|
||
assert.match(r.stderr, /git push отклонён — stale \(fetch first\)/, 'разбор отказа — кодом слияния');
|
||
assert.match(r.summary, /^### Независимое ревью v1\.78\.0\nИтог: High 0 · Medium 1 · Low 2 — `docs\/reviews\/RELEASE-REVIEW-v1\.78\.0\.md` в dev\./m);
|
||
assert.doesNotMatch(r.summary, /отклонён/);
|
||
assert.equal(git(box.origin, 'log', '-1', '--format=%B', 'dev'),
|
||
`docs: release review for ${TAG}\n\nНезависимое ревью линии перед стабильным релизом (PROCESS.md §11.5).\n`
|
||
+ 'Итог: High 0 · Medium 1 · Low 2. Выпуск не блокирует; решение по находкам — за владельцем.\n\nIssue: #638\nUser-Visible: no');
|
||
assert.deepEqual(git(box.origin, 'show', '--name-only', '--format=', 'dev').split('\n').sort(),
|
||
['docs/reviews/INDEX.md', RELEASE_DOC], 'коммит несёт документ и индекс');
|
||
const index = git(box.origin, 'show', 'dev:docs/reviews/INDEX.md');
|
||
assert.match(index, /CODE-REVIEW-8-r1/, 'индекс собран поверх сдвинутого dev');
|
||
assert.match(index, /RELEASE-REVIEW-v1\.78\.0/);
|
||
assert.match(git(box.origin, 'show', `dev:${RELEASE_DOC}`), /<!-- hp-release-review-anchors -->\n### Материал ревью\n\n```\ntag v1\.78\.0\n/);
|
||
});
|
||
|
||
for (const [label, stderr, kind, reason] of [
|
||
['право на workflow', remoteRejected('dev', WORKFLOW_REASON), PUSH_REFUSAL.workflow, WORKFLOW_REASON],
|
||
['прочий [remote rejected] (с заголовком Authorization и чужим токеном)', noisyRejected('dev'), PUSH_REFUSAL.remote, 'protected branch hook declined'],
|
||
['не отказ (сеть, аутентификация)', NETWORK, PUSH_REFUSAL.unknown, ''],
|
||
]) {
|
||
test(`#723 release-review.yml на настоящем bash: ${label} — без повторов, причина и ответ git без токена в журнале и сводке`, (t) => {
|
||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||
const box = sandbox(tempRoot(t, 'hp-723-release-'));
|
||
const before = git(box.origin, 'rev-parse', 'dev');
|
||
box.refuse(1, stderr);
|
||
const r = runRelease(box);
|
||
assert.equal(r.status, 1, r.stderr + r.stdout);
|
||
assert.equal(r.pushes, 1, 'повторов нет');
|
||
assert.deepEqual(r.sleeps, []);
|
||
assert.doesNotMatch(r.stdout, /dev ушёл вперёд/, 'отказ GitHub — не сдвиг dev');
|
||
assert.match(r.stdout, new RegExp(`::error::push docs/reviews/RELEASE-REVIEW-v1\\.78\\.0\\.md в dev отклонён \\(${kind}\\) — это не сдвиг dev`));
|
||
assert.match(r.stderr, new RegExp(`git push отклонён — ${kind}`), 'ответ git — в журнале');
|
||
assert.match(r.summary, new RegExp(`^### git push в \`dev\` отклонён: ${kind} \\(#723\\)$`, 'm'));
|
||
assert.match(r.summary, /Документ независимого ревью релиза не опубликован в `dev`/);
|
||
if (reason) {
|
||
assert.ok(r.summary.includes(`Причина, которую назвал GitHub: «${reason}»`), r.summary);
|
||
assert.ok(r.stderr.includes(reason));
|
||
}
|
||
assert.match(r.summary, /Ответ git:\n\n```\n[\s\S]+\n```\n$/);
|
||
assert.doesNotMatch(r.summary, /Независимое ревью v1\.78\.0/, 'успеха в сводке нет');
|
||
noisySecretsGone(r);
|
||
assert.equal(git(box.origin, 'rev-parse', 'dev'), before, 'dev не тронут');
|
||
});
|
||
}
|
||
|
||
function noisySecretsGone(r) {
|
||
noSecrets(r);
|
||
assert.doesNotMatch(r.summary + r.stderr, /Authorization: Basic (?!\*\*\*)/i);
|
||
}
|
||
|
||
// ---------- _process.yml: документ ревью в ветку задачи ----------
|
||
|
||
const BRANCH = 'issue/9-fix';
|
||
const REVIEW_DOC = 'docs/reviews/CODE-REVIEW-9-r1.md';
|
||
|
||
function taskBranch(box) {
|
||
git(box.work, 'checkout', '-q', '-b', BRANCH);
|
||
writeFileSync(join(box.work, 'a.mjs'), 'export const a = 9;\n');
|
||
commitAll(box.work, 'fix: a (#9)');
|
||
git(box.work, 'push', '-q', 'origin', BRANCH);
|
||
// Как actions/checkout в integrate: рабочая копия на dev.
|
||
git(box.work, 'checkout', '-q', 'dev');
|
||
}
|
||
|
||
function runReviewDoc(box) {
|
||
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,
|
||
MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`),
|
||
MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: '{"verdict":"green","high":0}',
|
||
});
|
||
}
|
||
|
||
test('#723 _process.yml на настоящем bash: ветка ушла вперёд — прежний ребейз и второй push, документ на ветке', (t) => {
|
||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
|
||
taskBranch(box);
|
||
box.neighbour(BRANCH, 1, 'b.mjs', 'export const b = 1;\n');
|
||
const r = runReviewDoc(box);
|
||
assert.equal(r.status, 0, r.stderr + r.stdout);
|
||
assert.equal(r.pushes, 2);
|
||
assert.ok(r.calls.some((call) => /(^| )rebase origin\/issue\/9-fix$/.test(call)), 'ребейз на сдвинутую ветку');
|
||
assert.match(r.stderr, /git push отклонён — stale \(fetch first\)/);
|
||
assert.match(r.stdout, /документ опубликован в issue\/9-fix: docs\/reviews\/CODE-REVIEW-9-r1\.md/);
|
||
assert.equal(r.summary, '', 'сводка об отказе не пишется: повтор прошёл');
|
||
assert.equal(git(box.origin, 'log', '-1', '--format=%B', BRANCH), 'docs: review document for #9\n\nIssue: #9\nUser-Visible: no');
|
||
assert.deepEqual(git(box.origin, 'show', '--name-only', '--format=', BRANCH).split('\n'), [REVIEW_DOC], 'один документ, без индекса');
|
||
assert.equal(git(box.origin, 'log', '-1', '--format=%s', `${BRANCH}~1`), 'neighbour b.mjs', 'коммит автора не потерян');
|
||
});
|
||
|
||
for (const [label, stderr, kind] of [
|
||
['право на workflow', remoteRejected(BRANCH, WORKFLOW_REASON), PUSH_REFUSAL.workflow],
|
||
['прочий [remote rejected] (с заголовком Authorization и чужим токеном)', noisyRejected(BRANCH), PUSH_REFUSAL.remote],
|
||
]) {
|
||
test(`#723 _process.yml на настоящем bash: ${label} — без ребейза и второго push, причина в журнале и сводке`, (t) => {
|
||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
|
||
taskBranch(box);
|
||
const before = git(box.origin, 'rev-parse', BRANCH);
|
||
box.refuse(1, stderr);
|
||
const r = runReviewDoc(box);
|
||
assert.equal(r.status, 1, r.stderr + r.stdout);
|
||
assert.equal(r.pushes, 1, 'второго push нет');
|
||
assert.ok(!r.calls.some((call) => /(^| )rebase /.test(call)), 'ребейза нет');
|
||
assert.match(r.stdout, new RegExp(`::error::push документа ревью в issue/9-fix отклонён \\(${kind}\\) — это не сдвиг issue/9-fix`));
|
||
assert.match(r.stderr, new RegExp(`git push отклонён — ${kind}`));
|
||
assert.match(r.summary, new RegExp(`^### git push в \`issue/9-fix\` отклонён: ${kind} \\(#723\\)$`, 'm'));
|
||
assert.match(r.summary, /Документ ревью не опубликован в `issue\/9-fix`/);
|
||
if (kind === PUSH_REFUSAL.workflow) assert.match(r.summary, /Файлы: `\.github\/workflows\/validate\.yml`\./);
|
||
noisySecretsGone(r);
|
||
assert.equal(git(box.origin, 'rev-parse', BRANCH), before, 'ветка не тронута');
|
||
});
|
||
}
|
||
|
||
test('#723 _process.yml на настоящем bash: сдвиг, затем отказ GitHub на втором push — ошибка с причиной, без третьей попытки', (t) => {
|
||
if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; }
|
||
const box = sandbox(tempRoot(t, 'hp-723-doc-'));
|
||
taskBranch(box);
|
||
box.neighbour(BRANCH, 1, 'b.mjs', 'export const b = 1;\n');
|
||
box.refuse(2, noisyRejected(BRANCH));
|
||
const r = runReviewDoc(box);
|
||
assert.equal(r.status, 1, r.stderr + r.stdout);
|
||
assert.equal(r.pushes, 2);
|
||
assert.match(r.stdout, /::error::документ ревью не опубликован в issue\/9-fix и после ребейза \(remote-rejected\)/);
|
||
assert.match(r.summary, /^### git push в `issue\/9-fix` отклонён: remote-rejected \(#723\)$/m);
|
||
noisySecretsGone(r);
|
||
});
|
||
|
||
// ---------- 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']]) {
|
||
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"/);
|
||
// Блок run не обрезан: последняя строка каждого шага на месте.
|
||
assert.match(RELEASE_STEP(), /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/);
|
||
assert.match(REVIEW_DOC_STEP(), /echo "документ опубликован в \$target: \$doc"\n*$/);
|
||
});
|
||
|
||
test('#723 AC2: сводка об отказе — без токена, URL с учётными данными и Authorization; причина и файлы названы', () => {
|
||
const refusal = classifyPushRefusal(`> Authorization: Bearer ${OTHER_TOKEN}\n${remoteRejected('dev', WORKFLOW_REASON)}`, { secrets: [FAKE_TOKEN] });
|
||
const text = refusalSummary(refusal, { ref: 'dev', stage: 'release-review' });
|
||
for (const secret of SECRETS) assert.ok(!text.includes(secret));
|
||
assert.doesNotMatch(text, /x-access-token:(?!\*\*\*@)/);
|
||
assert.match(text, /Authorization: Bearer \*\*\*/);
|
||
assert.match(text, /^### git push в `dev` отклонён: workflow \(#723\)\n\nДокумент независимого ревью релиза не опубликован в `dev`\. GitHub отклонил push по праву на workflow/);
|
||
assert.match(text, /Файлы: `\.github\/workflows\/validate\.yml`\./);
|
||
// ``` в ответе git не закрывает блок кода сводки
|
||
const fenced = refusalSummary(classifyPushRefusal(remoteRejected('dev', 'hook declined ```x```')), { ref: 'dev', stage: 'review-doc' });
|
||
assert.equal(fenced.match(/```/g).length, 2);
|
||
});
|
||
|
||
test('#723 CLI --summary: для устаревшего lease сводку не пишет (шаг повторяет), для отказа GitHub — дописывает', () => {
|
||
const temp = mkdtempSync(join(tmpdir(), 'hp-723-cli-'));
|
||
try {
|
||
const cli = join(SCRIPTS, 'merge-candidate.mjs');
|
||
const summary = join(temp, 'summary.md');
|
||
writeFileSync(summary, 'прежнее\n');
|
||
const run = (stderr) => {
|
||
writeFileSync(join(temp, 'push.stderr'), stderr);
|
||
return spawnSync(process.execPath, [cli, `--push-refusal=${join(temp, 'push.stderr')}`, '--ref=dev', '--stage=release-review', `--summary=${summary}`], {
|
||
encoding: 'utf8', env: { ...ENV, TOKEN: FAKE_TOKEN },
|
||
});
|
||
};
|
||
const stale = run(' ! [rejected] HEAD -> dev (fetch first)\nerror: failed to push some refs\n');
|
||
assert.equal(stale.status, 0);
|
||
assert.equal(stale.stdout, 'stale\n');
|
||
assert.equal(readFileSync(summary, 'utf8'), 'прежнее\n', 'stale — без сводки');
|
||
const refused = run(remoteRejected('dev', 'protected branch hook declined'));
|
||
assert.equal(refused.stdout, 'remote-rejected\n');
|
||
const text = readFileSync(summary, 'utf8');
|
||
assert.ok(text.startsWith('прежнее\n### git push в `dev` отклонён: remote-rejected (#723)\n'), 'дописывает, не затирает');
|
||
assert.ok(!text.includes(FAKE_TOKEN) && !refused.stderr.includes(FAKE_TOKEN));
|
||
} finally { rmSync(temp, { recursive: true, force: true }); }
|
||
});
|