diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 5f66f098..8e48021f 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -560,11 +560,37 @@ jobs: # fetch и push автор мог запушить коммит, и слепой --force потерял бы # его молча. Расхождение lease — падение прогона, а не предупреждение: # ревью пошло бы по коду, которого на ветке уже нет. + # + # #705: отказ разбирает тот же код, что у слияния кандидата + # (merge-candidate.mjs --push-refusal): ответ git — в журнал без + # токена, исход — одним словом. Отказ GitHub по праву на workflow — + # не сдвинутая ветка: задача возвращается автору без ревью с + # комментарием, как при конфликте. Прочий отказ GitHub — ошибка + # прогона со своей причиной. + push_err="$RUNNER_TEMP/rebase-push.stderr" if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \ "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ - "HEAD:refs/heads/$BRANCH"; then - echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора" - exit 1 + "HEAD:refs/heads/$BRANCH" 2> "$push_err"; then + refusal="$RUNNER_TEMP/push-refusal.md" + kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" \ + --branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \ + --run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --comment="$refusal") || kind=unknown + case "$kind" in + stale) + echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора" + exit 1 + ;; + workflow) + { echo 'refused=workflow'; echo "refusal_comment=$refusal"; } >> "$GITHUB_OUTPUT" + echo "::warning::GitHub не принял push ребейза $BRANCH: кандидат меняет workflow-файл, у токена конвейера нет права на workflow — ревью не запускается" + exit 0 + ;; + *) + echo "::error::GitHub отклонил push ребейза $BRANCH ($kind) — это не изменение ветки автором; ответ git выше" + exit 1 + ;; + esac fi # #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже # просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать @@ -598,7 +624,7 @@ jobs: # это заметить и отказаться, а не молча увезти в dev непроверенный код. - name: Зафиксировать SHA материала ревью id: material - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' env: NUM: ${{ github.event.issue.number }} REPO: ${{ github.repository }} @@ -651,7 +677,7 @@ jobs: # теле issue, которого в дереве нет. - name: "Зелёный вердикт прошлого захода применим без ревью (#499)" id: reuse - if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' + if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' && needs.guard.outputs.stage == 'code' env: NUM: ${{ github.event.issue.number }} # Правка ТЗ между раундами обязана отменять повторное применение @@ -702,6 +728,23 @@ jobs: --add-label S6-in-progress --remove-label S7-code-review echo "S7-code-review -> S6-in-progress (ревью не запускалось)" + # #705: GitHub не принял push приведённой ветки — кандидат меняет + # workflow-файл, а у токена конвейера нет права на workflow. Ветку никто + # не двигал, и ревью по неопубликованному ребейзу шло бы по коду, которого + # на ветке нет. Возврат автору без ревью, как при конфликте: цикл не + # израсходован, метка меняется. Текст комментария собрал разбор отказа. + - name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)" + if: steps.rebase.outputs.refused == 'workflow' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + COMMENT: ${{ steps.rebase.outputs.refusal_comment }} + run: | + gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$COMMENT" + gh issue edit "$NUM" --repo "${{ github.repository }}" \ + --add-label S6-in-progress --remove-label S7-code-review + echo "S7-code-review -> S6-in-progress (ревью не запускалось: push отклонён по праву на workflow)" + # До ревью конвейер запускает Validate на материале и ждёт его — всегда # лёгкий: мутантов в разработке нет (#709), весь реестр — ночью. Лёгкому годится и # завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или @@ -712,7 +755,7 @@ jobs: # слияние само дожидается Validate на кандидате. - name: Validate на материале id: gate - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} STAGE: ${{ needs.guard.outputs.stage }} diff --git a/PROCESS.md b/PROCESS.md index 1d6b61dd..0691d25f 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1333,6 +1333,11 @@ npm ci, Python и Chromium, оставаясь исполненной job: до затем push в `dev` с lease на ту вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова — новая попытка; после третьей — `S6-in-progress` с комментарием; +- push, отклонённый самим GitHub (`! [remote rejected]`), — не lease и не + #312: `S6-in-progress` с причиной и ответом git без токена (#705). Отказ по + праву на workflow (кандидат меняет `.github/workflows/`, токен конвейера его + не публикует) называется отдельно: ребейз и push делает автор, либо + владелец выдаёт право. Страж ребейза до ревью разбирает отказ тем же кодом; - красный Validate на кандидате или прогон, не появившийся за три минуты, — `S6-in-progress` с ссылкой; `S8-merged` ставится только после push; - после push ветка задачи удаляется с lease на влитую вершину (#702): коммит, diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index dccec5f0..78c668e4 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -20,7 +20,7 @@ // случаев §8.4 была юнит-тестом, а не верой в shell. import { spawnSync } from 'node:child_process'; -import { appendFileSync } from 'node:fs'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; import { isMainModule } from './spawn-portable.mjs'; import { fileURLToPath } from 'node:url'; import { @@ -41,6 +41,103 @@ export const PATCH_ID_EXCLUDES = Object.freeze([ export const VALIDATE_APPEAR_MS = 3 * 60 * 1000; export const VALIDATE_TOTAL_MS = 45 * 60 * 1000; +// --------------------------------------------------------------------------- +// Разбор отказа push (#705). +// +// До #705 любой отказ, где встречалось слово `rejected`, считался устаревшим +// lease: `! [remote rejected]` — отказ самого GitHub, например коммиту, +// меняющему `.github/workflows/`, от токена без права на workflow, — +// превращался в «ветка изменилась после проверенного материала (#312)», а +// stderr не печатался (#700: runs 36484993494, 36487044060). Исходов три: +// lease устарел (прежнее поведение), отказ по праву на workflow, прочий отказ +// GitHub. Всё, что не отказ (сеть, аутентификация), — по-прежнему сбой шага. + +export const PUSH_REFUSAL = Object.freeze({ + stale: 'stale', workflow: 'workflow', remote: 'remote-rejected', unknown: 'unknown', +}); + +/** + * Тексты GitHub, которые он кладёт в причину `! [remote rejected] … (…)`: + * - classic и fine-grained PAT: `refusing to allow a Personal Access Token to create or update workflow `.github/workflows/x.yml` without `workflow` scope`; + * - OAuth App (GCM, gh): `refusing to allow an OAuth App to create or update workflow `…` without `workflow` scope`; + * - GitHub App и GITHUB_TOKEN: `refusing to allow a GitHub App to create or update workflow `…` without `workflows` permission`; + * - прежние формы: `refusing to allow a bot to create or update workflow `…``, + * `refusing to allow an integration to create or update .github/workflows/x.yml`. + */ +const WORKFLOW_REFUSAL = /refusing to allow an? [^\n()]*? to create or update (?:workflow\b|[`'"]?\.github\/workflows\/)/i; +const WORKFLOW_FILE = /\.github\/workflows\/[^\s`'"()]+/g; +const REMOTE_REJECTED = /^\s*!\s*\[remote rejected\][^\n]*$/im; +const LOCAL_REJECTED = /^\s*!\s*\[rejected\][^\n]*$/im; +/** + * Гонка lease на стороне сервера: ссылка сдвинулась между объявлением и + * записью. По смыслу — тот же устаревший lease, что `(stale info)`. + */ +const SERVER_LEASE_RACE = /cannot lock ref [^\n]*but expected|incorrect old value provided/i; +/** Сколько ответа git везти в комментарий и журнал. */ +export const PUSH_STDERR_LIMIT = 1500; + +/** + * Вырезает учётные данные из текста, который уходит в журнал или в issue: + * userinfo в URL (`https://x-access-token:…@`), токены GitHub (`ghp_`, `gho_`, + * `ghu_`, `ghs_`, `ghr_`, `github_pat_`), заголовок Authorization и, если + * известны, сами значения секретов. + */ +export function redactSecrets(text, secrets = []) { + let out = String(text ?? ''); + for (const secret of secrets) { + if (secret && String(secret).length >= 4) out = out.split(String(secret)).join('***'); + } + return out + .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1***@') + .replace(/(? PUSH_STDERR_LIMIT ? `${text.slice(0, PUSH_STDERR_LIMIT)}\n…` : text; + const reasonOf = (line) => (line.match(/\(([^\n]*)\)\s*$/) || [])[1] || ''; + const out = (kind, reason = '', files = []) => ({ kind, reason, files, stderr: clipped }); + if (WORKFLOW_REFUSAL.test(text)) { + const line = text.split('\n').find((l) => WORKFLOW_REFUSAL.test(l)) || ''; + return out(PUSH_REFUSAL.workflow, reasonOf(line) || line.trim(), [...new Set(text.match(WORKFLOW_FILE) || [])]); + } + const remote = text.match(REMOTE_REJECTED); + if (remote) { + const reason = reasonOf(remote[0]); + return out(SERVER_LEASE_RACE.test(reason) ? PUSH_REFUSAL.stale : PUSH_REFUSAL.remote, reason); + } + // Отказ самого git по lease: `(stale info)`, `(fetch first)`, `(non-fast-forward)`. + const local = text.match(LOCAL_REJECTED); + if (local) return out(PUSH_REFUSAL.stale, reasonOf(local[0])); + if (/\((?:stale info|fetch first)\)/i.test(text)) return out(PUSH_REFUSAL.stale, 'stale info'); + return out(PUSH_REFUSAL.unknown); +} + +/** Отказ GitHub, который шаг слияния превращает в свой исход, а не в сбой. */ +export class PushRefusal extends Error { + constructor(ref, refusal, sha = '') { + super(`git push ${ref}: ${refusal.kind}${refusal.reason ? ` (${refusal.reason})` : ''}`); + this.name = 'PushRefusal'; + this.ref = ref; + this.sha = sha; + this.refusal = refusal; + } +} + +/** Ответ GitHub в комментарии: уже без секретов, в блоке кода. */ +function refusalExcerpt(ctx) { + const stderr = String(ctx.refusal?.stderr || '').replace(/```/g, "'''"); + return (stderr ? `Ответ GitHub:\n\n\`\`\`\n${stderr}\n\`\`\`` : 'GitHub не прислал текста отказа.') + + (ctx.pipelineUrl ? `\n\n[Прогон конвейера](${ctx.pipelineUrl}).` : ''); +} + /** * Чистое решение по состоянию одной попытки. Возвращает действие и, где * применимо, статусную метку, к которой ведёт это действие. @@ -52,10 +149,15 @@ export const VALIDATE_TOTAL_MS = 45 * 60 * 1000; * @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным * @param {'green'|'failed'|'missing'|'pending'|'cancelled'|'stale'|null} s.validate результат общего CI proof * @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова + * @param {'workflow'|'remote-rejected'|null} s.refused push отклонил сам GitHub (#705) * @param {number} s.attempt номер попытки, с 1 */ export function decideMerge(s) { if (!s.fresh) return { action: 'reject-stale', to: 'S6-in-progress' }; + // #705: отказ GitHub — не устаревший lease. Ветку никто не двигал, и + // «ветка изменилась после материала» (#312) была бы неправдой. + if (s.refused === PUSH_REFUSAL.workflow) return { action: 'push-refused-workflow', to: 'S6-in-progress' }; + if (s.refused) return { action: 'push-refused', to: 'S6-in-progress' }; if (s.conflict) return { action: 'conflict', to: 'S6-in-progress' }; if (!s.devMoved) { if (s.leaseRejected) return { action: 'retry' }; @@ -97,6 +199,26 @@ export function commentFor(action, ctx) { case 'give-up': return `**\`dev\` движется быстрее слияния: ${ctx.attempt} попытки собрать и проверить кандидата, каждый раз \`dev\` уходил до push (#492).**\n\n` + `Последний проверенный кандидат \`${short(ctx.candidate)}\` опубликован в ветку. Задача в \`S6-in-progress\`; вернуть \`S7-code-review\`, когда \`dev\` успокоится.`; + case 'push-refused-workflow': { + const rebase = ctx.stage === 'rebase'; + const files = (ctx.refusal?.files || []).map((f) => `\`${f}\``).join(', '); + return `**${rebase ? 'Ревью не запускалось' : 'Слияние не выполнено'}: кандидат меняет workflow-файл, токен конвейера не может его опубликовать: ребейз и push делает автор, либо владелец выдаёт право (#705).**\n\n` + + `GitHub отклонил push \`${short(ctx.candidate)}\` в \`${ctx.ref || ctx.branch}\`${files ? ` (${files})` : ''}: без права на workflow он не принимает коммит, который создаёт или меняет файл в \`.github/workflows/\`, если точно такого файла нет в другой ветке. ` + + `Это отказ GitHub по праву токена, а не расхождение ветки с проверенным материалом (#312).\n\n` + + (rebase + ? 'Код никто не читал, вердикта нет, цикл ревью не израсходован. ' + : 'Код-ревью зелёное — вердикт в силе, переделывать работу не нужно. ') + + `Задача переведена в \`S6-in-progress\`. Дальше — одно из двух:\n\n` + + `1. ребейз и push делает автор: \`git fetch origin\`, \`git rebase origin/dev\` в ветке \`${ctx.branch}\`, push своими учётными данными с правом на workflow; затем вернуть \`S7-code-review\`;\n` + + `2. либо владелец выдаёт токену конвейера \`HP_PROCESS_TOKEN\` право на workflow (classic PAT — scope \`workflow\`, fine-grained — Workflows: read and write) и возвращает \`S7-code-review\`.\n\n` + + refusalExcerpt(ctx); + } + case 'push-refused': + return `**${ctx.stage === 'rebase' ? 'Ревью не запускалось' : 'Слияние не выполнено'}: GitHub отклонил push в \`${ctx.ref || ctx.branch}\` (#705).**\n\n` + + `Причина, которую назвал GitHub: ${ctx.refusal?.reason ? `\`${ctx.refusal.reason}\`` : 'не указана'}. Это отказ самого GitHub (правило ветки, хук, сбой сервера), а не ветка, изменившаяся после проверенного материала (#312). ` + + (ctx.stage === 'rebase' ? 'Код никто не читал, цикл ревью не израсходован. ' : 'Вердикт ревью в силе. ') + + `Задача в \`S6-in-progress\`; устранить причину и вернуть \`S7-code-review\`.\n\n` + + refusalExcerpt(ctx); case 'push': case 'fast-forward': return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\`` @@ -139,10 +261,17 @@ export function realOps({ candidateTree = (sha) => githubCandidateTree({ repo, sha, token }), proofContext = (run) => loadGithubProofContext({ repo, run, token }), mutants = true, + log = (line) => console.log(line), }) { const pushUrl = `https://x-access-token:${token}@github.com/${repo}`; const git = (...args) => exec('git', args); const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; }; + // #705: ответ git на отказ — в журнал, без токена и URL с учётными данными. + const refusedPush = (ref, stderr) => { + const refusal = classifyPushRefusal(stderr, { secrets: [token] }); + log(`git push ${ref} отклонён — ${refusal.kind}${refusal.reason ? ` (${refusal.reason})` : ''}:\n${refusal.stderr || '(stderr пуст)'}`); + return refusal; + }; return { fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'), revParse: (ref) => must(git('rev-parse', ref), `rev-parse ${ref}`), @@ -190,17 +319,24 @@ export function realOps({ }, // #702: ветка задачи удаляется после слияния — только если её вершина всё // ещё та, что влита (lease): коммит, прилетевший после, не теряется. + // #705: отказ GitHub вершину ветки не доказывает — только устаревший lease + // оставляет ветку «сдвинутой»; прочее уходит в журнал ошибкой удаления. deleteBranch: (ref, expected) => { const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `:refs/heads/${ref}`); if (r.status === 0) return true; - if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false; - throw new Error(`git push :${ref}: ${r.stderr}`); + const refusal = refusedPush(`:${ref}`, r.stderr); + if (refusal.kind === PUSH_REFUSAL.stale) return false; + throw new Error(`git push :${ref}: ${refusal.kind}\n${refusal.stderr}`); }, + // true — ушло; false — lease устарел (прежний исход); отказ GitHub — + // PushRefusal со своим исходом (#705); прочий сбой — ошибка шага. pushWithLease: (sha, ref, expected) => { const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`); if (r.status === 0) return true; - if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false; - throw new Error(`git push ${ref}: ${r.stderr}`); + const refusal = refusedPush(ref, r.stderr); + if (refusal.kind === PUSH_REFUSAL.stale) return false; + if (refusal.kind === PUSH_REFUSAL.unknown) throw new Error(`git push ${ref}: ${refusal.stderr}`); + throw new PushRefusal(ref, refusal, sha); }, // Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза — // новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт @@ -245,14 +381,34 @@ export function realOps({ const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' }); if (r.status !== 0) throw new Error(`gh issue comment: ${r.stderr}`); }, - log: (line) => console.log(line), + log, }; } /** * Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }. + * + * #705: push, отклонённый самим GitHub (право на workflow, правило ветки), — + * свой исход с комментарием и `S6-in-progress`, а не «ветка изменилась» (#312) + * и не сбой шага. Устаревший lease по-прежнему решает `decideMerge` внутри попыток. */ -export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) { +export async function mergeCandidate(args) { + try { + return await mergeAttempts(args); + } catch (error) { + const refusal = error && error.refusal; + if (!refusal || refusal.kind === PUSH_REFUSAL.stale || refusal.kind === PUSH_REFUSAL.unknown) throw error; + const { branch, material, issue, ops, pipelineUrl } = args; + const decision = decideMerge({ fresh: true, refused: refusal.kind }); + ops.comment(issue, commentFor(decision.action, { + branch, material, issue, ref: error.ref, candidate: error.sha, refusal, stage: 'merge', pipelineUrl, + })); + ops.log(`решение: ${decision.action} → ${decision.to}`); + return { merged: false, to: decision.to, action: decision.action, candidate: error.sha || material }; + } +} + +async function mergeAttempts({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) { ops.fetch('dev', branch); const actual = ops.revParse(`origin/${branch}`); const reviewedFresh = actual === material @@ -345,7 +501,37 @@ const safe = (fn) => { try { return fn(); } catch { return null; } }; const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); -if (isMainModule(import.meta.url)) { // #496: переносимо для Windows +/** + * #705: отказ push для шага workflow — стража ребейза в `_process.yml`. Тот же + * разбор, что у слияния: исход, ответ git без секретов и, для отказа GitHub, + * текст комментария в issue. + */ +export function describePushRefusal(stderr, { ref, branch, candidate, stage = 'rebase', pipelineUrl, secrets = [] } = {}) { + const refusal = classifyPushRefusal(stderr, { secrets }); + const action = refusal.kind === PUSH_REFUSAL.workflow ? 'push-refused-workflow' + : refusal.kind === PUSH_REFUSAL.remote ? 'push-refused' : ''; + const comment = action ? commentFor(action, { ref, branch, candidate, refusal, stage, pipelineUrl }) : ''; + return { refusal, comment }; +} + +/** + * `--push-refusal=<файл со stderr git push>`: в stdout — одно слово исхода + * (`stale`, `workflow`, `remote-rejected`, `unknown`), в stderr — ответ git + * без секретов (журнал), с `--comment=<файл>` — комментарий для issue. + */ +function pushRefusalMain() { + const secrets = [process.env.TOKEN, process.env.HP_PROCESS_TOKEN, process.env.GH_TOKEN].filter(Boolean); + 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, + }); + console.error(`git push отклонён — ${refusal.kind}${refusal.reason ? ` (${refusal.reason})` : ''}:\n${refusal.stderr || '(stderr пуст)'}`); + if (arg('comment') && comment) writeFileSync(arg('comment'), `${comment}\n`); + process.stdout.write(`${refusal.kind}\n`); +} + +if (isMainModule(import.meta.url) && arg('push-refusal')) pushRefusalMain(); +else if (isMainModule(import.meta.url)) { // #496: переносимо для Windows const branch = arg('branch'); const material = arg('material'); const issue = arg('issue'); @@ -358,16 +544,19 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window // #696: `--mutants=false` — треки show/ship сливаются по лёгкому Validate. const mutants = arg('mutants') !== 'false'; const ops = realOps({ repo, token, issue, mutants }); - mergeCandidate({ branch, material, issue, ops, mutants }).then((r) => { + const pipelineUrl = process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}` : ''; + mergeCandidate({ branch, material, issue, ops, mutants, pipelineUrl }).then((r) => { const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`; if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out); process.stdout.write(out); }, (err) => { // Инвариант конвейера: после прогона метка меняется всегда. Сбой самого // слияния — не повод оставить задачу висеть в S7: S6 и внятный комментарий. - console.error(err); + // #705: текст сбоя уходит в issue — без токена и URL с учётными данными. + console.error(redactSecrets(err && err.stack || err, [token])); try { - ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${String(err && err.message || err).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`); + ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${redactSecrets(String(err && err.message || err), [token]).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`); } catch (e) { console.error(e); } if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n'); process.exit(0); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 8fb161c2..35cecac9 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -5544,6 +5544,20 @@ const MUTANT_DEFINITIONS = [ replace: ' if (false && merged && extra.branchTip) { // mutant: branch kept', }], }, + // #705: отказ push — три исхода, а не один «lease устарел». + { + id: 'push-refusal-kinds-glued', + guard: 'node --test --test-name-pattern="#705 AC1" test/merge-candidate.test.mjs', + because: '#705: a GitHub refusal (`! [remote rejected]`, e.g. a workflow file pushed by a token ' + + 'without the workflow permission) read as a stale lease tells the author the branch moved after ' + + 'review (#312) and hides the reason; the old /stale info|rejected|fetch first|lease/ glued them (#700)', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: ' if (WORKFLOW_REFUSAL.test(text)) {\n', + replace: ' if (/stale info|rejected|fetch first|lease/i.test(text)) return out(PUSH_REFUSAL.stale); // mutant: #700 again\n' + + ' if (WORKFLOW_REFUSAL.test(text)) {\n', + }], + }, { id: 'nightly-does-not-wait', guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs', diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 5b2ba735..d13aa614 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -7,7 +7,8 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, commentFor, decideMerge, mergeCandidate, realOps, sh, + MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, PUSH_REFUSAL, PushRefusal, classifyPushRefusal, commentFor, decideMerge, + describePushRefusal, mergeCandidate, realOps, redactSecrets, sh, } from '../scripts/merge-candidate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; import { buildIndex } from '../scripts/reviews-index.mjs'; @@ -67,7 +68,7 @@ test('§8.4 таблица решений decideMerge', () => { test('каждый исход, меняющий метку, объясняется комментарием; успех — одной строкой', () => { const ctx = { material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/1-x', runUrl: 'https://run', attempt: 3 }; - for (const action of ['reject-stale', 'conflict', 'rereview', 'validation-red', 'validation-missing', 'give-up']) { + for (const action of ['reject-stale', 'conflict', 'rereview', 'validation-red', 'validation-missing', 'give-up', 'push-refused-workflow', 'push-refused']) { const body = commentFor(action, ctx); assert.ok(body.length > 80, action); assert.match(body, /S6-in-progress|S7-code-review/, action); @@ -82,7 +83,7 @@ test('каждый исход, меняющий метку, объясняетс * dev по порядку (следующая после каждого отклонённого lease), ответы * Validate — по порядку кандидатов. */ -function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false, deleteOk = true }) { +function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false, deleteOk = true, refuse = null }) { const calls = []; let devIndex = 0; let validateIndex = 0; @@ -112,6 +113,8 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject }, pushWithLease: (sha, ref, expected) => { calls.push(['push', sha, ref, expected]); + // #705: отказ самого GitHub — так, как его бросает realOps.pushWithLease. + if (refuse && refuse.ref === ref) throw new PushRefusal(ref, classifyPushRefusal(refuse.stderr), sha); if (ref === 'dev' && rejects > 0) { rejects -= 1; devIndex += 1; return false; } return true; }, @@ -122,7 +125,7 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject validateIndex += 1; return { result, url: `https://run/${sha}` }; }, - comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); }, + comment: (issue, body) => { calls.push(['comment', body.split('\n')[0], body]); }, deleteBranch: (ref, expected) => { calls.push(['delete', ref, expected]); return deleteOk; }, log: () => {}, }; @@ -675,3 +678,202 @@ test('#702: сдвинутая вершина — ветка остаётся, assert.equal(r.merged, true); assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /оставлена: её вершина сдвинулась после слияния/); }); + +// ---------- #705: отказ push — три исхода, а не один «lease устарел» ---------- +// +// #700 (runs 36484993494, 36487044060): GitHub не принял кандидата, менявшего +// `.github/workflows/`, от токена без права на workflow, а слияние по слову +// `rejected` выдало это за «ветка изменилась после материала (#312)» и не +// напечатало stderr. Тексты отказа — дословно те, что присылает GitHub. + +const FAKE_TOKEN = 'ghs_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'; +const PUSH_URL = `https://x-access-token:${FAKE_TOKEN}@github.com/o/r`; +const failedTo = `error: failed to push some refs to '${PUSH_URL}'`; +const remoteRejected = (reason, ref = 'issue/700-x') => `To https://github.com/o/r\n ! [remote rejected] 0123abcd -> ${ref} (${reason})\n${failedTo}`; +const WORKFLOW_REFUSALS = { + 'classic и fine-grained PAT': 'refusing to allow a Personal Access Token to create or update workflow `.github/workflows/validate.yml` without `workflow` scope', + 'OAuth App': 'refusing to allow an OAuth App to create or update workflow `.github/workflows/validate.yml` without `workflow` scope', + 'GitHub App / GITHUB_TOKEN': 'refusing to allow a GitHub App to create or update workflow `.github/workflows/validate.yml` without `workflows` permission', + 'bot (прежняя форма)': 'refusing to allow a bot to create or update workflow `.github/workflows/validate.yml`', + 'integration (прежняя форма)': 'refusing to allow an integration to create or update .github/workflows/validate.yml', +}; + +test('#705 AC1: разбор отказа push различает устаревший lease, право на workflow и прочий отказ GitHub', () => { + // устаревший lease — прежнее поведение (#312 / новая попытка) + for (const reason of ['stale info', 'fetch first', 'non-fast-forward']) { + const r = classifyPushRefusal(`To https://github.com/o/r\n ! [rejected] 0123abcd -> dev (${reason})\n${failedTo}`); + assert.equal(r.kind, PUSH_REFUSAL.stale, reason); + assert.equal(r.reason, reason); + } + // гонка lease на стороне сервера — тоже устаревший lease, а не отказ GitHub + assert.equal(classifyPushRefusal(remoteRejected("cannot lock ref 'refs/heads/dev': is at 1111 but expected 2222", 'dev')).kind, PUSH_REFUSAL.stale); + // отказ по праву на workflow — каждый вид токена + for (const [who, reason] of Object.entries(WORKFLOW_REFUSALS)) { + const r = classifyPushRefusal(remoteRejected(reason)); + assert.equal(r.kind, PUSH_REFUSAL.workflow, who); + assert.deepEqual(r.files, ['.github/workflows/validate.yml'], who); + assert.equal(r.reason, reason, who); + } + // прочий отказ GitHub — свой исход с причиной + const hook = classifyPushRefusal(`remote: error: GH006: Protected branch update failed for refs/heads/dev.\n${remoteRejected('protected branch hook declined', 'dev')}`); + assert.equal(hook.kind, PUSH_REFUSAL.remote); + assert.equal(hook.reason, 'protected branch hook declined'); + assert.equal(classifyPushRefusal(remoteRejected('pre-receive hook declined')).kind, PUSH_REFUSAL.remote); + // не отказ вовсе — сбой шага, как раньше + assert.equal(classifyPushRefusal(`fatal: unable to access '${PUSH_URL}/': The requested URL returned error: 403`).kind, PUSH_REFUSAL.unknown); + assert.equal(classifyPushRefusal('').kind, PUSH_REFUSAL.unknown); + // решение: отказ GitHub ведёт в S6 своим исходом, а не в «ветка изменилась» + assert.deepEqual(decideMerge({ fresh: true, refused: PUSH_REFUSAL.workflow }), { action: 'push-refused-workflow', to: 'S6-in-progress' }); + assert.deepEqual(decideMerge({ fresh: true, refused: PUSH_REFUSAL.remote }), { action: 'push-refused', to: 'S6-in-progress' }); +}); + +test('#705 AC1: ответ git уходит в журнал без токенов и URL с учётными данными', () => { + const pat = 'ghp_' + '0123456789abcdefghijABCDEFGHIJ012345'; + const fine = 'github_pat_' + '11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz'; + const oauth = 'gho_' + 'abcdefghij0123456789ABCDEFGHIJ012345'; + const text = [ + `fatal: unable to access 'https://x-access-token:${FAKE_TOKEN}@github.com/o/r/'`, + `https://oauth2:${fine}@github.com/o/r and https://user:${pat}@example.com/x`, + `token ${pat}, ${oauth}, ${fine}, ${FAKE_TOKEN}`, + 'Authorization: Bearer abc.def.ghi', + 'plain-secret-value in text', + ].join('\n'); + const clean = redactSecrets(text, ['plain-secret-value']); + for (const secret of [FAKE_TOKEN, pat, fine, oauth, 'abc.def.ghi', 'plain-secret-value', 'x-access-token:']) { + assert.ok(!clean.includes(secret), `${secret.slice(0, 12)}… вырезан`); + } + assert.match(clean, /https:\/\/\*\*\*@github\.com\/o\/r\//, 'хост и путь остаются — по ним видно, куда шёл push'); + const refusal = classifyPushRefusal(remoteRejected(WORKFLOW_REFUSALS['classic и fine-grained PAT']), { secrets: [FAKE_TOKEN] }); + assert.ok(!refusal.stderr.includes(FAKE_TOKEN)); + assert.match(refusal.stderr, /! \[remote rejected\] 0123abcd -> issue\/700-x \(refusing to allow/, 'сам отказ в журнале целиком'); +}); + +/** realOps с git, отвечающим на push заданным stderr. */ +function refusingOps(stderr) { + const logs = []; + const pushes = []; + const exec = (cmd, args) => { + if (cmd === 'git' && args[0] === 'push') { pushes.push(args); return { status: 1, stdout: '', stderr }; } + throw new Error(`unexpected ${cmd} ${args.join(' ')}`); + }; + return { ops: realOps({ repo: 'o/r', token: FAKE_TOKEN, exec, log: (line) => logs.push(line) }), logs, pushes }; +} + +test('#705 AC1: realOps.pushWithLease — lease устарел → false, отказ GitHub → PushRefusal, stderr в журнале без токена', () => { + const stale = refusingOps(`To https://github.com/o/r\n ! [rejected] 0123abcd -> dev (stale info)\n${failedTo}`); + assert.equal(stale.ops.pushWithLease('0123abcd', 'dev', 'dev0'), false, 'прежнее поведение: новая попытка или #312'); + assert.match(stale.logs.join('\n'), /git push dev отклонён — stale \(stale info\):[\s\S]*\(stale info\)/); + assert.ok(stale.pushes[0].includes(PUSH_URL), 'push идёт с токеном'); + + const workflow = refusingOps(remoteRejected(WORKFLOW_REFUSALS['GitHub App / GITHUB_TOKEN'])); + assert.throws(() => workflow.ops.pushWithLease('0123abcd', 'issue/700-x', 'mat'), (error) => { + assert.ok(error instanceof PushRefusal); + assert.equal(error.refusal.kind, PUSH_REFUSAL.workflow); + assert.equal(error.ref, 'issue/700-x'); + assert.equal(error.sha, '0123abcd'); + return true; + }); + const journal = workflow.logs.join('\n'); + assert.match(journal, /refusing to allow a GitHub App to create or update workflow/, 'stderr напечатан'); + assert.ok(!journal.includes(FAKE_TOKEN), 'без токена'); + + const other = refusingOps(remoteRejected('pre-receive hook declined', 'dev')); + assert.throws(() => other.ops.pushWithLease('0123abcd', 'dev', 'dev0'), (error) => error.refusal?.kind === PUSH_REFUSAL.remote); + + const broken = refusingOps(`fatal: unable to access '${PUSH_URL}/': Could not resolve host: github.com`); + assert.throws(() => broken.ops.pushWithLease('0123abcd', 'dev', 'dev0'), (error) => { + assert.ok(!(error instanceof PushRefusal), 'сбой сети — сбой шага, а не исход'); + assert.ok(!error.message.includes(FAKE_TOKEN), 'текст ошибки уйдёт в issue — без токена'); + return true; + }); + + // удаление влитой ветки: только устаревший lease значит «вершина сдвинулась» + assert.equal(refusingOps(' ! [rejected] (delete) -> issue/1-x (stale info)').ops.deleteBranch('issue/1-x', 'mat'), false); + assert.throws(() => refusingOps(remoteRejected('protected branch hook declined', 'issue/1-x')).ops.deleteBranch('issue/1-x', 'mat'), + /remote-rejected/, 'правило ветки — не «вершина сдвинулась после слияния»'); +}); + +test('#705 AC2: кандидат меняет workflow-файл — S6 с комментарием о праве, не #312; в dev ничего', async () => { + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', + refuse: { ref: 'issue/1-x', stderr: remoteRejected(WORKFLOW_REFUSALS['classic и fine-grained PAT'], 'issue/1-x') } }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, pipelineUrl: 'https://run/705' }); + assert.equal(r.action, 'push-refused-workflow'); + assert.equal(r.to, 'S6-in-progress'); + assert.equal(r.merged, false); + assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev'), 'в dev не уходит'); + assert.ok(!ops.calls.some((c) => c[0] === 'validate' || c[0] === 'delete')); + const comments = ops.calls.filter((c) => c[0] === 'comment'); + assert.equal(comments.length, 1); + const body = comments[0][2]; + assert.match(body, /кандидат меняет workflow-файл, токен конвейера не может его опубликовать: ребейз и push делает автор, либо владелец выдаёт право/); + assert.doesNotMatch(body, /ветка изменилась после проверенного материала/); + assert.match(body, /`\.github\/workflows\/validate\.yml`/, 'файл назван'); + assert.match(body, /вердикт в силе/); + assert.match(body, /```\n[\s\S]*refusing to allow a Personal Access Token[\s\S]*```/, 'ответ GitHub в комментарии'); + assert.ok(!body.includes(FAKE_TOKEN), 'без токена'); + assert.match(body, /\[Прогон конвейера\]\(https:\/\/run\/705\)/); +}); + +test('#705 AC2: прочий отказ GitHub на push в dev — свой исход в S6, без повторных попыток', async () => { + const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', + refuse: { ref: 'dev', stderr: remoteRejected('protected branch hook declined', 'dev') } }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'push-refused'); + assert.equal(r.to, 'S6-in-progress'); + assert.equal(ops.calls.filter((c) => c[0] === 'push').length, 1, 'отказ GitHub — не гонка lease: повтор его не лечит'); + const body = ops.calls.find((c) => c[0] === 'comment')[2]; + assert.match(body, /GitHub отклонил push в `dev`/); + assert.match(body, /`protected branch hook declined`/); + assert.doesNotMatch(body, /workflow-файл/); +}); + +test('#705: разбор для шага workflow — тот же исход и комментарий стража ребейза', () => { + const { refusal, comment } = describePushRefusal(remoteRejected(WORKFLOW_REFUSALS['OAuth App']), { + ref: 'issue/1-x', branch: 'issue/1-x', candidate: 'c'.repeat(40), stage: 'rebase', pipelineUrl: 'https://run/1', secrets: [FAKE_TOKEN], + }); + assert.equal(refusal.kind, PUSH_REFUSAL.workflow); + assert.match(comment, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл, токен конвейера не может его опубликовать: ребейз и push делает автор, либо владелец выдаёт право/); + assert.match(comment, /цикл ревью не израсходован/); + assert.doesNotMatch(comment, /вердикт в силе/, 'вердикта ещё нет'); + assert.ok(!comment.includes(FAKE_TOKEN)); + assert.equal(describePushRefusal(' ! [rejected] a -> b (stale info)').comment, '', 'на устаревший lease комментария нет: шаг падает, как раньше'); +}); + +test('#705 на настоящем git: отказ сервера `[remote rejected]` — не устаревший lease, отказ по lease — прежний false', (t) => { + if (process.platform === 'win32') { t.skip('хук pre-receive — shell-скрипт'); return; } + const dir = mkdtempSync(join(tmpdir(), 'hp-merge-705-')); + try { + const bare = join(dir, 'origin.git'); + execFileSync('git', ['init', '-q', '--bare', bare]); + // #717: без фонового автообслуживания — иначе rmSync ловит его lock-файлы. + for (const [key, value] of [['receive.autogc', 'false'], ['maintenance.auto', 'false'], ['gc.auto', '0']]) git(bare, 'config', key, value); + const work = join(dir, 'work'); + execFileSync('git', ['clone', '-q', bare, work]); + const cfg = ['-c', 'user.name=t', '-c', 'user.email=t@x', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0']; + writeFileSync(join(work, 'a.txt'), '1\n'); + git(work, 'add', '.'); + execFileSync('git', ['-C', work, ...cfg, 'commit', '-q', '-m', 'base']); + git(work, 'push', '-q', 'origin', 'HEAD:refs/heads/dev'); + const base = git(work, 'rev-parse', 'HEAD'); + writeFileSync(join(work, 'a.txt'), '2\n'); + execFileSync('git', ['-C', work, ...cfg, 'commit', '-q', '-am', 'next']); + const next = git(work, 'rev-parse', 'HEAD'); + // URL с токеном переписывается на локальный bare — push настоящий + const exec = (cmd, args, opts) => sh(cmd, cmd === 'git' + ? ['-C', work, '-c', `url.${bare}.insteadOf=https://x-access-token:${FAKE_TOKEN}@github.com/o/r`, ...args] : args, opts); + const logs = []; + const ops = realOps({ repo: 'o/r', token: FAKE_TOKEN, exec, log: (line) => logs.push(line) }); + assert.equal(ops.pushWithLease(next, 'dev', 'f'.repeat(40)), false, 'lease на чужую вершину — stale info'); + mkdirSync(join(bare, 'hooks'), { recursive: true }); + writeFileSync(join(bare, 'hooks', 'pre-receive'), '#!/bin/sh\necho "GH013: Repository rule violations found for refs/heads/dev." >&2\nexit 1\n', { mode: 0o755 }); + assert.throws(() => ops.pushWithLease(next, 'dev', base), (error) => { + assert.equal(error.refusal?.kind, PUSH_REFUSAL.remote, JSON.stringify(error.refusal)); + assert.equal(error.refusal.reason, 'pre-receive hook declined'); + return true; + }); + assert.match(logs.join('\n'), /GH013: Repository rule violations/, 'ответ сервера в журнале'); + assert.equal(git(work, 'ls-remote', bare, 'refs/heads/dev').split('\t')[0], base, 'dev не тронут'); + } finally { + rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + } +}); diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index f9690567..8dce322b 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -364,3 +364,113 @@ test('#698: patch-id кандидата не видит того, что реб 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/`); +});