fix(process): publish steps tell a GitHub push refusal from a moved branch (#723)

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
This commit is contained in:
Claude
2026-09-30 22:26:44 +00:00
committed by claude[bot]
parent 5cd9d0419a
commit 0d85807157
6 changed files with 503 additions and 24 deletions
+35 -9
View File
@@ -1597,14 +1597,17 @@ jobs:
fi
# Первый рубеж: что вообще проиндексировано.
git diff --cached --name-only | node scripts/review-doc-guard.mjs
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
msg="$RUNNER_TEMP/review-doc-commit.txt"
{
echo "docs: review document for #$NUM"
echo ""
echo "Issue: #$NUM"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
docs: review document for #$NUM
Issue: #$NUM
User-Visible: no
EOF
commit -q -F "$msg"
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
# судит намерение шага, этот — результат, а расходились они именно
# тогда, когда база оказывалась не той.
@@ -1612,8 +1615,26 @@ jobs:
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
#
# #723: гонкой считается только устаревший lease. Отказ разбирает код
# слияния (merge-candidate.mjs --push-refusal, #705) — из dev, как у
# стража ребейза: ветка, отставшая от dev, его может не нести. Отказ
# GitHub (право на workflow, правило ветки, хук) ребейз не лечит:
# шаг падает сразу, причина и ответ git без токена — в журнале и в
# сводке шага; метка не меняется.
tools="$RUNNER_TEMP/publish-tools"
rm -rf "$tools" && mkdir -p "$tools"
git fetch -q origin dev
git archive origin/dev scripts | tar -x -C "$tools"
push_err="$RUNNER_TEMP/review-doc-push.stderr"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" != "stale" ]; then
echo "::error::push документа ревью в $target отклонён ($kind) — это не сдвиг $target, ребейз не поможет; причина и ответ git — выше и в сводке шага"
exit 1
fi
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -1626,8 +1647,13 @@ jobs:
# После ребейза набор путей другой — проверяется заново. Форс здесь
# запрещён и не появляется: ветка двигается только вперёд.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target" 2> "$push_err"; then
kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
echo "::error::документ ревью не опубликован в $target и после ребейза ($kind); причина и ответ git — выше"
exit 1
fi
fi
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
+25 -10
View File
@@ -303,6 +303,9 @@ jobs:
(cd "$dir" && sha256sum -c manifest.sha256)
test "$DOC" = "$(node scripts/release-review.mjs doc --tag="$TAG")"
counts=$(jq -r '"High \(.high) · Medium \(.medium) · Low \(.low)"' "$dir/result.json")
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
msg="$RUNNER_TEMP/release-review-commit.txt"
push_err="$RUNNER_TEMP/release-review-push.stderr"
for attempt in 1 2 3; do
git fetch -q origin dev
git reset -q --hard origin/dev
@@ -318,24 +321,36 @@ jobs:
node scripts/reviews-index.mjs --dir=docs/reviews --strict
git add -- "$DOC" docs/reviews/INDEX.md
git diff --cached --name-only | node scripts/review-doc-guard.mjs
{
echo "docs: release review for $TAG"
echo ""
echo "Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5)."
echo "Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем."
echo ""
echo "Issue: #638"
echo "User-Visible: no"
} > "$msg"
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<MSG
docs: release review for $TAG
Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5).
Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем.
Issue: #638
User-Visible: no
MSG
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
echo "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: $counts"
exit 0
fi
# #723: сдвигом dev считается только устаревший lease — отказ
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705).
# Отказ GitHub (право на workflow, правило ветки, хук) повтор не
# лечит: шаг останавливается, причина и ответ git без токена — в
# журнале и в сводке шага.
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
--stage=release-review --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
if [ "$kind" != "stale" ]; then
echo "::error::push $DOC в dev отклонён ($kind) — это не сдвиг dev, повтор не поможет; причина и ответ git — выше и в сводке шага"
exit 1
fi
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
sleep $((attempt * 10))
done
+4 -1
View File
@@ -1349,7 +1349,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
#312: `S6-in-progress` с причиной и ответом git без токена (#705). Отказ по
праву на workflow (кандидат меняет `.github/workflows/`, токен конвейера его
не публикует) называется отдельно: ребейз и push делает автор, либо
владелец выдаёт право. Страж ребейза до ревью разбирает отказ тем же кодом;
владелец выдаёт право. Страж ребейза до ревью разбирает отказ тем же кодом, как
и публикация документов ревью — в ветку задачи и ревью релиза в `dev` (#723):
повтор или ребейз — только на устаревший lease, отказ GitHub останавливает шаг
с причиной и ответом git без токена в сводке;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
+41 -3
View File
@@ -514,19 +514,57 @@ export function describePushRefusal(stderr, { ref, branch, candidate, stage = 'r
return { refusal, comment };
}
/**
* #723: шаги публикации — документ ревью в ветку задачи (`_process.yml`) и
* документ ревью релиза в `dev` (`release-review.yml`) — любой отказ push
* считали сдвигом ветки и повторяли. Повтор лечит только устаревший lease;
* отказ GitHub шаг останавливает, а причина и ответ git (уже без секретов)
* ложатся в сводку шага. Текст — здесь, а не многострочной строкой в `run:`.
*/
const PUBLISHED = Object.freeze({
'review-doc': 'Документ ревью',
'release-review': 'Документ независимого ревью релиза',
});
export function refusalSummary(refusal, { ref = '', stage = '' } = {}) {
const what = PUBLISHED[stage] || 'Коммит';
const why = refusal.kind === PUSH_REFUSAL.workflow
? 'GitHub отклонил push по праву на workflow: у токена конвейера нет права создавать и менять `.github/workflows/`'
: refusal.kind === PUSH_REFUSAL.remote
? 'GitHub отклонил push сам (правило ветки, хук, сбой сервера)'
: 'git push не удался, и это не отказ по lease (сеть, аутентификация)';
// Ответ GitHub не должен открыть или закрыть блок кода сводки.
const unfence = (text) => String(text || '').replace(/```/g, "'''");
const files = (refusal.files || []).map((file) => `\`${file}\``).join(', ');
const stderr = unfence(refusal.stderr);
return [
`### git push в \`${ref}\` отклонён: ${refusal.kind} (#723)`,
'',
`${what} не опубликован в \`${ref}\`. ${why}. Это не сдвиг \`${ref}\`: повтор и ребейз не помогут, шаг остановлен без повторов.`,
...(refusal.reason ? ['', `Причина, которую назвал GitHub: «${unfence(refusal.reason)}».${files ? ` Файлы: ${files}.` : ''}`] : []),
'',
stderr ? `Ответ git:\n\n\`\`\`\n${stderr}\n\`\`\`` : 'git не прислал текста отказа.',
'',
].join('\n');
}
/**
* `--push-refusal=<файл со stderr git push>`: в stdout — одно слово исхода
* (`stale`, `workflow`, `remote-rejected`, `unknown`), в stderr — ответ git
* без секретов (журнал), с `--comment=<файл>` — комментарий для issue.
* без секретов (журнал), с `--comment=<файл>` — комментарий для issue, с
* `--summary=<файл>` (#723) — сводка шага об отказе, который повтор не лечит
* (для `stale` не пишется: шаг повторяет).
*/
function pushRefusalMain() {
const secrets = [process.env.TOKEN, process.env.HP_PROCESS_TOKEN, process.env.GH_TOKEN].filter(Boolean);
const ref = arg('ref') || arg('branch');
const stage = arg('stage') || 'rebase';
const { refusal, comment } = describePushRefusal(readFileSync(arg('push-refusal'), 'utf8'), {
ref: arg('ref') || arg('branch'), branch: arg('branch'), candidate: arg('candidate'),
stage: arg('stage') || 'rebase', pipelineUrl: arg('run-url'), secrets,
ref, branch: arg('branch'), candidate: arg('candidate'), stage, pipelineUrl: arg('run-url'), secrets,
});
console.error(`git push отклонён — ${refusal.kind}${refusal.reason ? ` (${refusal.reason})` : ''}:\n${refusal.stderr || '(stderr пуст)'}`);
if (arg('comment') && comment) writeFileSync(arg('comment'), `${comment}\n`);
if (arg('summary') && refusal.kind !== PUSH_REFUSAL.stale) appendFileSync(arg('summary'), refusalSummary(refusal, { ref, stage }));
process.stdout.write(`${refusal.kind}\n`);
}
+395
View File
@@ -0,0 +1,395 @@
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 }); }
});
+3 -1
View File
@@ -86,7 +86,9 @@ test('#638: только ручной/вызванный запуск на dev,
assert.match(publish, /secrets\.HP_PROCESS_TOKEN/);
assert.match(publish, /review-doc-guard\.mjs/, 'в dev уходит только docs\/reviews');
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/, 'индекс тем же коммитом и неизвестное имя сразу блокирует публикацию');
assert.match(publish, /Issue: #638\n\s+User-Visible: no/, 'трейлеры провенанса');
// #723: сообщение — построчно в файл, без heredoc в run; точный текст
// коммита судит исполнение шага (test/publish-push-refusal.test.mjs).
assert.match(publish, /echo "Issue: #638"\n\s+echo "User-Visible: no"/, 'трейлеры провенанса');
});
test('#638: повтор на тот же тег не тратит модель, если документ уже в dev', () => {