From c93490cfdb6c7eb72a63622ab84d0d15604b6f2f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:44:08 +0800 Subject: [PATCH 1/3] ci(release): write the release page from a template, with an English summary #191 made the publish job write the release once, with GitHub's generated list of pull requests as its only text and the tag as its title. A release is now titled "ThinkWatch Core ", and its text has, in order: - an English summary from release-notes/.md, when that file exists; - a table of the files for each platform; - the commands that install this version on a Linux server and switch an existing installation to it (install.sh --version, twcore upgrade --version --restart); - how to verify a download against its .sha256; - GitHub's generated list of pull requests. scripts/release_notes.py builds the text; the publish job fetches the generated list itself (releases/generate-notes) and hands the finished text to action-gh-release, which no longer generates anything. The "only once" rule from #191 stays: a release that already exists keeps its text. A rehearsal (workflow_dispatch) now writes the text into the run summary, using the version in Cargo.toml. scripts/release_notes_test.py checks that the table links exactly the files in release.yml's FILES list, that the install and upgrade options exist, and that every file in release-notes/ renders; the Linux CI job runs it before compiling. release-notes/0.47.0.md summarizes 0.47.0 from #183-#190; the live v0.47.0 release page now carries it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 ++ .github/workflows/release.yml | 59 ++++++++--- CONTRIBUTING.md | 27 ++++- release-notes/0.47.0.md | 17 +++ scripts/release_notes.py | 158 ++++++++++++++++++++++++++++ scripts/release_notes_test.py | 190 ++++++++++++++++++++++++++++++++++ 6 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 release-notes/0.47.0.md create mode 100755 scripts/release_notes.py create mode 100755 scripts/release_notes_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 966ed587..e0cab1e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,13 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + + # 发布页的正文只在推 tag 时才写(release.yml 的 publish)。脚本和 + # `release-notes/` 下的说明在改它们的那个 PR 上就核对:链接对不对得上 + # 发出去的文件、说明里有没有混进中文。只要 Python,放在编译之前 + - name: Release page text + run: python3 scripts/release_notes_test.py + - uses: dtolnay/rust-toolchain@stable with: components: clippy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c431b278..e25ec673 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -299,6 +299,7 @@ jobs: # `generate_release_notes`。Release 已经存在时,这个 action 照样再要一份 # 生成的说明,接在原有正文后面 —— 于是 v0.44.0 到 v0.47.0 的说明各有四份 # (四个 job),v0.30.0 到 v0.43.0 各有两份(macOS、Windows 两个 job)。 + # 现在正文由下面一步自己拼好交给它,不再让它生成。 publish: name: Publish needs: [twcore, twcore-windows, twcore-linux] @@ -324,6 +325,10 @@ jobs: dist/twcore-aarch64-unknown-linux-gnu.tar.gz dist/twcore-aarch64-unknown-linux-gnu.tar.gz.sha256 steps: + # 发布页的正文要用这个仓库里的脚本和 `release-notes/`。先取代码再下载产物: + # checkout 会清空工作目录 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: path: dist @@ -338,33 +343,55 @@ jobs: cd dist sha256sum -c ./*.sha256 - # 同一个原因,这一个 job 也会接上第二份:Release 已经在了 —— 重跑这个 - # job(比如挂文件挂到一半断了),或者 Release 先手工建好了(v0.11.0 就是 - # 这样多出一份的)。所以只在它还不存在时生成 + # 发布页:标题「ThinkWatch Core <版本>」,正文(英文)由 + # `scripts/release_notes.py` 写 —— `release-notes/<版本>.md` 里的说明(可以 + # 没有)、下载表、服务器上安装和升级的命令、核对方法,最后是 GitHub 按上一版 + # 以来合进 main 的 PR 生成的清单。排练也写,写进这次运行的摘要里,不发布。 + # + # **正文只写一次。**Release 已经在了 —— 重跑这个 job(比如挂文件挂到一半 + # 断了),或者 Release 先手工建好了(v0.11.0 就是这样多出一份说明的)—— + # 就不动它的正文。以前这里每跑一次就接上一份生成的清单。 # # **「不存在」只认 gh 查不到时的那句 `release not found`。**网络断了、令牌 - # 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话就又 - # 接上一份。所以别的失败让这一步挂掉,原话留在日志里 - - name: Notes only for a release that does not exist yet + # 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话它的 + # 正文就被盖掉。所以别的失败让这一步挂掉,原话留在日志里 + - name: The page text, only for a release that does not exist yet id: notes - if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} run: | - if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then - echo "generate=false" - elif grep -qx 'release not found' <<<"$out"; then - echo "generate=true" + set -euo pipefail + if [ "$GITHUB_REF_TYPE" = tag ]; then + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then + echo "release $GITHUB_REF_NAME already exists; its text is left as it is" + echo "path=" >> "$GITHUB_OUTPUT" + exit 0 + elif ! grep -qx 'release not found' <<<"$out"; then + echo "$out" >&2 + exit 1 + fi else - echo "$out" >&2 - exit 1 - fi >> "$GITHUB_OUTPUT" + # 排练没有 tag:用代码里的版本号 + VERSION=$(awk -F'"' '/^version *= *"/ { print $2; exit }' Cargo.toml) + fi + # 清单向 GitHub 要:和它在发布页上「自动生成」的是同一份。排练时这个 tag + # 可能还不存在,那就算到这次的 commit 为止(tag 存在时 target_commitish + # 不起作用) + gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + -f tag_name="v$VERSION" -f target_commitish="$GITHUB_SHA" \ + --jq .body > "$RUNNER_TEMP/changes.md" + python3 scripts/release_notes.py "$VERSION" "$RUNNER_TEMP/changes.md" > "$RUNNER_TEMP/notes.md" + cat "$RUNNER_TEMP/notes.md" >> "$GITHUB_STEP_SUMMARY" + echo "path=$RUNNER_TEMP/notes.md" >> "$GITHUB_OUTPUT" - uses: softprops/action-gh-release@v2 # 排练到上面为止 if: github.ref_type == 'tag' with: + name: ThinkWatch Core ${{ env.VERSION }} + # 空的话(Release 已经在了)这个 action 保留原有的正文 + body_path: ${{ steps.notes.outputs.path }} files: ${{ env.FILES }} fail_on_unmatched_files: true - # GitHub 生成的说明:上一版以来合进 main 的 PR - generate_release_notes: ${{ steps.notes.outputs.generate }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 390c9377..c339ce97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,7 +161,9 @@ answered on the request itself. "which build is in there" has to be a fact somebody can check rather than whatever sat in a `target/` directory that afternoon. -1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. +1. Bump `version` in the workspace `Cargo.toml`, add + `release-notes/X.Y.Z.md` if the release page should carry a summary + (see below), and land both on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. 3. `release.yml` builds `twcore` for every target below. It checks that each binary is built for its target and, where the runner can execute @@ -185,10 +187,29 @@ The bare binaries are what the desktop app's pipeline bundles and what the unit and the binary come from the same commit. The file names are a contract with both: `twcore upgrade` has a test that reads `release.yml`. +The release is titled `ThinkWatch Core X.Y.Z`. Its text, in English, is +written by `scripts/release_notes.py` in this order: + +1. `release-notes/X.Y.Z.md`, when that file exists: a summary of the + release in paragraphs or lists, without a top-level heading. It goes + in with the version bump, because the tag fixes what the tree + contains. +2. A table of the files for each platform. +3. The commands that install this version on a server and switch an + existing installation to it. +4. How to verify a download against its `.sha256`. +5. GitHub's list of the pull requests merged since the previous release. + +The text is written when the release is created. A release that already +exists keeps its text, so re-running the job does not add to it, and a +correction after publishing is made on the release page itself. CI runs +`python3 scripts/release_notes_test.py`, which checks the script against +`release.yml` and renders every file in `release-notes/`. + To try a change to `release.yml` without publishing, run it by hand on your branch (`gh workflow run release.yml --ref `): it builds and -checks everything, leaves the files as the run's artifacts, and publishes -nothing. +checks everything, leaves the files as the run's artifacts, shows the +release text in the run's summary, and publishes nothing. The desktop app pins `tw-api` (and the few other crates it uses: `tw-types`, `tw-yaml`, `tw-guard`, `tw-watch`, `tw-link`) to the same tag diff --git a/release-notes/0.47.0.md b/release-notes/0.47.0.md new file mode 100644 index 00000000..a958a022 --- /dev/null +++ b/release-notes/0.47.0.md @@ -0,0 +1,17 @@ +This release adds a remote control port, through which ThinkWatch Lite on another machine can manage a core running on a server, together with an install script, a systemd unit and `twcore upgrade` for running `twcore` as a service on Linux. ThinkWatch Lite 2026.9.16 includes this version. + +**Upgrade notes** + +- A misspelled field under `proxies` (for example `typ:` for `type:`) is now a configuration error; it used to be ignored. A configuration that contains one does not pass `twcore check`, and core does not start with it. +- Every connection to the control plane now begins with a Noise handshake, and control requests no longer use a bearer token. HTTP clients such as curl cannot call the control plane directly; `twcore call` sends a request through the handshake. +- Client adoption, MCP editing and the client configuration scan moved to ThinkWatch Lite, which runs on the machine where those files are. The `twcore scan` and `twcore clients` subcommands and the related control-plane endpoints and events are removed; `POST /clients/{id}/key` remains. + +**Control key.** `listen.control.key` in `config.yaml` holds the key, and `twcore serve` adds one to a configuration that has none. Every control transport (the unix socket, the loopback port on Windows and the remote port) starts with a `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` handshake keyed by it, and a wrong key is refused before any HTTP. `twcore control-key` prints the key; `--rotate` replaces it and closes the connections made with the previous one. The key is masked in `GET /config`, in the configuration history and in diagnostic bundles, and it cannot be changed through the control plane. + +**Remote control port.** `listen.control.remote` opens a TCP port for ThinkWatch Lite on another machine, in addition to the local channel. `twcore init` writes the section disabled, with a random port from 20000–32000. `twcore remote enable [--bind B] [--port N] [--allow CIDR]`, `twcore remote disable` and `twcore remote show` change and report it, and a running core applies a change within a second. A source outside `allow_from` (the private ranges by default; loopback is not added automatically) is closed before the handshake, and a source with five failed handshakes within 60 seconds is ignored for 60 seconds. Remote connections cannot shut core down, download diagnostics or change `listen.control`. + +**Server deployment.** `scripts/install.sh` installs `twcore` on Linux (x86_64, aarch64) as a systemd service, with a `thinkwatch` system user, data in `/var/lib/thinkwatch` and environment variables in `/etc/thinkwatch/env`; it checks the SHA-256 and never starts or restarts the service. Releases now include `twcore-.tar.gz` for both Linux targets, holding the binary, `twcore.service` and `LICENSE`. `twcore upgrade [--check] [--restart] [--version X.Y.Z]` replaces a separately installed `twcore` with a release from GitHub after checking its SHA-256 and running it once; it does not replace the copy inside ThinkWatch Lite, which the app updates itself. The guide is [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md). + +**Configuration reference.** [docs/config.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/config.md) (and `docs/config.zh-CN.md`) describes every section and field of `config.yaml`. The field tables and the lists of built-in rules are generated from the code, and a test fails when the manual and the code disagree. + +**Fix.** On Linux, changing `listen.gateway.bind` between `all` and a single address on the same port now takes effect without a restart; it used to be reported as a port in use by another program. diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100755 index 00000000..4880d47e --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""写出一版在 GitHub Release 页面上的正文。 + +用法:release_notes.py <版本> <更新列表文件> + +正文是英文的,依次是: + +1. `release-notes/<版本>.md`:这一版的说明。**可以没有**,没有就从下载表开始。 +2. 下载表:每个平台一行,文件名和 release.yml 挂上去的一字不差。 +3. 服务器上安装、升级到这一版的命令。 +4. 怎样用 `.sha256` 核对下载的文件。 +5. 第二个参数的内容:GitHub 按上一版以来合并的 PR 生成的「What's Changed」,由 + 调用方用 `gh api repos/<仓库>/releases/generate-notes` 取来。拆成参数传进来, + 这个脚本就不碰网络,测试可以直接喂它。 + +说明文件写坏了(有中文、有一级标题、是空的)就不写正文 —— 发版流水线在这一步 +停下,比发出去再改好。CI 在每个 PR 上把 `release-notes/` 下的每一份都过一遍 +(release_notes_test.py),所以真到打 tag 时不该再撞上。 +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +REPO = "ThinkWatchProject/ThinkWatch-Core" +ROOT = pathlib.Path(__file__).resolve().parent.parent +NOTES_DIR = ROOT / "release-notes" + +# 每个平台发的文件:(平台, 二进制, 服务器安装用的压缩包)。**和 release.yml 的 +# FILES 一字不差** —— release_notes_test.py 拿那份清单核对这张表,多一个少一个 +# 都不行,对不上的话发布页上的链接就是 404。 +DOWNLOADS = [ + ("Linux, x86_64", "twcore-x86_64-unknown-linux-gnu", "twcore-x86_64-unknown-linux-gnu.tar.gz"), + ("Linux, aarch64", "twcore-aarch64-unknown-linux-gnu", "twcore-aarch64-unknown-linux-gnu.tar.gz"), + ("macOS, Apple silicon", "twcore-aarch64-apple-darwin", None), + ("Windows, x64", "twcore-x86_64-pc-windows-msvc.exe", None), + ("Windows, ARM64", "twcore-aarch64-pc-windows-msvc.exe", None), +] + +INSTALL_SH = f"https://raw.githubusercontent.com/{REPO}/main/scripts/install.sh" + +# 三段数字,和 `twcore upgrade` 认的一样:0.47.0 +VERSION = re.compile(r"\d+\.\d+\.\d+") +# 中日韩文字和全角标点(码位区间)。发布页写英文 +CJK_RANGES = [(0x3000, 0x30FF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0xFF00, 0xFFEF)] +CJK = re.compile("[" + "".join(f"{chr(a)}-{chr(b)}" for a, b in CJK_RANGES) + "]") + + +class NotesError(Exception): + pass + + +def summary(version: str, notes_dir: pathlib.Path = NOTES_DIR) -> str | None: + """`release-notes/<版本>.md` 的内容;没有这个文件是 None。""" + path = notes_dir / f"{version}.md" + if not path.exists(): + return None + text = path.read_text(encoding="utf-8").strip() + name = f"release-notes/{path.name}" + if not text: + raise NotesError(f"{name} is empty; delete it if there is nothing to say") + for n, line in enumerate(text.splitlines(), 1): + if CJK.search(line): + raise NotesError(f"{name}, line {n}: the release page is written in English\n {line}") + # 发布页的标题由流水线定(ThinkWatch Core <版本>),正文里再来一个一级标题就重了 + if line.startswith("# "): + raise NotesError( + f"{name}, line {n}: the workflow sets the title; start with ## or with text\n {line}" + ) + return text + + +def render(version: str, changes: str, summary_text: str | None) -> str: + """整份正文。`changes` 是 GitHub 生成的那段,原样接在最后。""" + if not VERSION.fullmatch(version): + raise NotesError(f"`{version}` is not a version such as 0.47.0") + base = f"https://github.com/{REPO}/releases/download/v{version}" + + def link(name: str | None) -> str: + return f"[`{name}`]({base}/{name})" if name else "—" + + parts = [] + if summary_text: + parts.append(summary_text) + + rows = "\n".join(f"| {platform} | {link(binary)} | {link(archive)} |" for platform, binary, archive in DOWNLOADS) + parts.append( + f"""## Downloads + +| Platform | Binary | Archive for server installation | +|---|---|---| +{rows} + +Each file is published with a `.sha256` file beside it. A Linux archive contains `twcore`, the systemd unit `twcore.service` and `LICENSE`. ThinkWatch Lite includes its own copy of `twcore`; the files here are for running core separately, such as on a server.""" + ) + + parts.append( + f"""## Server installation + +On Linux (x86_64 or aarch64), the install script sets up `twcore` as a systemd service. This installs {version}: + +```sh +curl -fsSL {INSTALL_SH} | sudo sh -s -- --version {version} +``` + +An installation made with the script switches to {version} with: + +```sh +sudo twcore upgrade --version {version} --restart +``` + +Configuration, the remote control port and connecting ThinkWatch Lite are described in [docs/server.md](https://github.com/{REPO}/blob/main/docs/server.md).""" + ) + + parts.append( + """## Verifying a download + +A `.sha256` file holds the SHA-256 of the file followed by its name. With both files in the current directory, on Linux: + +```sh +sha256sum -c twcore-x86_64-unknown-linux-gnu.tar.gz.sha256 +``` + +On macOS: + +```sh +shasum -a 256 -c twcore-aarch64-apple-darwin.sha256 +``` + +On Windows, in PowerShell, the following prints `True` when the binary matches: + +```powershell +(Get-FileHash .\\twcore-x86_64-pc-windows-msvc.exe).Hash -eq (Get-Content .\\twcore-x86_64-pc-windows-msvc.exe.sha256).Split()[0] +``` + +The install script and `twcore upgrade` check the SHA-256 themselves.""" + ) + + if changes.strip(): + parts.append(changes.strip()) + return "\n\n".join(parts) + "\n" + + +def main() -> None: + if len(sys.argv) != 3: + sys.exit("usage: release_notes.py ") + version, changes_file = sys.argv[1], sys.argv[2] + changes = pathlib.Path(changes_file).read_text(encoding="utf-8") + try: + sys.stdout.write(render(version, changes, summary(version))) + except NotesError as e: + sys.exit(str(e)) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_notes_test.py b/scripts/release_notes_test.py new file mode 100755 index 00000000..57f586cd --- /dev/null +++ b/scripts/release_notes_test.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""release_notes.py 的测试,外加 `release-notes/` 下的每一份说明。 + + python3 scripts/release_notes_test.py + +CI 在每个 PR 上跑它:发布页的正文只在推 tag 时才写,写坏了(链接 404、说明里 +混进中文)要到那时才看得见,而那时要撤回的是一个已经推上去的 tag。 +""" + +import pathlib +import re +import subprocess +import sys +import tempfile +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +# 不在仓库里留 __pycache__ +sys.dont_write_bytecode = True + +import release_notes as rn # noqa: E402 + +ROOT = HERE.parent +WORKFLOW = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + +CHANGES = """## What's Changed +* chore: v0.47.0 by @fylorn in https://github.com/ThinkWatchProject/ThinkWatch-Core/pull/190 + + +**Full Changelog**: https://github.com/ThinkWatchProject/ThinkWatch-Core/compare/v0.46.0...v0.47.0""" + +BASE = "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/download/v0.47.0/" + + +def published() -> list[str]: + """release.yml 里 FILES 那一段:发布出去的每一个文件。""" + block = re.search(r"\n FILES: \|\n((?: dist/\S+\n)+)", WORKFLOW) + assert block, "release.yml has no FILES block" + return [line.strip().removeprefix("dist/") for line in block.group(1).splitlines()] + + +def notes_dir(case: unittest.TestCase, files: dict[str, str]) -> pathlib.Path: + """一个临时的 `release-notes/`,测试结束就删。""" + tmp = tempfile.TemporaryDirectory() + case.addCleanup(tmp.cleanup) + d = pathlib.Path(tmp.name) + for name, text in files.items(): + (d / name).write_text(text, encoding="utf-8") + return d + + +class Body(unittest.TestCase): + def test_sections_come_in_order(self): + body = rn.render("0.47.0", CHANGES, "A remote control port.") + order = [ + body.index("A remote control port."), + body.index("## Downloads"), + body.index("## Server installation"), + body.index("## Verifying a download"), + body.index("## What's Changed"), + ] + self.assertEqual(order, sorted(order)) + self.assertTrue(body.startswith("A remote control port.\n\n## Downloads")) + self.assertTrue(body.endswith("v0.46.0...v0.47.0\n")) + + def test_without_a_summary_it_starts_with_the_downloads(self): + self.assertTrue(rn.render("0.47.0", CHANGES, None).startswith("## Downloads\n")) + + def test_without_changes_there_is_no_empty_section(self): + body = rn.render("0.47.0", "\n", None) + self.assertNotIn("What's Changed", body) + self.assertTrue(body.endswith("themselves.\n")) + + def test_it_links_exactly_the_files_the_workflow_publishes(self): + # 多一个是 404,少一个是发了没人找得到 + body = rn.render("0.47.0", CHANGES, None) + links = [u.removeprefix(BASE) for u in re.findall(r"\]\((https://[^)]+)\)", body) if u.startswith(BASE)] + files = [f for f in published() if not f.endswith(".sha256")] + self.assertEqual(len(files), 7, files) + self.assertEqual(sorted(links), sorted(files)) + # 每个文件都有它的校验文件 + for f in files: + self.assertIn(f"{f}.sha256", published()) + + def test_install_and_upgrade_pin_this_version(self): + body = rn.render("0.47.0", CHANGES, None) + self.assertIn( + "\ncurl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh" + " | sudo sh -s -- --version 0.47.0\n", + body, + ) + self.assertIn("\nsudo twcore upgrade --version 0.47.0 --restart\n", body) + + def test_the_commands_use_options_that_exist(self): + # install.sh 和 twcore upgrade 各自认的选项;改了名字这里就对不上 + install = (ROOT / "scripts/install.sh").read_text(encoding="utf-8") + self.assertIn("--version) ", install) + main = (ROOT / "bin/twcore/src/main.rs").read_text(encoding="utf-8") + upgrade = main[main.index(" Upgrade {") : main.index("\n },", main.index(" Upgrade {"))] + for field in ["restart: bool", "version: Option"]: + self.assertIn(field, upgrade) + + def test_the_checksum_commands_name_real_files(self): + body = rn.render("0.47.0", CHANGES, None) + for f in ["twcore-x86_64-unknown-linux-gnu.tar.gz", "twcore-aarch64-apple-darwin", "twcore-x86_64-pc-windows-msvc.exe"]: + self.assertIn(f"{f}.sha256", published()) + self.assertIn("sha256sum -c twcore-x86_64-unknown-linux-gnu.tar.gz.sha256\n", body) + self.assertIn("shasum -a 256 -c twcore-aarch64-apple-darwin.sha256\n", body) + self.assertIn( + "(Get-FileHash .\\twcore-x86_64-pc-windows-msvc.exe).Hash -eq " + "(Get-Content .\\twcore-x86_64-pc-windows-msvc.exe.sha256).Split()[0]\n", + body, + ) + + def test_the_body_itself_is_english(self): + self.assertIsNone(rn.CJK.search(rn.render("0.47.0", CHANGES, None))) + + def test_a_malformed_version_is_refused(self): + for bad in ["v0.47.0", "0.47", "0.47.0-rc1", "2026.9.16.1", ""]: + with self.assertRaises(rn.NotesError, msg=bad): + rn.render(bad, CHANGES, None) + + +class Summary(unittest.TestCase): + def test_absent_is_none(self): + self.assertIsNone(rn.summary("0.47.0", notes_dir(self, {}))) + + def test_read_and_trimmed(self): + d = notes_dir(self, {"0.47.0.md": "\nA remote control port.\n\n"}) + self.assertEqual(rn.summary("0.47.0", d), "A remote control port.") + + def test_chinese_is_refused(self): + for text in ["远程控制端口。", "Remote port(远程)", "Remote port,"]: + d = notes_dir(self, {"0.47.0.md": f"Upgrade notes.\n\n{text}\n"}) + with self.assertRaises(rn.NotesError, msg=text) as e: + rn.summary("0.47.0", d) + self.assertIn("line 3", str(e.exception)) + + def test_a_top_level_heading_is_refused(self): + d = notes_dir(self, {"0.47.0.md": "# ThinkWatch Core 0.47.0\n\nText.\n"}) + with self.assertRaises(rn.NotesError): + rn.summary("0.47.0", d) + # 二级标题可以 + d = notes_dir(self, {"0.47.0.md": "## Upgrade notes\n\nText.\n"}) + self.assertEqual(rn.summary("0.47.0", d), "## Upgrade notes\n\nText.") + + def test_an_empty_file_is_refused(self): + with self.assertRaises(rn.NotesError): + rn.summary("0.47.0", notes_dir(self, {"0.47.0.md": " \n\n"})) + + +class Committed(unittest.TestCase): + def test_every_file_in_release_notes_renders(self): + for path in sorted(rn.NOTES_DIR.glob("*")): + with self.subTest(path.name): + self.assertEqual(path.suffix, ".md") + version = path.name.removesuffix(".md") + self.assertRegex(version, rn.VERSION) + text = rn.summary(version) + self.assertTrue(rn.render(version, CHANGES, text).startswith(text)) + + +class CommandLine(unittest.TestCase): + def run_script(self, version: str) -> subprocess.CompletedProcess: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + changes = pathlib.Path(tmp.name) / "changes.md" + changes.write_text(CHANGES, encoding="utf-8") + return subprocess.run( + [sys.executable, str(HERE / "release_notes.py"), version, str(changes)], + capture_output=True, + text=True, + ) + + def test_writes_the_body_to_stdout(self): + r = self.run_script("9.9.9") + self.assertEqual(r.returncode, 0, r.stderr) + out = r.stdout + self.assertTrue(out.startswith("## Downloads\n")) + self.assertIn("releases/download/v9.9.9/twcore-aarch64-apple-darwin)", out) + + def test_a_bad_version_fails(self): + r = self.run_script("v9.9.9") + self.assertNotEqual(r.returncode, 0) + self.assertEqual(r.stdout, "") + + +if __name__ == "__main__": + unittest.main() From 440baa6c9f3f1beb9f67a4a5c36b1a193130e766 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:48:01 +0800 Subject: [PATCH 2/3] release-notes: summarize 0.48.0 v0.48.0 was published before the template landed, so its page holds only GitHub's generated list. release-notes/0.48.0.md summarizes it from #191-#198 so the page can be rewritten from the template: - upgrade notes: CONTROL_API_VERSION 21, so ThinkWatch Lite 2026.9.16 (core 0.47.0, protocol 20) does not connect to it and a server used with that app stays on 0.47.0; the request store's schema 20, which empties the request history on the first start (and again on the way back); the /in-flight shape, RequestStarted.session and the removed ChatgptUsage fields; - session and route on the start event, requests a rule decided without an upstream, the replayable /in-flight snapshot and the new /live fields, and /summary/routes (#197); - per-group unpriced and no-usage counts, and security log totals (#193); - the signed-in account on a ChatGPT account upstream (#195); - releases published from one job, the server guide and the crate metadata (#191), and the test port fix (#192). Co-Authored-By: Claude Opus 5.5 --- release-notes/0.48.0.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 release-notes/0.48.0.md diff --git a/release-notes/0.48.0.md b/release-notes/0.48.0.md new file mode 100644 index 00000000..ec19383a --- /dev/null +++ b/release-notes/0.48.0.md @@ -0,0 +1,23 @@ +This release reports more of what core knows about each request: the session and the route from the moment a request starts, a snapshot of running requests that a client can replay, and how often each route and rule was used. It also names the account signed in on a ChatGPT account upstream, counts the requests in each cost group that have no price or no usage, and gives the security log totals for the whole window. + +**Upgrade notes** + +- The control-plane protocol version (`CONTROL_API_VERSION`) is now 21. ThinkWatch Lite connects only to a core with the same protocol version, so a server has to run the core version that the app includes. ThinkWatch Lite 2026.9.16 includes 0.47.0 (protocol 20) and does not connect to 0.48.0: a server used with it stays on 0.47.0 until the app is updated to a release that includes 0.48.0. `sudo twcore upgrade --version 0.47.0 --restart` switches a server back to 0.47.0. +- The format of the request history changed (request store schema 20). On its first start, 0.48.0 replaces the request history and the stored request and response bodies of an earlier version with an empty store; the configuration is kept. Switching back to an earlier version empties it again. +- `GET /in-flight` returns an object, `InFlight { now_ms, requests: [{ id, events }] }`, instead of an array of start events. `RequestStarted.session_fp` is replaced by `session`. `ChatgptUsage.email` and `ChatgptUsage.plan` are removed; the signed-in account is in `ProviderView.oauth.account`. + +**Session and route from the start.** The gateway assigns a request to a session when the request starts (the same conversation, within 30 minutes of its previous request), and `RequestStarted` carries that session id, the one the request is stored under. The first routing phase now finishes before the start event, so `RequestStarted` also carries the route, the rule, the strategy group and the rules that rewrote the request. `RequestRouted` carries the final record, including rewrites from the second phase and `denied_by` for a rule that denied the request there. The stored routing record has the same fields and is written from the start event, so a request that ends before routing completes still shows the route and rule it matched. + +**Requests a rule decided.** A request that a rule denies in the first routing phase, or that none of the upstreams a rule chose can serve, used to produce no events and no stored row. It now produces start, routed and failed events and a row, and counts among the requests and failures in `/summary`; a denial in the second phase now also produces the routed event. Requests that fail before a rule decides (authentication, model admission, no matching rule) are still not recorded. + +**Live views.** `GET /in-flight` returns core's clock and, for each running request, the events seen so far in order, so that a client connecting while requests are running can replay them and compute elapsed time from core's timestamps. The running requests in `GET /live` add `elapsed_ms`, `session`, `route`, `rule`, `group` and `upstream`. + +**Rule hit counts.** `GET /summary/routes` reports, for a time window (today by default), each route's requests, failures and last request, and for each rule the requests it decided, the requests it applied to, their failures and the last one. The counts come from the stored routing records, not from the current configuration. + +**Cost groups and the security log.** Every group returned by `GET /summary/buckets/by` reports `unpriced_requests` and `no_usage_requests`, so a group with no cost can be told apart as unpriced, missing usage or free. `GET /security/events` adds `total` and `by_outcome` (recorded, replaced, cut, blocked): the counts for everything the query's guard and time window match, the same on every page. + +**ChatGPT account upstreams.** `ProviderView.oauth.account` gives the email and plan of the account signed in on a ChatGPT account upstream. Both are read from the access token core already keeps with the credential: nothing is requested from the network, nothing new is stored, and the account and user ids in the token are not exposed. The plan is reported as the backend names it, including plans that core does not list. + +**Releases.** A release is now published by a single job once every platform has been built and each file matches its SHA-256. Previously the first platform to finish created the release, and made it the latest, while the others were still building, and each build job added another copy of the release notes. The server guide, [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md), states that ThinkWatch Lite keeps a remote connection's key in a private file in its data directory rather than in the system keychain, and applies to the app on macOS, Windows and Linux. The crates carry homepage and documentation links, and `twcore --help` begins with the binary's description. + +**Tests.** Tests no longer hand core a port that was just released, which could make tests that bind ports, such as those of the remote control port, fail intermittently on Linux. From 54c6bd90f7c0ab17555a969f13a1fe026ada6a4b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:00:15 +0800 Subject: [PATCH 3/3] release-notes(0.48.0): say where the key is kept without naming the keychain Co-Authored-By: Claude Opus 5.5 --- release-notes/0.48.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/0.48.0.md b/release-notes/0.48.0.md index ec19383a..01516757 100644 --- a/release-notes/0.48.0.md +++ b/release-notes/0.48.0.md @@ -18,6 +18,6 @@ This release reports more of what core knows about each request: the session and **ChatGPT account upstreams.** `ProviderView.oauth.account` gives the email and plan of the account signed in on a ChatGPT account upstream. Both are read from the access token core already keeps with the credential: nothing is requested from the network, nothing new is stored, and the account and user ids in the token are not exposed. The plan is reported as the backend names it, including plans that core does not list. -**Releases.** A release is now published by a single job once every platform has been built and each file matches its SHA-256. Previously the first platform to finish created the release, and made it the latest, while the others were still building, and each build job added another copy of the release notes. The server guide, [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md), states that ThinkWatch Lite keeps a remote connection's key in a private file in its data directory rather than in the system keychain, and applies to the app on macOS, Windows and Linux. The crates carry homepage and documentation links, and `twcore --help` begins with the binary's description. +**Releases.** A release is now published by a single job once every platform has been built and each file matches its SHA-256. Previously the first platform to finish created the release, and made it the latest, while the others were still building, and each build job added another copy of the release notes. The server guide, [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md), states that ThinkWatch Lite keeps a remote connection's key in a private file in its data directory, and applies to the app on macOS, Windows and Linux. The crates carry homepage and documentation links, and `twcore --help` begins with the binary's description. **Tests.** Tests no longer hand core a port that was just released, which could make tests that bind ports, such as those of the remote control port, fail intermittently on Linux.