diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c9a048e6a..4a6ba7d3c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,7 @@ ## Required Verification +- [ ] `pnpm check:branch` - [ ] `pnpm check:quality` - [ ] `AUTH_TRUST_HOST=true PORT=3001 pnpm dev` - [ ] `SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm smoke:routes` diff --git a/README.md b/README.md index 5e8ac55db..df4096091 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ pnpm lint pnpm check:logs pnpm check:brand pnpm check:ci +pnpm check:branch pnpm check:env pnpm check:secrets pnpm check:metadata @@ -91,6 +92,8 @@ pnpm db:generate 推荐提交前至少运行: ```bash +git fetch github main --prune +pnpm check:branch pnpm check:quality pnpm audit:security AUTH_TRUST_HOST=true PORT=3001 pnpm dev diff --git a/docs/operations.md b/docs/operations.md index c79fb30c4..e45dfb182 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -5,6 +5,8 @@ ## 合并前检查 ```bash +git fetch github main --prune +pnpm check:branch pnpm lint pnpm check:logs pnpm check:brand @@ -40,6 +42,21 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release `pnpm clean:artifacts` 会清理当前工作区常见本地垃圾文件,例如 `.DS_Store`、swap 文件和 `tsconfig.tsbuildinfo`。它会跳过 `.git`、`node_modules`、`.next`、`.pnpm-store`、`.venv` 等重目录,适合在提交或打包前手动执行。 +## 分支与 PR 就绪检查 + +```bash +git fetch github main --prune +pnpm check:branch +``` + +`pnpm check:branch` 默认以 `github/main` 为基线,检查当前工作区是否干净、基线 ref 是否存在、当前分支相对 main 的 ahead/behind 状态、受影响文件数量,以及 dry-run merge tree 是否出现冲突标记。它会把 behind main 作为 warning,方便还没决定 rebase/merge 前先看风险;如果发布或合并策略要求必须最新,可使用: + +```bash +REQUIRE_UP_TO_DATE=true pnpm check:branch +``` + +如果远端名不同,可以用 `PR_BASE_REF=origin/main pnpm check:branch` 或 `pnpm check:branch origin/main` 覆盖基线。提交前临时验证未提交改动时,可使用 `ALLOW_DIRTY=true pnpm check:branch` 或 `pnpm check:branch --allow-dirty`。 + ## CI `.github/workflows/lint.yml` 是基础质量门禁,运行 `pnpm check:quality`,负责 lint、日志治理、公开交付面品牌治理、CI hygiene、环境变量示例卫生、secret hygiene、package metadata、测试门禁卫生、轻量 helper 回归测试、类型检查、diff hygiene 和 tracked 污染物检查。 diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 185d679fb..a48b37f87 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -6,6 +6,8 @@ ```bash pnpm install --frozen-lockfile +git fetch github main --prune +pnpm check:branch AUTH_TRUST_HOST=true PORT=3001 pnpm dev SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release ``` @@ -13,6 +15,7 @@ SMOKE_BASE_URL=http://127.0.0.1:3001 pnpm verify:release 预期: - `pnpm check:quality` 通过 lint、日志脱敏门禁、公开交付面品牌治理、CI hygiene、环境变量示例卫生、secret hygiene、package metadata、测试门禁卫生、轻量 helper 回归测试、类型检查、diff hygiene 和 tracked 污染物检查。 +- `pnpm check:branch` 确认当前分支相对 `github/main` 没有 dry-run merge 冲突;behind main 会提示为 warning,发布前可用 `REQUIRE_UP_TO_DATE=true` 提升为失败。 - `pnpm audit:security` 输出已审计,Dependency Audit workflow 也应通过;若仍有 critical/high,必须在 [dependency-risk-register.md](dependency-risk-register.md) 中记录 remediation 或风险接受状态。 - `pnpm smoke:routes` 验证 `/` 返回 200。 - `/classic` 和 `/chat/test-id` 重定向到 `/`。 diff --git a/package.json b/package.json index b1667722d..05563e839 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check:artifacts": "node scripts/check-tracked-artifacts.mjs", "check:brand": "node scripts/check-brand-hygiene.mjs", "check:ci": "node scripts/check-ci-hygiene.mjs", + "check:branch": "node scripts/check-branch-readiness.mjs", "check:env": "node scripts/check-env-example.mjs", "check:secrets": "node scripts/check-secrets.mjs", "check:metadata": "node scripts/check-package-metadata.mjs", diff --git a/scripts/check-branch-readiness.mjs b/scripts/check-branch-readiness.mjs new file mode 100644 index 000000000..8a21984fd --- /dev/null +++ b/scripts/check-branch-readiness.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const args = process.argv.slice(2); +const baseRef = process.env.PR_BASE_REF || args.find((arg) => !arg.startsWith("--")) || "github/main"; +const requireUpToDate = + process.env.REQUIRE_UP_TO_DATE === "true" || args.includes("--require-up-to-date"); +const allowDirty = process.env.ALLOW_DIRTY === "true" || args.includes("--allow-dirty"); + +const failures = []; +const warnings = []; + +const currentBranch = git(["branch", "--show-current"]).trim() || "HEAD"; +const status = git(["status", "--porcelain"]); +if (status.trim() && !allowDirty) { + failures.push("working tree has uncommitted changes; commit, stash, or set ALLOW_DIRTY=true"); +} + +try { + git(["rev-parse", "--verify", baseRef]); +} catch { + failures.push(`base ref not found: ${baseRef}. Run \`git fetch\` or set PR_BASE_REF.`); +} + +if (failures.length === 0) { + const mergeBase = git(["merge-base", "HEAD", baseRef]).trim(); + const [behindText, aheadText] = git(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`]) + .trim() + .split(/\s+/); + const behind = Number.parseInt(behindText || "0", 10); + const ahead = Number.parseInt(aheadText || "0", 10); + const changedFiles = git(["diff", "--name-only", `${baseRef}...HEAD`]) + .split(/\r?\n/) + .filter(Boolean).length; + + const mergeTree = runMergeTree(mergeBase, baseRef); + const conflictLines = mergeTree + .split(/\r?\n/) + .filter((line) => /^(<<<<<<<|=======|>>>>>>>|CONFLICT\b)/.test(line)); + + if (conflictLines.length > 0) { + const conflictReportPath = join(tmpdir(), "yedir-branch-readiness-conflicts.txt"); + writeFileSync(conflictReportPath, conflictLines.join("\n")); + failures.push( + `merge conflict markers detected against ${baseRef}; details: ${conflictReportPath}` + ); + } + + if (behind > 0) { + const message = `${currentBranch} is behind ${baseRef} by ${behind} commit(s)`; + if (requireUpToDate) { + failures.push(message); + } else { + warnings.push(message); + } + } + + console.log( + JSON.stringify( + { + baseRef, + branch: currentBranch, + mergeBase, + ahead, + behind, + changedFiles, + mergeConflictMarkers: conflictLines.length, + requireUpToDate, + }, + null, + 2 + ) + ); +} + +if (warnings.length > 0) { + console.warn(`Branch readiness warnings:\n- ${warnings.join("\n- ")}`); +} + +if (failures.length > 0) { + console.error(`Branch readiness failed:\n- ${failures.join("\n- ")}`); + process.exit(1); +} + +console.log("Branch readiness check passed."); + +function git(args) { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function runMergeTree(mergeBase, baseRef) { + try { + return git(["merge-tree", mergeBase, "HEAD", baseRef]); + } catch (error) { + return [error.stdout, error.stderr].filter(Boolean).join("\n"); + } +}