diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0af9b69..edb88d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,8 @@ jobs: - name: selftest (auto codec) shell: powershell - run: powershell -NoProfile -ExecutionPolicy Bypass -File tests/selftest.ps1 -Port 17801 + # no -Port: the self test picks a free one, same as a developer would + run: powershell -NoProfile -ExecutionPolicy Bypass -File tests/selftest.ps1 - name: selftest (hex codec) shell: powershell @@ -152,7 +153,8 @@ jobs: python-version: '3.11' - name: e2e (auto codec) - run: python tests/test_e2e.py --port 17810 + # no --port: the test picks a free one + run: python tests/test_e2e.py - name: e2e (hex codec) run: python tests/test_e2e.py --port 17811 --agent-args "--codec hex" diff --git a/CHANGELOG.md b/CHANGELOG.md index b1844be..1d782d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,12 +36,26 @@ (committer 是 `GitHub `、标题以 ` (#N)` 结尾):只跳过标题 规范检查——标题改由新增的 PR 门禁在**合并前**拦截(见上「新增」)—— DCO 与正文格式照旧校验;`tests/test_check_commits.py` 增到 18 例。 +- 合并方式改为**只允许 Rebase and merge**(`governance/repository-settings.json`): + Squash 会把整个 PR 压成一个提交,提交标题变成 PR 标题、每个提交的 `Signed-off-by:` + 也不再是原文——#7 就是这么把 `main` 判红的,而 `main` 的历史改不回来。rebase 让 PR 里 + 的原始提交原样落到 `main`,代价是提交信息必须一开始就写对;`pr-gate` 仍在合并前校验 + PR 标题与模板清单。 +- 本地自测不再卡死:`tests/selftest.ps1` 曾默认用 17801,而这个端口在本机常已被 VS Code 的 + 端口转发占着(Windows 上更具体的 `127.0.0.1` 监听会盖过 agent 的 `0.0.0.0`),devctl 于是 + 连到了陌生人身上并永远等回复。现在两套自测**默认向系统要一个空闲端口** + (`-Port` / `--port` 仍可指定),指定的端口已被占用时立刻报错退出(退出码 2)。 +- `cli/devctl.ps1` 的连接没有超时(`devctl.py` 一直是 15 秒),端口不通时脚本会一直挂着。 + 现在两版一致:连不上或超时在 15 秒内报错退出(退出码 1),错误信息也去掉了 .NET 包装。 ### 验证 - 本地 mock 自测(Windows):`serial_agent.ps1` + `devctl.ps1` **12/12**(`auto` 与 `hex` 两种编码),`serial_agent.py` + `devctl.py` **11/11**;交叉组合 (`devctl.py → serial_agent.ps1`、`devctl.ps1 → serial_agent.py`)的 `shutdown` 亦通过, 之后 agent 进程退出、TCP 端口重新可绑定。 +- 失败路径复查(本机 Windows):`selftest.ps1` 默认端口 12/12、指定端口 + `-Codec hex` 12/12; + `test_e2e.py` 默认端口 11/11、`--codec hex` 11/11;对已占用端口两者都**秒退并返回 2**; + `devctl.ps1` 对不通的端口立即报错退出 1,对黑洞地址 15 秒超时退出 1,mock agent 正常。 ## [1.0.0] - 2026-10-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35e0a1e..3de0b1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,9 @@ python3 tests/test_e2e.py python3 tests/test_e2e.py --agent-args '--codec hex' ``` +> 自测默认自己挑空闲端口(`-Port` / `--port` 可指定);端口已被占用时**直接报错**(退出码 2), +> 不会卡住。 + 有真实设备/串口时,再跑一次端到端确认: ```bash @@ -136,7 +139,7 @@ git config core.hooksPath tools/git-hooks 把它写成 `main` 上的提交标题,事后才发现就改不回来了)。 2. CI 全绿(含 `Commit style` 与 `PR gate`);本地自测命令与输出贴在 PR 里。 3. required status checks 未通过不可合入;单人维护模式下不设强制批准数(GitHub 不允许自批),由维护者审阅后合并——加了协作者后按 [REVIEW_POLICY.md](REVIEW_POLICY.md) 提升为「至少 1 位他人批准」。 -4. 由 maintainer 合入 `main`:**Squash merge**(线性历史,分支自动删除)。PR 标题沿用内核风格 `: <祈使句摘要>`;squash 提交的作者是 GitHub 账号(`+@users.noreply.github.com`),正文里 `git commit -s` 写的 `Signed-off-by:` 依旧算数——检查器按账号判定,两种 noreply 写法都接受,网页端点合并即可。 +4. 由 maintainer 合入 `main`:**Rebase and merge**(线性历史,分支自动删除)。仓库只允许这一种合并方式——**不要 Squash**:它会把整个 PR 压成一个提交,提交标题变成 PR 标题、正文被拼成一坨,每个提交的 `Signed-off-by:` 也不再是原文。rebase 保留 PR 里每个提交的作者与信息,所以提交信息必须一开始就写对;PR 标题同样按内核规范 `: <祈使句摘要>`(检索入口,且万一有人改回 squash 就会写进历史)。 5. 发布:打 tag `vX.Y.Z`,同步更新 `CHANGELOG.md`。 6. 补丁必须保持同步:`agent/serial_agent.ps1` ↔ `agent/serial_agent.py`、`cli/devctl.ps1` ↔ `cli/devctl.py`,以及 `PROTOCOL.md` / `README.md` / `CHANGELOG.md` / `skill/` 随行为变化一起改,清单见 [REVIEW_POLICY.md](REVIEW_POLICY.md)。 diff --git a/README.md b/README.md index 39a3e19..adaeceb 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,9 @@ python3 cli/devctl.py --tcp 127.0.0.1:7801 exec "uname -a" | `--token TOKEN` | `-Token TOKEN` | 代理若用 `--auth` 启动,这里给出相同口令 | | `-q` | `-Quiet` | 只输出结果,不打印诊断信息 | +> **连接超时 15 秒**(两版一致):连不上会在 15 秒内报错退出(退出码 1),不会像以前那样 +> 一直挂着——端口写错、client 关机、或端口被别的进程占用时,都会立刻看到原因。 + ### 7.2 子命令 | 子命令 | 作用 | 退出码 | @@ -504,6 +507,10 @@ python3 tests/test_e2e.py --target 127.0.0.1:7801 预期结果均为 `N passed, 0 failed`(跨版本组合:ps↔py 均已验证)。 +> 自测默认**自己挑一个空闲端口**(`-Port` / `--port` 可以指定):指定的端口已被占用时会 +> 立刻报错退出(退出码 2)并告诉你端口被占了,而不会连到占用者身上、在 devctl 里一直等回复—— +> 本机开着 VS Code 时尤其要注意后者(它会转发终端里出现过的端口)。 + --- ## 14. 假设与限制 @@ -525,7 +532,7 @@ python3 tests/test_e2e.py --target 127.0.0.1:7801 | 规则 | 值 | | --- | --- | | 直推 `main` | ❌ 禁止(`enforce_admins: true`,管理员也一样) | -| 合并方式 | ✅ 仅 Squash merge,线性历史 | +| 合并方式 | ✅ 仅 **Rebase and merge**(PR 里每个提交原样落到 `main`,含 `Signed-off-by`),线性历史 | | 必需审查 | ✅ 必须走 PR + CI 全绿;单人维护模式下由维护者审阅后合并(`required_approving_review_count: 0`,原因见 [REVIEW_POLICY.md](REVIEW_POLICY.md)) | | 过期审查 | 有新提交后自动作废(`dismiss_stale_reviews`) | | 最近推送审批 | ⚙️ 单人模式下关闭(`require_last_push_approval: false`);有第二位维护者时开启 | @@ -544,9 +551,9 @@ CI 必需检查(`governance/branch-protection.json` 中的 contexts): 6. `PR gate (title + merge checklist)` 前五个在 `.github/workflows/ci.yml`,第六个在 `.github/workflows/pr-gate.yml`: -`pr-gate` 校验 **PR 标题符合内核规范**(网页端 squash 合并会把标题写成提交标题)与 -**模板里「合并前清单」是否逐条勾选**,编辑 PR 描述后会重跑。改门禁或改清单都要走 PR, -清单条目在 `.github/pull_request_template.md`。 +`pr-gate` 校验 **PR 标题符合内核规范**(标题是检索与变更日志的入口,而且一旦有人改回 +squash 合并,它就会变成 `main` 上的提交标题)与 **模板里「合并前清单」是否逐条勾选**, +编辑 PR 描述后会重跑。改门禁或改清单都要走 PR,清单条目在 `.github/pull_request_template.md`。 相关约定: diff --git a/REVIEW_POLICY.md b/REVIEW_POLICY.md index d9a1860..974bc91 100644 --- a/REVIEW_POLICY.md +++ b/REVIEW_POLICY.md @@ -10,7 +10,7 @@ | 闸门 | 位置 | 拦什么 | 能不能绕过 | | --- | --- | --- | --- | -| ① 分支保护 | `governance/branch-protection.json`(由 `tools/create-github-repo.ps1` 施加) | 直推 `main`、强推、删分支、检查没绿就合并 | 不能:`enforce_admins: true` + 空 bypass,管理员也一样 | +| ① 分支保护 | `governance/branch-protection.json` + `governance/repository-settings.json`(由 `tools/create-github-repo.ps1` 施加) | 直推 `main`、强推、删分支、检查没绿就合并、**用 Squash 把 PR 压成一个提交**(只允许 `Rebase and merge`) | 不能:`enforce_admins: true` + 空 bypass,管理员也一样 | | ② 自动校验 | `.github/workflows/ci.yml` + `.github/workflows/pr-gate.yml` | BOM/CRLF、语法、两套实现自测、交叉验证、commit 规范 + DCO、**PR 标题与「合并前清单」** | 不能:改 CI 自己也要走同一个 PR 流程 | | ③ 人工审查 | PR 本身 | 维护者看过 diff、复现步骤、自测输出后才合并 | 单人维护时维护者即作者(GitHub 不允许自批,因此不设强制批准数);有协作者后提升为「必须他人批准」 | @@ -23,7 +23,7 @@ python3 tools/check-commits.py --range main..HEAD git commit -s # DCO 签名,见 CONTRIBUTING.md §3 git rebase main # 保持线性历史 git push -u origin agent/hex-retry -# 在 GitHub 开 PR(模板自动带上)→ 等 CI 五个 job 全绿 → 维护者审阅 → 合并 +# 在 GitHub 开 PR(模板自动带上)→ 等 CI 六个 job 全绿 → 维护者审阅 → Rebase and merge ``` ## 三、补丁必须保持同步(本仓库最容易踩的雷) @@ -43,10 +43,11 @@ CI 的 `Cross implementation check` 只能验证「ps 的 CLI 连 py 的 agent ## 四、几个实测过的坑 -1. **Squash 合并会重写作者**:squash 提交的作者是 GitHub 账号(邮箱取账号设置), - 而 `Commit style` 检查要求 `Signed-off-by:` 与作者一致。稳妥做法是让本地 `user.email` - 与 GitHub 账号邮箱一致,并在 PR 正文里也带上 `Signed-off-by: <同一邮箱>`; - 或者改用 rebase 合并(保留原作者)。 +1. **只能用 Rebase and merge,不许 Squash**(`repository-settings.json` 只开了 rebase): + Squash 会把整个 PR 压成一个提交,标题变成 PR 标题、正文被拼成一坨、每个提交的 + `Signed-off-by:` 也不再是原文——#7 就是这么把 `main` 判红的(标题 `Agent/shutdown op (#7)` + 不合内核规范,而 commit 门禁只能在**合并之后**才发现)。rebase 保留 PR 中每个提交的 + 作者与信息,代价是提交信息必须一开始就写对。 2. **`strict` 检查要求 PR 追上最新 `main`**:main 前进后旧 PR 需要 rebase / update 才能合并 (dependabot 的 PR 会自动更新)。 3. **单人维护不要设 `required_approving_review_count: 1`**:GitHub 不允许自己批准自己的 PR, @@ -69,6 +70,7 @@ CI 的 `Cross implementation check` 只能验证「ps 的 CLI 连 py 的 agent | --- | --- | | 收紧到「必须他人批准」 | 先加协作者 / 第二账号,再把 `required_approving_review_count` 设为 1,`require_code_owner_reviews`、`require_last_push_approval` 设为 true,重跑 `tools/create-github-repo.ps1` | | 只要最基础保护(禁强推 / 禁删除) | 把 `required_pull_request_reviews` 与 `required_status_checks` 置空后重新施加 | +| 改合并方式 | 改 `governance/repository-settings.json` 的 `allow_squash_merge` / `allow_rebase_merge` / `allow_merge_commit`,再跑 `tools/create-github-repo.ps1` | | 临时救急 | 改 GitHub Settings → Rules 后重跑脚本恢复;**不要**长期留 bypass | ## 六、提交前的本地自查 diff --git a/cli/devctl.ps1 b/cli/devctl.ps1 index 0b8a9e0..39632a0 100644 --- a/cli/devctl.ps1 +++ b/cli/devctl.ps1 @@ -62,6 +62,9 @@ $script:In = $null $script:Out = $null $script:Proc = $null $script:LastRc = 0 +# devctl.py gives up on connecting after 15 s; the PowerShell edition used to +# block forever, which made an unreachable endpoint look like a hung script. +$script:ConnectTimeoutMs = 15000 function Write-Info { param([string]$m) if (-not $Quiet) { [Console]::Error.WriteLine($m) } } @@ -131,7 +134,20 @@ function Connect-Agent { if ($Tcp -match '^(.*):(\d+)$') { $h = $Matches[1]; $port = [int]$Matches[2] } $client = New-Object System.Net.Sockets.TcpClient $client.NoDelay = $true - $client.Connect($h, $port) + try { + $async = $client.BeginConnect($h, $port, $null, $null) + if (-not $async.AsyncWaitHandle.WaitOne($script:ConnectTimeoutMs)) { + throw ('connect timed out after ' + $script:ConnectTimeoutMs + ' ms') + } + $client.EndConnect($async) + } catch { + try { $client.Close() } catch { } + # EndConnect wraps the socket error, so unwrap it for a readable line. + $why = $_.Exception.Message + if ($_.Exception.InnerException) { $why = $_.Exception.InnerException.Message } + Write-Info ("ERR: cannot connect to tcp:{0}:{1}: {2}" -f $h, $port, $why) + exit 1 + } $script:In = $client.GetStream() $script:Out = $client.GetStream() Write-Info ("connected to tcp:{0}:{1}" -f $h, $port) diff --git a/governance/repository-settings.json b/governance/repository-settings.json index edcb253..899d2a0 100644 --- a/governance/repository-settings.json +++ b/governance/repository-settings.json @@ -7,9 +7,9 @@ "has_projects": false, "has_wiki": false, "has_discussions": true, - "allow_squash_merge": true, + "allow_squash_merge": false, "allow_merge_commit": false, - "allow_rebase_merge": false, + "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": true, "squash_merge_commit_message": "COMMIT_MESSAGES", diff --git a/tests/selftest.ps1 b/tests/selftest.ps1 index 38da564..58f112a 100644 --- a/tests/selftest.ps1 +++ b/tests/selftest.ps1 @@ -14,7 +14,7 @@ #> [CmdletBinding()] param( - [int]$Port = 17801, + [int]$Port = 0, [string]$Agent = '', [string]$Devctl = '', [string]$Target = '', @@ -35,6 +35,31 @@ $script:Fail = 0 $tmp = Join-Path (Split-Path -Parent $PSScriptRoot) '.tmp-selftest' New-Item -ItemType Directory -Force -Path $tmp | Out-Null $skipStart = [bool]$Target + +# The test agent binds 0.0.0.0, and on Windows a more specific 127.0.0.1 +# listener wins over it -- VS Code forwards the ports it sees in the terminal, +# which is how the old fixed default (17801) ended up talking to a stranger and +# hanging inside devctl instead of failing. Ask the OS for a free port instead, +# and refuse to run against an explicit port that somebody already answers. +function Test-PortBusy { + param([int]$P) + try { $c = New-Object System.Net.Sockets.TcpClient; $c.Connect('127.0.0.1', $P); $c.Close(); return $true } + catch { return $false } +} + +if (-not $skipStart) { + if ($Port -le 0) { + $probe = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0) + $probe.Start() + $Port = ([System.Net.IPEndPoint]$probe.LocalEndpoint).Port + $probe.Stop() + } elseif (Test-PortBusy -P $Port) { + Write-Host ("selftest: 127.0.0.1:{0} already answers - refusing to guess who it is." -f $Port) + Write-Host 'selftest: pass -Port , or stop whatever listens there.' + exit 2 + } +} + $target = if ($Target) { $Target } else { "127.0.0.1:$Port" } # Run devctl.ps1 the way a user would (out of process) and return its stdout. diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 0508f82..ed77c8d 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -44,6 +44,16 @@ def devctl(args): return pr.returncode, pr.stdout, pr.stderr +def port_busy(host, port): + """True when something already answers on host:port.""" + try: + s = socket.create_connection((host, port), timeout=1) + s.close() + return True + except OSError: + return False + + def wait_port(host, port, seconds=20): end = time.time() + seconds while time.time() < end: @@ -60,7 +70,8 @@ def main(): global TARGET ap = argparse.ArgumentParser() ap.add_argument('--target', default='') - ap.add_argument('--port', type=int, default=17870) + ap.add_argument('--port', type=int, default=0, + help='port for the agent this test starts (default: pick a free one)') ap.add_argument('--agent', default=os.path.join(ROOT, 'agent', 'serial_agent.py')) ap.add_argument('--agent-args', default='') opts = ap.parse_args() @@ -70,8 +81,23 @@ def main(): TARGET = opts.target print('testing against external target %s' % TARGET) else: + # The agent binds 0.0.0.0 and on Windows a more specific 127.0.0.1 + # listener wins over it (VS Code forwards the ports it sees), so a fixed + # default port can send the whole test to a stranger. Ask the OS for a + # free one instead of guessing. + if not opts.port: + probe = socket.socket() + try: + probe.bind(('127.0.0.1', 0)) + opts.port = probe.getsockname()[1] + finally: + probe.close() + elif port_busy('127.0.0.1', opts.port): + print('port %d already answers on 127.0.0.1; pass --port ' + % opts.port) + return 2 TARGET = '127.0.0.1:%d' % opts.port - print('starting agent: %s (--mock)' % opts.agent) + print('starting agent: %s (--mock) on port %d' % (opts.agent, opts.port)) extra = opts.agent_args.split() if opts.agent_args else [] proc = subprocess.Popen([PYTHON, opts.agent, '--mock', '--tcp-port', str(opts.port)] + extra, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) diff --git a/tools/create-github-repo.ps1 b/tools/create-github-repo.ps1 index 3c1d280..058b333 100644 --- a/tools/create-github-repo.ps1 +++ b/tools/create-github-repo.ps1 @@ -12,7 +12,7 @@ - PR required, >=1 approving review, code-owner review required - stale reviews dismissed, last-push approval required - required status checks (CI jobs) must be green and up to date - - no force push, no deletion, squash-merge only, linear history + - no force push, no deletion, rebase-merge only, linear history 4. create the first release tag (optional, -Release) Requires a token with the `repo` scope. It is never written to disk: