diff --git a/.cursor/rules/engineering-workflow.mdc b/.cursor/rules/engineering-workflow.mdc new file mode 100644 index 0000000..815ca6e --- /dev/null +++ b/.cursor/rules/engineering-workflow.mdc @@ -0,0 +1,28 @@ +--- +description: 标准化分支、测试、发布闸门(始终应用) +alwaysApply: true +--- + +# 标准化开发测试发布闸门 + +开始任何开发、修复、发布前,先读: + +- `ENGINEERING_WORKFLOW_RULES.md` +- `PRODUCTION_RELEASE_RULES.md` +- `AGENTS.md` + +必须遵守: + +- 新建分支必须基于最新 `origin/main`,推荐执行 `bash scripts/new-branch.sh feature/xxx` +- 发布前必须重新检查当前分支是否落后 `origin/main` +- 发布来源必须是干净、可追溯的 Git commit +- 禁止从脏工作区、detached HEAD、落后主线的分支发布 +- 禁止直接 `rsync` 到 `103/105` 或 SSH 到生产手改源码 + +发布前必须执行: + +```bash +bash scripts/check-release-ready.sh +``` + +如需临时绕过,只能在用户明确批准后使用项目文档中记录的显式环境变量。 diff --git a/AGENTS.md b/AGENTS.md index a3d43a4..c75c7a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,22 @@ -# Agent 协作说明(Codex / Cloud / Cursor / 其它 AI 工具通用) +# Agent 协作说明(Codex / Cloud / Cursor / Claude / 其它 AI 工具通用) -本文件供**所有**在仓库内工作的 AI 编码助手阅读,不限于 Cursor。 +本文件供所有在仓库内工作的 AI 编码助手阅读。仓库级开发、测试、发布规范以 [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md) 为准;生产发布规范以 [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md) 为准。 + +## 必读:分支与发布闸门 + +1. 新建分支前必须先同步远端主线,推荐执行 `bash scripts/new-branch.sh feature/xxx`。 +2. 发布前必须重新检查当前分支是否落后 `origin/main`。 +3. 发布来源必须是干净、可追溯的 Git commit。 +4. 禁止从脏工作区、detached HEAD、落后主线的分支发布。 +5. 发布前必须执行: + +```bash +bash scripts/check-release-ready.sh +``` ## 必读:已验证回归守卫(勿轻易覆盖) -下列行为经过长时间调试验证。**删改相关代码前必须先读文档并跑 verify**: +下列行为经过长时间调试验证。删改相关代码前必须先读文档并跑 verify: | 主题 | 文档 | |------|------| @@ -15,42 +27,35 @@ ### 改相关文件前必跑 ```bash -# 单测 + 源码静态守卫 npm run verify:mindspace-publish-guards - -# 发 Portal runtime 后(含 bundle 检查) npm run verify:mindspace-publish-guards:full ``` -发版脚本(`scripts/release-prod.sh`、`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行上述 verify。 +发版脚本(`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行相关 verify。 ### 受保护的关键路径 -- `mindspace-public-finish-sync.mjs` — `edit_file` 必须 materialize 到 `public/*.html` -- `chat-finish-sync.mjs` — Finish / UpdateConversation **merge**,禁止盲覆盖 -- `conversation-display.mjs` + `src/utils/message.ts` — 用户消息不得展示 routing/skill 内部前缀 -- `src/hooks/useTKMindChat.ts` — `syncSessionMessages` merge + 重试 -- `server.mjs` — session snapshot 需 `hint_mc` **且** `hint_ua` 才走缓存 +- `mindspace-public-finish-sync.mjs` - `edit_file` 必须 materialize 到 `public/*.html` +- `chat-finish-sync.mjs` - Finish / UpdateConversation 必须 merge,禁止盲覆盖 +- `conversation-display.mjs` + `src/utils/message.ts` - 用户消息不得展示 routing/skill 内部前缀 +- `src/hooks/useTKMindChat.ts` - `syncSessionMessages` merge + 重试 +- `server.mjs` - session snapshot 需 `hint_mc` 且 `hint_ua` 才走缓存 代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 -### 相关单测(已接入 `npm test`) +### 相关单测 - `mindspace-public-finish-sync.test.mjs` - `chat-finish-sync.test.mjs` - `conversation-display.test.mjs` ---- - ## Cursor 专用规则(其它工具请读上文文档) Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` 内容与 `docs/regression-guards/mindspace-publish-and-chat-finish.md` 一致,仅为 Cursor 在编辑相关文件时自动提示。 ---- - ## 仓库惯例 - 本地开发:`pnpm dev`(见 [README.md](README.md)) - 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md) -- 发版须 Git commit,禁止本机直 rsync 到 103/105(见 README) +- 发版须 Git commit,禁止本机直 `rsync` 到 `103/105` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ec8555 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Claude 协作说明 + +本仓库的开发、测试、发布规范与 `AGENTS.md` 同源。开始任何代码修改或发布操作前,先读: + +1. [ENGINEERING_WORKFLOW_RULES.md](ENGINEERING_WORKFLOW_RULES.md) +2. [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md) +3. [AGENTS.md](AGENTS.md) + +## 强制规则 + +- 新建分支必须基于最新 `origin/main`。 +- 发布前必须重新检查当前分支是否落后 `origin/main`。 +- 发布来源必须是干净、可追溯的 Git commit。 +- 禁止从脏工作区、detached HEAD、落后主线的分支发布。 +- 发布前必须执行: + +```bash +bash scripts/check-release-ready.sh +``` + +如果规则与口头指令冲突,先暂停并确认,不要绕过发布闸门。 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md index 02ac618..02ffd3a 100644 --- a/ENGINEERING_WORKFLOW_RULES.md +++ b/ENGINEERING_WORKFLOW_RULES.md @@ -7,10 +7,38 @@ ## 2. 开发约束 -1. 每次开发都必须形成本地 Git commit。 -2. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混成一团。 -3. 不允许长期堆积“只有自己知道用途”的未提交改动。 -4. `.env`、账号、密钥、运行态数据不进入 Git。 +1. 新建开发分支前必须同步远端主线。推荐统一使用: + +```bash +bash scripts/new-branch.sh feature/xxx +``` + +等价手工命令: + +```bash +git fetch origin --prune +git switch main +git pull --ff-only +git switch -c feature/xxx +``` + +也可以直接从远端主线建分支: + +```bash +git fetch origin --prune +git switch -c feature/xxx origin/main +``` + +2. 每次开发都必须形成本地 Git commit。 +3. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混成一团。 +4. 不允许长期堆积“只有自己知道用途”的未提交改动。 +5. `.env`、账号、密钥、运行态数据不进入 Git。 +6. 分支开发中如果主线继续变化,发布前必须重新对齐: + +```bash +git fetch origin --prune +git rebase origin/main +``` ## 3. 测试约束 @@ -26,6 +54,13 @@ 4. Portal 唯一合法生产入口是 `bash scripts/release-portal-runtime-prod.sh`。 5. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。 6. 发布前必须有备份,发布后必须有健康检查和业务验收。 +7. 发布前必须通过统一闸门: + +```bash +bash scripts/check-release-ready.sh +``` + +8. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。 ## 5. 文档约束 @@ -37,3 +72,4 @@ 1. 发布前要求工作区可读:`git status` 不能混入无关改动。 2. 为每次正式发布保留 manifest、备份包路径、验证结果。 3. 涉及数据库、计费、用户空间、鉴权的改动,要额外保留一份业务验收清单。 +4. 每个 AI 工具都必须先读本文件;自动化工具、Git hook、发布脚本以 `scripts/check-release-ready.sh` 的结果为准。 diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 4467292..e957812 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -9,7 +9,8 @@ 4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。 5. 发布包不得携带运行态资产;`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。 6. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。 -7. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。 -8. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 -9. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 -10. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 +7. 每次生产发布前必须先通过 `bash scripts/check-release-ready.sh`,确认分支不落后 `origin/main`、工作区干净、发布来源可追溯。 +8. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。 +9. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 +10. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 +11. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 diff --git a/scripts/check-branch-baseline.sh b/scripts/check-branch-baseline.sh new file mode 100755 index 0000000..a67328c --- /dev/null +++ b/scripts/check-branch-baseline.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE_REF="${BASE_REF:-}" +SKIP_FETCH=0 + +usage() { + cat <<'EOF' +Usage: + bash scripts/check-branch-baseline.sh [--skip-fetch] + +Checks that the current branch is not behind origin/main. + +Environment: + BASE_REF Base ref to compare against. Defaults to origin/main when available. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-fetch) SKIP_FETCH=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null + +if [[ "${SKIP_FETCH}" -ne 1 ]] && git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then + git -C "${ROOT}" fetch origin --prune +fi + +if [[ -z "${BASE_REF}" ]]; then + for candidate in origin/main origin/master main master; do + if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then + BASE_REF="${candidate}" + break + fi + done +fi + +branch="$(git -C "${ROOT}" branch --show-current)" +if [[ -z "${branch}" ]]; then + echo "Baseline check failed: detached HEAD is not allowed for development or release." >&2 + exit 1 +fi + +if ! git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null; then + echo "Baseline check failed: base ref not found: ${BASE_REF}" >&2 + exit 1 +fi + +read -r ahead behind < <(git -C "${ROOT}" rev-list --left-right --count "HEAD...${BASE_REF}") + +if [[ "${behind}" -ne 0 ]]; then + echo "Baseline check failed: ${branch} is behind ${BASE_REF} by ${behind} commit(s)." >&2 + echo "Run: git fetch origin --prune && git rebase ${BASE_REF}" >&2 + exit 1 +fi + +echo "Baseline check passed: ${branch} is current with ${BASE_REF} (ahead ${ahead})." diff --git a/scripts/check-release-ready.sh b/scripts/check-release-ready.sh new file mode 100755 index 0000000..00ccd8e --- /dev/null +++ b/scripts/check-release-ready.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE_REF="${BASE_REF:-}" +SKIP_FETCH=0 + +usage() { + cat <<'EOF' +Usage: + bash scripts/check-release-ready.sh [--skip-fetch] + +Blocks releases unless the repository is on a named branch, the branch is not +behind origin/main, and the worktree is clean. + +Environment: + BASE_REF Base ref to compare against. Defaults to origin/main when available. + ALLOW_MAIN_RELEASE=1 Allow publishing directly from main/master. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-fetch) SKIP_FETCH=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null + +branch="$(git -C "${ROOT}" branch --show-current)" +if [[ -z "${branch}" ]]; then + echo "Release check failed: detached HEAD is not a release source." >&2 + exit 1 +fi + +case "${branch}" in + main|master) + if [[ "${ALLOW_MAIN_RELEASE:-0}" != "1" ]]; then + echo "Release check failed: publish from a dedicated release/feature branch, not ${branch}." >&2 + echo "Set ALLOW_MAIN_RELEASE=1 only for an explicitly approved mainline release." >&2 + exit 1 + fi + ;; +esac + +if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then + echo "Release check failed: worktree has uncommitted or untracked changes." >&2 + git -C "${ROOT}" status --short --untracked-files=all >&2 + exit 1 +fi + +if [[ "${SKIP_FETCH}" -eq 1 ]]; then + bash "${ROOT}/scripts/check-branch-baseline.sh" --skip-fetch +else + bash "${ROOT}/scripts/check-branch-baseline.sh" +fi + +effective_base="${BASE_REF}" +if [[ -z "${effective_base}" ]]; then + for candidate in origin/main origin/master main master; do + if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then + effective_base="${candidate}" + break + fi + done +fi + +head_sha="$(git -C "${ROOT}" rev-parse --short HEAD)" +echo "Release check passed: ${branch} @ ${head_sha} is clean and current with ${effective_base}." diff --git a/scripts/new-branch.sh b/scripts/new-branch.sh new file mode 100755 index 0000000..4a40f75 --- /dev/null +++ b/scripts/new-branch.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE_REF="${BASE_REF:-}" + +usage() { + cat <<'EOF' +Usage: + bash scripts/new-branch.sh + +Creates a new development branch from the latest origin/main. + +Environment: + BASE_REF Base ref to create from. Defaults to origin/main when available. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -ne 1 ]]; then + usage + [[ $# -eq 1 ]] && exit 0 || exit 1 +fi + +branch="$1" + +if [[ -n "$(git -C "${ROOT}" status --porcelain=v1 --untracked-files=all)" ]]; then + echo "New branch check failed: current worktree is not clean." >&2 + git -C "${ROOT}" status --short --untracked-files=all >&2 + exit 1 +fi + +if git -C "${ROOT}" remote get-url origin >/dev/null 2>&1; then + git -C "${ROOT}" fetch origin --prune +fi + +if [[ -z "${BASE_REF}" ]]; then + for candidate in origin/main origin/master main master; do + if git -C "${ROOT}" rev-parse --verify --quiet "${candidate}" >/dev/null; then + BASE_REF="${candidate}" + break + fi + done +fi + +git -C "${ROOT}" rev-parse --verify --quiet "${BASE_REF}" >/dev/null || { + echo "New branch check failed: base ref not found: ${BASE_REF}" >&2 + exit 1 +} + +git -C "${ROOT}" switch -c "${branch}" "${BASE_REF}" +echo "Created ${branch} from ${BASE_REF}." diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 828f1a4..c083fd9 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -84,6 +84,13 @@ need_cmd scp need_cmd tar need_cmd shasum +if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then + bash "${ROOT}/scripts/check-release-ready.sh" +else + echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2 + exit 1 +fi + check_release_scope() { local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" if [[ "${bypass}" == "1" ]]; then