From 50099acc7596bec9ace532ca93145ff7c93c1888 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 13 Aug 2026 22:59:23 +0300 Subject: [PATCH] fix: allow stable promotion of published beta history Issue: #130 User-Visible: no --- .githooks/pre-push | 2 +- .github/workflows/validate.yml | 1 + PROCESS.md | 9 ++++++++- scripts/process-gate.mjs | 35 +++++++++++++++++++++++++++++++++- test/process-gate.test.mjs | 31 ++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index 0a9a1cf1..5744c771 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2 # shellcheck disable=SC2086 - if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then + if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then status=1 fi done diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ad6ad811..d959eecf 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -51,6 +51,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.sha }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TARGET_REF: ${{ github.ref }} # Публичный репозиторий: штатного токена хватает на чтение issue. GH_TOKEN: ${{ github.token }} run: | diff --git a/PROCESS.md b/PROCESS.md index 6ae7241a..511d42d6 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -608,7 +608,7 @@ Project v2 остаётся человеческим представление {`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый, недоступный или помеченный `blocked` — отказ (**fail closed**). -Две оговорки к проверке 8, обе выяснились при реализации. + Три оговорки к проверке 8 выяснились при реализации. **`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку, @@ -621,6 +621,13 @@ Validate стартует от этого push и успевает прочит документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или `S7-code-review`, то есть заведомо вне рабочего множества. +**При продвижении в `main` не перепроверяются коммиты, уже достижимые из +prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а +stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`. +Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через +`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой +post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed. + Не реализовано и остаётся долгом: 9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 24af7265..050a6bdf 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -289,6 +289,22 @@ export function commitsUnderRuleOne(commits) { ); } +export function isStableTarget(targetRef) { + return /^(?:refs\/heads\/)?main$/.test(targetRef ?? ''); +} + +// При stable promotion диапазон main..candidate закономерно содержит коммиты, +// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты +// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые +// post-beta коммиты остаются в выборке и проверяются fail-closed как обычно. +export function commitsNeedingIssueStatus( + commits, { targetRef = '', isPublishedPrereleaseCommit = () => false } = {}, +) { + const underRuleOne = commitsUnderRuleOne(commits); + if (!isStableTarget(targetRef)) return underRuleOne; + return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha)); +} + // 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не // пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен. export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) { @@ -382,6 +398,7 @@ function main(argv) { const repo = value('repo', process.cwd()); const allowed = flag('no-merged') ? STRICT_STATUS : ALLOWED_STATUS; + const targetRef = value('target-ref', process.env.TARGET_REF ?? ''); let range = value('range'); if (!range && flag('github-range')) { @@ -428,7 +445,23 @@ function main(argv) { // проверки 3. Второй запрос по тому же issue — лишний сетевой вызов. let labelsOf = null; if (flag('issues')) { - const numbers = [...new Set(commitsUnderRuleOne(commits).flatMap((c) => c.issues).map((t) => t.slice(1)))]; + const prereleaseTags = isStableTarget(targetRef) + ? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) => + /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-[0-9A-Za-z.-]+$/.test(tag)) + : []; + const publishedCache = new Map(); + const isPublishedPrereleaseCommit = (sha) => { + if (!publishedCache.has(sha)) { + publishedCache.set(sha, prereleaseTags.some((tag) => + spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, `${tag}^{commit}`], + { encoding: 'utf8' }).status === 0)); + } + return publishedCache.get(sha); + }; + const statusCommits = commitsNeedingIssueStatus(commits, { + targetRef, isPublishedPrereleaseCommit, + }); + const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))]; const runner = ghRunner(process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh'); const cache = new Map(); const cached = (nn) => { diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 761b2f13..b4a74911 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -14,6 +14,7 @@ import { checkReviewDocLimit, checkSpecs, classify, + commitsNeedingIssueStatus, commitsUnderRuleOne, evaluateCommit, makeCommit, @@ -170,6 +171,36 @@ test('only class A/B commits are held to the issue status', () => { assert.deepEqual(commitsUnderRuleOne([reviewDoc]), []); }); +test('stable promotion skips status recheck only for commits already published in a prerelease', () => { + const published = makeCommit({ + sha: 'a'.repeat(40), subject: 'Fix shipped in beta', body: 'Issue: #123', files: ['src/a.ts'], + }); + const postBeta = makeCommit({ + sha: 'b'.repeat(40), subject: 'New promotion work', body: 'Issue: #130', files: ['scripts/a.mjs'], + }); + const publishedShas = new Set([published.sha]); + assert.deepEqual( + commitsNeedingIssueStatus([published, postBeta], { + targetRef: 'refs/heads/main', + isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha), + }).map((commit) => commit.sha), + [postBeta.sha], + ); + assert.deepEqual( + rules(checkIssueStatuses(['130'], () => ({ + ok: true, json: { state: 'CLOSED', labels: [] }, + }))), + [8], + ); + assert.deepEqual( + commitsNeedingIssueStatus([published, postBeta], { + targetRef: 'refs/heads/dev', + isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha), + }).map((commit) => commit.sha), + [published.sha, postBeta.sha], + ); +}); + 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' });