fix: ignore published main commits during dev reconciliation

Issue: #155
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-14 21:25:45 +03:00
parent 7f70b64f48
commit 321d153c22
3 changed files with 103 additions and 6 deletions
+7
View File
@@ -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`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
+46 -6
View File
@@ -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 = [
+50
View File
@@ -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' });