From ddf4586fb4042f4eec3b17a362860a391a24df13 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:16:08 +0800 Subject: [PATCH 1/5] ci(release): publish from one job, so the notes are written once Every build job attached its own files with softprops/action-gh-release and generate_release_notes. When the release already exists, the action asks GitHub for the generated notes again and appends them to the body it found, so each job after the first added another copy: four in v0.44.0 to v0.47.0 (macOS, Windows, two Linux legs), two in v0.30.0 to v0.43.0. v0.11.0 has two for the same reason: that release was created by hand before the workflow ran. The build jobs now only build, check and upload their files as run artifacts. A final publish job waits for all of them, checks that exactly the listed files arrived and that each matches its .sha256, and writes the release once. It asks for generated notes only when the release does not exist yet, so re-running it after a failed upload does not add a second copy. Publishing in one step also closes a window: the first job to finish used to create the release, making it `releases/latest` while the other platforms were still building, so install.sh and `twcore upgrade` found nothing for those platforms for a few minutes. A failed target now publishes nothing rather than an incomplete release. The asset list lives in the publish job, where the upgrade test that reads release.yml still finds every file name it looks for. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 114 ++++++++++++++++++++++++++-------- CONTRIBUTING.md | 14 +++-- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b222bbed..53ac3719 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,19 @@ # 这条流水线不重跑那四道门:tag 是从 main 上打的,而 main 上的每一个 # commit 都过过 `ci.yml`。这里只做 CI 做不了的那件事 —— 产出一个别人 # 能下载、能校验的文件。 +# +# **所有平台一起发,或者都不发。**各平台的 job 只构建、自检,把文件交给 +# 最后的 `publish`,Release 由它一次写成。各挂各的时候,先编完的那个就把 +# Release 建了出来,`releases/latest` 随即指向它,而别的平台的文件还在路上 +# —— 那几分钟里 `scripts/install.sh` 和 `twcore upgrade` 在那些平台上找不到 +# 文件;某个平台编挂了,发出去的就是缺一块的 Release。 name: Release on: push: tags: ["v*"] - # **排练。**手工触发时照常构建、照常自检,但什么都不发。 + # **排练。**手工触发时照常构建、照常自检,但什么都不发:文件留在这次 + # 运行的产物(Artifacts)里,`publish` 照样把它们逐个核对,只是不写 Release。 # # 理由是这条流水线唯一一个「发现了也没法在本次补救」的性质:tag 一旦 # 推上去,流水线错了就得把它撤回来。而这里新增的那条 Windows 支线要靠 @@ -72,16 +79,12 @@ jobs: shasum -a 256 twcore-aarch64-apple-darwin > twcore-aarch64-apple-darwin.sha256 cat twcore-aarch64-apple-darwin.sha256 - - uses: softprops/action-gh-release@v2 - # 排练不发布。 - if: github.ref_type == 'tag' + # 交给 `publish`,Release 只由它来写 + - uses: actions/upload-artifact@v4 with: - files: | - dist/twcore-aarch64-apple-darwin - dist/twcore-aarch64-apple-darwin.sha256 - # 发布说明留空,从 tag 的信息里来 —— 让一份自动生成的清单 - # 冒充发布说明,读的人得不到任何东西 - generate_release_notes: true + name: twcore-aarch64-apple-darwin + path: dist/ + if-no-files-found: error twcore-windows: name: twcore (Windows x64 + arm64) @@ -163,16 +166,11 @@ jobs: Get-Content "$out.sha256" } - - uses: softprops/action-gh-release@v2 - # 排练不发布。 - if: github.ref_type == 'tag' + - uses: actions/upload-artifact@v4 with: - files: | - dist/twcore-x86_64-pc-windows-msvc.exe - dist/twcore-x86_64-pc-windows-msvc.exe.sha256 - dist/twcore-aarch64-pc-windows-msvc.exe - dist/twcore-aarch64-pc-windows-msvc.exe.sha256 - generate_release_notes: true + name: twcore-windows + path: dist/ + if-no-files-found: error twcore-linux: name: twcore (${{ matrix.target }}) @@ -289,13 +287,77 @@ jobs: test -f "$T/twcore-$TARGET/twcore.service" "$T/twcore-$TARGET/twcore" --version + - uses: actions/upload-artifact@v4 + with: + name: twcore-${{ matrix.target }} + path: dist/ + if-no-files-found: error + + # **Release 只在这里写,而且只写一次。** + # + # 以前每个构建 job 各自用 action-gh-release 挂自己的文件,每一次都带着 + # `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] + runs-on: ubuntu-latest + env: + # 发出去的就是这些,一个不多、一个不少。文件名是和桌面版的流水线、 + # `twcore upgrade`、`scripts/install.sh` 之间的约定,`twcore upgrade` 有 + # 一条测试读这份文件核对它。加一个平台要在这里加上它的文件 —— 否则 + # 下面的核对会指出多出来的那几个 + FILES: | + dist/twcore-aarch64-apple-darwin + dist/twcore-aarch64-apple-darwin.sha256 + dist/twcore-x86_64-pc-windows-msvc.exe + dist/twcore-x86_64-pc-windows-msvc.exe.sha256 + dist/twcore-aarch64-pc-windows-msvc.exe + dist/twcore-aarch64-pc-windows-msvc.exe.sha256 + dist/twcore-x86_64-unknown-linux-gnu + dist/twcore-x86_64-unknown-linux-gnu.sha256 + dist/twcore-x86_64-unknown-linux-gnu.tar.gz + dist/twcore-x86_64-unknown-linux-gnu.tar.gz.sha256 + dist/twcore-aarch64-unknown-linux-gnu + dist/twcore-aarch64-unknown-linux-gnu.sha256 + dist/twcore-aarch64-unknown-linux-gnu.tar.gz + dist/twcore-aarch64-unknown-linux-gnu.tar.gz.sha256 + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + # 排练也跑这一步:构建 job 交来的正好是清单上的文件,每个都对得上 + # 它的校验和 + - name: Every file is here, nothing else is, and each matches its checksum + run: | + set -euo pipefail + diff <(printf '%s' "$FILES" | sort) <(find dist -type f | sort) + cd dist + sha256sum -c ./*.sha256 + + # 同一个原因,这一个 job 也会接上第二份:Release 已经在了 —— 重跑这个 + # job(比如挂文件挂到一半断了),或者 Release 先手工建好了(v0.11.0 就是 + # 这样多出一份的)。所以只在它还不存在时生成 + - name: Notes only for a release that does not exist yet + id: notes + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + echo "generate=false" >> "$GITHUB_OUTPUT" + else + echo "generate=true" >> "$GITHUB_OUTPUT" + fi + - uses: softprops/action-gh-release@v2 - # 排练不发布。 + # 排练到上面为止 if: github.ref_type == 'tag' with: - files: | - dist/twcore-${{ matrix.target }} - dist/twcore-${{ matrix.target }}.sha256 - dist/twcore-${{ matrix.target }}.tar.gz - dist/twcore-${{ matrix.target }}.tar.gz.sha256 - generate_release_notes: true + 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 3540ba39..f8b1c2db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,9 +157,11 @@ whatever sat in a `target/` directory that afternoon. 1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. -3. `release.yml` builds `twcore` for every target below, checks each - binary actually runs and reports the version on the tag, and attaches - them to a GitHub Release, each with a `.sha256` (` `). +3. `release.yml` builds `twcore` for every target below and checks each + binary actually runs and reports the version on the tag. Once every + target has built, a single job attaches them all to a GitHub Release, + each with a `.sha256` (` `); if one target fails, nothing + is published. | Target | Files | |---|---| @@ -173,8 +175,10 @@ 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`. -To try a change to `release.yml` without publishing, run it by hand -(`workflow_dispatch`): it builds and checks everything and uploads nothing. +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. The desktop app pins `tw-api` (and the few other crates it uses: `tw-types`, `tw-yaml`, `tw-guard`, `tw-watch`) to the same tag and bundles the binary From 2af1329d4ecb1c5c8d6cbd1aff4d7328e2f17e4b Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:16:22 +0800 Subject: [PATCH 2/5] docs: remote cores are reached from any desktop OS, and the key is in a file docs/server.md said the desktop app keeps the control key in the Mac's keychain. ThinkWatch Lite stores remote connection keys in a file in its data directory that only the user running the app can read (0600 in a 0700 directory on macOS and Linux; on Windows the directory's protected DACL admits only the current user and SYSTEM) and does not use the system keychain. The page also spoke only of a Mac, while Lite on macOS, Windows and Linux connects to a remote core. The wording is now OS-neutral; "this computer's address" matches the app's own message for a refused connection. The Chinese page gets the same corrections. CONTRIBUTING.md still called the remote control port a transport "to come"; it shipped in v0.47.0. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- docs/server.md | 29 +++++++++++++++-------------- docs/server.zh-CN.md | 6 +++--- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8b1c2db..3c25f937 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ clean the diff is: protections.** Replay came close to being a legitimate way around redaction. - **One door into the control plane.** Every transport (unix socket, - Windows loopback port, and the remote port to come) hands its + Windows loopback port, and the remote control port) hands its connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. diff --git a/docs/server.md b/docs/server.md index e0b2c29f..319ba244 100644 --- a/docs/server.md +++ b/docs/server.md @@ -3,9 +3,9 @@ [中文](server.zh-CN.md) ThinkWatch Core runs without a desktop: on a Linux machine it is started by -systemd from its configuration file, and the ThinkWatch Lite app on a Mac -connects to it over the network to show traffic and change settings. Clients -anywhere on the network send their requests to the server's gateway. +systemd from its configuration file, and ThinkWatch Lite on macOS, Windows or +Linux connects to it over the network to show traffic and change settings. +Clients anywhere on the network send their requests to the server's gateway. This page covers installing, configuring, starting, connecting and upgrading. Every field mentioned is described in the @@ -185,7 +185,7 @@ and enter: The app tests the connection before saving and says what is wrong if it fails: no answer (address, port, firewall, `enabled`), connection closed -(this Mac's address is probably not in `allow_from`), wrong key, or +(this computer's address is probably not in `allow_from`), wrong key, or different versions. `allow_from` for this port does not let the server itself in automatically; commands on the server use the local channel. @@ -193,21 +193,22 @@ A source that fails the handshake five times within a minute is ignored for a minute. Removing a network from `allow_from` also closes the connections already open from it. -The key is kept in the Mac's keychain. To replace it, run -`twcore control-key --rotate` on the server; connections made with the old -key are closed at once, and connected apps then have to be given the new -key. +The desktop app stores the key in its data directory, in a file readable +only by the user who runs the app, rather than in the system keychain. To +replace it, run `twcore control-key --rotate` on the server; connections +made with the old key are closed at once, and connected apps then have to +be given the new key. -A remote connection can do everything the app does on its own Mac except -three things, which the server refuses: stopping core (systemd runs it), -taking the diagnostic bundle, and changing `listen.control`, the section -it came in through. Do those on the server. +A remote connection can do everything the app does on its own computer +except three things, which the server refuses: stopping core (systemd +runs it), taking the diagnostic bundle, and changing `listen.control`, +the section it came in through. Do those on the server. ### Point clients at the server Clients use the server's gateway, `http://:8788`, with a gateway key -from `clients`. The desktop app can point the clients on the Mac at the -server (Clients page); on other machines, configure them by hand. +from `clients`. The desktop app can point the clients on its own computer at +the server (Clients page); on other machines, configure them by hand. ## Upgrading diff --git a/docs/server.zh-CN.md b/docs/server.zh-CN.md index 4f4b3aa0..3b253d38 100644 --- a/docs/server.zh-CN.md +++ b/docs/server.zh-CN.md @@ -2,7 +2,7 @@ [English](server.md) -ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,macOS、Windows 或 Linux 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 @@ -143,13 +143,13 @@ allowed sources: 192.168.1.0/24 同一来源一分钟内握手失败五次,之后一分钟不理它。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 -密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 +桌面应用把密钥存放在其数据目录下的一个文件中,该文件只有运行应用的用户可以读取;不使用系统钥匙串。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 远程连接能做应用在本机能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。 ### 让客户端指向服务器 -客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把本机的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 ## 升级 From 4f8bae1d0f335f6055b0ddd30dde2eb65103e405 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:16:46 +0800 Subject: [PATCH 3/5] chore(cargo): homepage, docs and readme for every crate; keywords for the gateway The workspace gains homepage (https://thinkwat.ch/core/, the canonical form of /core), documentation and readme, and every member inherits them. The crates are not published to crates.io -- the desktop app and ThinkWatch Enterprise take them from git by tag -- so there is no docs.rs page; documentation points at the project's docs instead. Keywords and categories describe the gateway itself, so only twcore, tw-gateway and tw-control inherit them; a YAML patch layer or a directory watcher is not an HTTP server. Both follow crates.io's rules: five keywords at most, and categories from its list of slugs. Two descriptions were out of date. tw-control no longer serves only a unix socket: Windows uses a loopback port, and there is an optional remote control port. twcore runs as the local gateway behind ThinkWatch Lite or on its own on a server. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 10 ++++++++++ bin/twcore/Cargo.toml | 7 ++++++- crates/tw-api/Cargo.toml | 3 +++ crates/tw-breaker/Cargo.toml | 3 +++ crates/tw-config/Cargo.toml | 3 +++ crates/tw-control/Cargo.toml | 7 ++++++- crates/tw-dialect/Cargo.toml | 3 +++ crates/tw-engine/Cargo.toml | 3 +++ crates/tw-gateway/Cargo.toml | 5 +++++ crates/tw-guard/Cargo.toml | 3 +++ crates/tw-link/Cargo.toml | 3 +++ crates/tw-observe/Cargo.toml | 3 +++ crates/tw-pricing/Cargo.toml | 3 +++ crates/tw-secret/Cargo.toml | 3 +++ crates/tw-store/Cargo.toml | 3 +++ crates/tw-types/Cargo.toml | 3 +++ crates/tw-watch/Cargo.toml | 3 +++ crates/tw-yaml/Cargo.toml | 3 +++ 18 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8b502174..2baa824a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,16 @@ edition = "2024" rust-version = "1.85" license = "MIT" repository = "https://github.com/ThinkWatchProject/ThinkWatch-Core" +homepage = "https://thinkwat.ch/core/" +# 这些 crate 不发 crates.io(企业版和桌面版按 tag 从 git 取),docs.rs 上 +# 没有它们 —— 文档指向项目自己的 +documentation = "https://thinkwat.ch/docs/core/" +readme = "README.md" +# 关键词和分类说的是网关本身,只有网关那几个 crate(twcore、tw-gateway、 +# tw-control)继承;其余的 crate 各有各的事,只继承上面几项。 +# crates.io 的规矩:关键词最多五个,分类只能用它列出的 slug +keywords = ["ai-gateway", "llm", "openai", "anthropic", "gemini"] +categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index b00dfe8f..ea4eea68 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -5,7 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Self-contained local AI gateway binary, and the engine behind ThinkWatch Lite" +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +description = "Self-contained AI gateway binary: the local gateway behind ThinkWatch Lite, or a standalone gateway on a server" +keywords.workspace = true +categories.workspace = true [dependencies] tw-config = { workspace = true } diff --git a/crates/tw-api/Cargo.toml b/crates/tw-api/Cargo.toml index 3ec5b7ed..bdbd3be3 100644 --- a/crates/tw-api/Cargo.toml +++ b/crates/tw-api/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Control-plane contract: request and response types, plus a client" [dependencies] diff --git a/crates/tw-breaker/Cargo.toml b/crates/tw-breaker/Cargo.toml index 0e3c8b47..733f746c 100644 --- a/crates/tw-breaker/Cargo.toml +++ b/crates/tw-breaker/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "The circuit-breaker state machine both gateways run: pure data and pure transitions, stored wherever the caller keeps it" [dependencies] diff --git a/crates/tw-config/Cargo.toml b/crates/tw-config/Cargo.toml index f037b21d..84dadad0 100644 --- a/crates/tw-config/Cargo.toml +++ b/crates/tw-config/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "YAML config schema, loading, and validation" [dependencies] diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index eb5256a6..ab8273a6 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -5,7 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Control-plane API server over a unix socket" +homepage.workspace = true +documentation.workspace = true +readme.workspace = true +description = "Control-plane API server over a unix socket, a loopback port on Windows, and an optional remote control port" +keywords.workspace = true +categories.workspace = true [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-dialect/Cargo.toml b/crates/tw-dialect/Cargo.toml index 38cd577e..7a3e72a2 100644 --- a/crates/tw-dialect/Cargo.toml +++ b/crates/tw-dialect/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Request, response and stream conversion between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini" [dependencies] diff --git a/crates/tw-engine/Cargo.toml b/crates/tw-engine/Cargo.toml index c8254fe2..b5f6a48d 100644 --- a/crates/tw-engine/Cargo.toml +++ b/crates/tw-engine/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Routing rule engine and policy groups" [dependencies] diff --git a/crates/tw-gateway/Cargo.toml b/crates/tw-gateway/Cargo.toml index cd2c752f..6d84aee2 100644 --- a/crates/tw-gateway/Cargo.toml +++ b/crates/tw-gateway/Cargo.toml @@ -5,7 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Data-plane HTTP server" +keywords.workspace = true +categories.workspace = true [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-guard/Cargo.toml b/crates/tw-guard/Cargo.toml index ad2b4a5c..9cec2a04 100644 --- a/crates/tw-guard/Cargo.toml +++ b/crates/tw-guard/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Guards shared by both gateways: outbound redaction and restoration, inspection of the tool calls an upstream returns, hidden characters, content filtering and an output length limit" [dependencies] diff --git a/crates/tw-link/Cargo.toml b/crates/tw-link/Cargo.toml index 7abeb067..1b2fa47f 100644 --- a/crates/tw-link/Cargo.toml +++ b/crates/tw-link/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Control-channel handshake and encryption (Noise NNpsk0), shared by core and the desktop app" [dependencies] diff --git a/crates/tw-observe/Cargo.toml b/crates/tw-observe/Cargo.toml index 7b08870f..b316444e 100644 --- a/crates/tw-observe/Cargo.toml +++ b/crates/tw-observe/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Event bus" [dependencies] diff --git a/crates/tw-pricing/Cargo.toml b/crates/tw-pricing/Cargo.toml index a39d0b21..9dcaa63f 100644 --- a/crates/tw-pricing/Cargo.toml +++ b/crates/tw-pricing/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Pricing: the public price table, user price sheets, and three-state cost (measured, estimated, unpriced)" [dependencies] diff --git a/crates/tw-secret/Cargo.toml b/crates/tw-secret/Cargo.toml index 88fb5138..1a41b16a 100644 --- a/crates/tw-secret/Cargo.toml +++ b/crates/tw-secret/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Environment variable interpolation, exec-sourced credentials, and secret masking" [dependencies] diff --git a/crates/tw-store/Cargo.toml b/crates/tw-store/Cargo.toml index 0bdb523c..d0ae45b8 100644 --- a/crates/tw-store/Cargo.toml +++ b/crates/tw-store/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Request history and runtime state on SQLite and the filesystem" [dependencies] diff --git a/crates/tw-types/Cargo.toml b/crates/tw-types/Cargo.toml index 5af0619a..5f637a6e 100644 --- a/crates/tw-types/Cargo.toml +++ b/crates/tw-types/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "A message for people: a stable code, its arguments, and the English sentence" [dependencies] diff --git a/crates/tw-watch/Cargo.toml b/crates/tw-watch/Cargo.toml index 1f78ee48..0390cfe0 100644 --- a/crates/tw-watch/Cargo.toml +++ b/crates/tw-watch/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "Debounced directory watching: one signal per burst of changes" [dependencies] diff --git a/crates/tw-yaml/Cargo.toml b/crates/tw-yaml/Cargo.toml index fdc6cf49..093afc31 100644 --- a/crates/tw-yaml/Cargo.toml +++ b/crates/tw-yaml/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +homepage.workspace = true +documentation.workspace = true +readme.workspace = true description = "YAML patch layer that makes minimal edits to the original text" [dependencies] From d5cb8e8e22a7535f8d6bbc4d41c1bd3c0f61e772 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:51:46 +0800 Subject: [PATCH 4/5] ci(release): only "release not found" counts as a missing release The publish job asks `gh release view` whether the release exists, and it treated any failure as "no". A network error, a bad token or a rate limit then generated the notes again for a release that did exist, which is the duplicate this job is there to prevent. Now only gh's own `release not found` means the release is missing. Any other failure fails the step, with gh's message in the log. Tried locally with gh 2.93.0 against an existing tag, a missing tag, a bad token (HTTP 401) and an unreachable proxy. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53ac3719..c431b278 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -341,17 +341,24 @@ jobs: # 同一个原因,这一个 job 也会接上第二份:Release 已经在了 —— 重跑这个 # job(比如挂文件挂到一半断了),或者 Release 先手工建好了(v0.11.0 就是 # 这样多出一份的)。所以只在它还不存在时生成 + # + # **「不存在」只认 gh 查不到时的那句 `release not found`。**网络断了、令牌 + # 不对、被限流,gh 一样失败;把那些也当成「不存在」,Release 其实在的话就又 + # 接上一份。所以别的失败让这一步挂掉,原话留在日志里 - name: Notes only for a release that does not exist yet id: notes if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} run: | - if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then - echo "generate=false" >> "$GITHUB_OUTPUT" + 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" else - echo "generate=true" >> "$GITHUB_OUTPUT" - fi + echo "$out" >&2 + exit 1 + fi >> "$GITHUB_OUTPUT" - uses: softprops/action-gh-release@v2 # 排练到上面为止 From 89eaab0dd676d5d4c27eb22b88ed80178e41237e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:51:46 +0800 Subject: [PATCH 5/5] docs: what release.yml checks and which crates the app pins; --help from the crate description CONTRIBUTING said release.yml checks that every binary runs and reports the version on the tag. The Windows arm64 binary is cross-compiled on an x64 runner and cannot run there, so only its PE machine field is checked. The step now says that, and lists the glibc 2.35 ceiling the Linux binaries are held to. The desktop app also pins tw-link, which carries the control-channel handshake. `twcore --help` still described only the local engine behind ThinkWatch Lite. Its first line now comes from the crate description, and the description is worded to read well in both places: "Self-contained AI gateway: the local engine behind ThinkWatch Lite, or a standalone gateway on a server". Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 22 +++++++++++++--------- bin/twcore/Cargo.toml | 3 ++- bin/twcore/src/main.rs | 7 ++----- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c25f937..99e9674d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,11 +157,15 @@ whatever sat in a `target/` directory that afternoon. 1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. -3. `release.yml` builds `twcore` for every target below and checks each - binary actually runs and reports the version on the tag. Once every - target has built, a single job attaches them all to a GitHub Release, - each with a `.sha256` (` `); if one target fails, nothing - is published. +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 + it, that it starts and reports the version on the tag. The Windows + arm64 binary is cross-compiled on an x64 runner, so only the machine + field in its PE header is checked. The Linux binaries are also checked + to need glibc 2.35 at most (Ubuntu 22.04). Once every target has + built, a single job attaches them all to a GitHub Release, each with a + `.sha256` (` `); if one target fails, nothing is + published. | Target | Files | |---|---| @@ -180,10 +184,10 @@ your branch (`gh workflow run release.yml --ref `): it builds and checks everything, leaves the files as the run's artifacts, and publishes nothing. -The desktop app pins `tw-api` (and the few other crates it uses: `tw-types`, -`tw-yaml`, `tw-guard`, `tw-watch`) to the same tag and bundles the binary -from that release. Those two have to come from one commit: the binary -speaks a protocol, and the app compiles a mirror of it. +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 +and bundles the binary from that release. Those two have to come from one +commit: the binary speaks a protocol, and the app compiles a mirror of it. On macOS, Apple Silicon only, deliberately. An Intel user downloading a file that will not open is worse served than one who finds no download diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index ea4eea68..3d2d9e65 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -8,7 +8,8 @@ repository.workspace = true homepage.workspace = true documentation.workspace = true readme.workspace = true -description = "Self-contained AI gateway binary: the local gateway behind ThinkWatch Lite, or a standalone gateway on a server" +# `twcore --help` 的第一行也是它(main.rs 的 `about`) +description = "Self-contained AI gateway: the local engine behind ThinkWatch Lite, or a standalone gateway on a server" keywords.workspace = true categories.workspace = true diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index b6b39026..303d71d5 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -17,11 +17,8 @@ mod upgrade; use lockfile::{LockFile, LockOutcome}; #[derive(Parser)] -#[command( - name = "twcore", - version, - about = "The local AI gateway engine behind ThinkWatch Lite" -)] +// `about` 不写值就是 Cargo.toml 里的 description:一句话只写一处,两边不会各说各的 +#[command(name = "twcore", version, about)] struct Cli { /// Path to the configuration file; ~/.thinkwatch/config.yaml by default #[arg(long, global = true)]