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
121 changes: 95 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 支线要靠
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }})
Expand Down Expand Up @@ -289,13 +287,84 @@ 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 就是
# 这样多出一份的)。所以只在它还不存在时生成
#
# **「不存在」只认 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 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 "$out" >&2
exit 1
fi >> "$GITHUB_OUTPUT"

- 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 }}
28 changes: 18 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -157,9 +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, checks each
binary actually runs and reports the version on the tag, and attaches
them to a GitHub Release, each with a `.sha256` (`<sha> <file>`).
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` (`<sha> <file>`); if one target fails, nothing is
published.

| Target | Files |
|---|---|
Expand All @@ -173,13 +179,15 @@ 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 <branch>`): 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
Expand Down
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 的版本。
Expand Down
8 changes: 7 additions & 1 deletion bin/twcore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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
# `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

[dependencies]
tw-config = { workspace = true }
Expand Down
7 changes: 2 additions & 5 deletions bin/twcore/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-breaker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 6 additions & 1 deletion crates/tw-control/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-dialect/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions crates/tw-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-guard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-link/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-observe/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-pricing/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-secret/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tw-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading