Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,26 @@
(committer 是 `GitHub <noreply@github.com>`、标题以 ` (#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

Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ python3 tests/test_e2e.py
python3 tests/test_e2e.py --agent-args '--codec hex'
```

> 自测默认自己挑空闲端口(`-Port` / `--port` 可指定);端口已被占用时**直接报错**(退出码 2),
> 不会卡住。

有真实设备/串口时,再跑一次端到端确认:

```bash
Expand Down Expand Up @@ -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 标题沿用内核风格 `<subsystem>: <祈使句摘要>`;squash 提交的作者是 GitHub 账号(`<ID>+<login>@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 标题同样按内核规范 `<subsystem>: <祈使句摘要>`(检索入口,且万一有人改回 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)。

Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 子命令

| 子命令 | 作用 | 退出码 |
Expand Down Expand Up @@ -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. 假设与限制
Expand All @@ -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`);有第二位维护者时开启 |
Expand All @@ -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`。

相关约定:

Expand Down
14 changes: 8 additions & 6 deletions REVIEW_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不允许自批,因此不设强制批准数);有协作者后提升为「必须他人批准」 |

Expand All @@ -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
```

## 三、补丁必须保持同步(本仓库最容易踩的雷)
Expand All @@ -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,
Expand All @@ -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 |

## 六、提交前的本地自查
Expand Down
18 changes: 17 additions & 1 deletion cli/devctl.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) } }

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions governance/repository-settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 26 additions & 1 deletion tests/selftest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
#>
[CmdletBinding()]
param(
[int]$Port = 17801,
[int]$Port = 0,
[string]$Agent = '',
[string]$Devctl = '',
[string]$Target = '',
Expand All @@ -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 <free 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.
Expand Down
30 changes: 28 additions & 2 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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 <free 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)
Expand Down
2 changes: 1 addition & 1 deletion tools/create-github-repo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading