diff --git a/PROCESS.md b/PROCESS.md index 06870745..99ba0ad9 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -588,6 +588,13 @@ Performance зелёные на точном SHA; статусов issue не к считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него попали бы все нарушения, совершённые до появления гейта. + При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель — + уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev` + общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из + `origin/main`; сам merge и новые post-merge коммиты остаются под всеми + проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение + не распространяется (issue #155). + Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук, который не работает в самолёте, отключают целиком, а строгий проход всё равно diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 050a6bdf..8e024840 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -293,6 +293,24 @@ export function isStableTarget(targetRef) { return /^(?:refs\/heads\/)?main$/.test(targetRef ?? ''); } +export function isDevTarget(targetRef) { + return /^(?:refs\/heads\/)?dev$/.test(targetRef ?? ''); +} + +// A main-only infrastructure commit is already published and already passed +// the process gate. When main is merged back into dev, `old-dev..merge` walks +// that second parent as if it were fresh issue-branch work. Requiring its +// closed issue to become active again makes the mandatory main -> dev +// reconciliation impossible. Exclude only commits proven reachable from the +// remote main ref, and only while the destination itself is dev. The merge +// commit and every genuinely new commit remain in the checked set. +export function commitsNeedingTargetValidation( + commits, { targetRef = '', isCommitOnMain = () => false } = {}, +) { + if (!isDevTarget(targetRef)) return commits; + return commits.filter((commit) => !isCommitOnMain(commit.sha)); +} + // При stable promotion диапазон main..candidate закономерно содержит коммиты, // уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты // (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые @@ -424,8 +442,27 @@ function main(argv) { ); const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim(); + const hasOriginMain = spawnSync( + 'git', ['-C', repo, 'rev-parse', '--verify', 'refs/remotes/origin/main'], + { encoding: 'utf8' }, + ).status === 0; + const mainCache = new Map(); + const isCommitOnMain = (sha) => { + if (!hasOriginMain) return false; + if (!mainCache.has(sha)) { + mainCache.set(sha, spawnSync( + 'git', ['-C', repo, 'merge-base', '--is-ancestor', sha, 'refs/remotes/origin/main'], + { encoding: 'utf8' }, + ).status === 0); + } + return mainCache.get(sha); + }; + const checkedCommits = commitsNeedingTargetValidation( + commits, { targetRef, isCommitOnMain }, + ); + const findings = []; - for (const c of commits) findings.push(...evaluateCommit(c)); + for (const c of checkedCommits) findings.push(...evaluateCommit(c)); // Проверке 2 отдаются только коммиты самой ветки: origin/dev..HEAD, а не // диапазон события. См. комментарий у checkBranchRule. @@ -433,12 +470,13 @@ function main(argv) { ? (() => { const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'], { encoding: 'utf8' }).status === 0; - if (!hasDev) return commits; - return parseRecords( + if (!hasDev) return checkedCommits; + const own = parseRecords( git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo), ); + return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain }); })() - : commits; + : checkedCommits; findings.push(...checkBranchRule(branch, ownCommits)); // Метки читаются один раз и используются дважды: проверкой 8 и escalation @@ -458,7 +496,7 @@ function main(argv) { } return publishedCache.get(sha); }; - const statusCommits = commitsNeedingIssueStatus(commits, { + const statusCommits = commitsNeedingIssueStatus(checkedCommits, { targetRef, isPublishedPrereleaseCommit, }); const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))]; @@ -482,7 +520,9 @@ function main(argv) { } const specsDir = join(repo, 'docs', 'specs'); - findings.push(...checkSpecs(commits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf)); + findings.push(...checkSpecs( + checkedCommits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf, + )); const reviewDir = join(repo, 'docs', 'reviews'); const reviewFiles = [ diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 459361a5..8ff9d436 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -16,6 +16,7 @@ import { checkSpecs, classify, commitsNeedingIssueStatus, + commitsNeedingTargetValidation, commitsUnderRuleOne, evaluateCommit, makeCommit, @@ -202,6 +203,55 @@ test('stable promotion skips status recheck only for commits already published i ); }); +test('dev reconciliation ignores published main commits but keeps new post-merge work', () => { + const mainOnly = makeCommit({ + sha: 'a'.repeat(40), subject: 'Main-only workflow fix', body: 'Issue: #85', + files: ['.github/workflows/mutation-gate.yml'], + }); + const postMerge = makeCommit({ + sha: 'b'.repeat(40), subject: 'New gate fix', body: 'Issue: #155', + files: ['scripts/process-gate.mjs'], + }); + const commits = [mainOnly, postMerge]; + const isCommitOnMain = (sha) => sha === mainOnly.sha; + + const dev = commitsNeedingTargetValidation(commits, { + targetRef: 'refs/heads/dev', isCommitOnMain, + }); + assert.deepEqual(dev.map((commit) => commit.sha), [postMerge.sha]); + + const statusByIssue = (nn) => ({ + ok: true, + json: nn === '85' + ? { state: 'CLOSED', labels: [] } + : { state: 'OPEN', labels: [{ name: 'S6-in-progress' }] }, + }); + assert.deepEqual( + rules(checkIssueStatuses( + dev.flatMap((candidate) => candidate.issues).map((issue) => issue.slice(1)), + statusByIssue, + )), + [], + ); + assert.deepEqual(rules(checkIssueStatuses(['85', '155'], statusByIssue)), [8]); + + // The exemption belongs only to a dev destination. Main promotion, issue + // branches and an ordinary dev push with no main-reachable commits keep the + // complete input set. + for (const targetRef of ['refs/heads/main', 'refs/heads/issue/155-gate']) { + assert.deepEqual( + commitsNeedingTargetValidation(commits, { targetRef, isCommitOnMain }), + commits, + ); + } + assert.deepEqual( + commitsNeedingTargetValidation(commits, { + targetRef: 'refs/heads/dev', isCommitOnMain: () => false, + }), + commits, + ); +}); + test('issue status check is fail closed when the source of truth is unreachable', () => { // AC3: недоступный gh должен давать отказ, а не молчаливый пропуск. const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });