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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ jobs:
# dash),写出 bash 的语法就是装不上。runner 自带 shellcheck
- name: The install script is POSIX sh
run: shellcheck -s sh scripts/install.sh
# 发布页的正文只在推 tag 时才写(release.yml 的 `release-body`)。脚本和
# `release-notes/` 下的说明在改它们的那个 PR 上就核对:链接对不对得上
# 发出去的文件、说明里有没有混进中文
- name: Release page text
run: python3 scripts/release_notes_test.py
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
Expand Down
83 changes: 79 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
# 它们一起挂上去 —— 只发出一部分的话,latest.json 要么缺平台,要么指向
# 一个不存在的文件。
#
# 发布页的标题是「ThinkWatch Lite <版本>」,正文(英文)由
# `scripts/release_notes.py` 写:`release-notes/<版本>.md` 里的说明、下载表、
# 核对方法、上一版以来合并的 PR。见 `release-body` 这个 job。
#
# 这条流水线不重跑 `ci.yml` 那几道门:tag 是从 main 上打的,main 上
# 每个 commit 都过过。这里只做 CI 做不了的那件事 —— 打出安装包并且
# 在发出去之前拆开看一眼。
Expand Down Expand Up @@ -505,9 +509,54 @@ jobs:
dist/ThinkWatch-Lite-*.AppImage.sig
if-no-files-found: error

# 发布页上的正文,英文:这一版的说明(`release-notes/<版本>.md`,可以没有)、
# 各平台的下载表和安装命令、核对方法,最后是 GitHub 按上一版以来合并的 PR 生成
# 的清单。怎么拼见 `scripts/release_notes.py`。
#
# **单独一个 job,演练也跑。**它不等安装包:推一个 `rehearse/` 分支,一分钟内
# 就能在这次运行的摘要里看到发布页会是什么样。发布时 `publish` 取它的产物。
release-body:
name: Release page text
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: The tag is the version in the tree
run: |
set -euo pipefail
GOT=$(bash scripts/version.sh)
if [ "$GITHUB_REF_TYPE" = tag ]; then
WANT="${GITHUB_REF_NAME#v}"
if [ "$GOT" != "$WANT" ]; then
echo "tag 是 $WANT,而代码里写的是 $GOT" >&2
exit 1
fi
fi
echo "VERSION=$GOT" >> "$GITHUB_ENV"

# 清单向 GitHub 要:和它在发布页上「自动生成」的是同一份。演练时这个 tag
# 可能还不存在,那就算到这次的 commit 为止(tag 存在时 target_commitish 不起作用)
- name: Write it
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="v$VERSION" -f target_commitish="$GITHUB_SHA" \
--jq .body > "$RUNNER_TEMP/changes.md"
mkdir -p page
python3 scripts/release_notes.py "$VERSION" "$RUNNER_TEMP/changes.md" > page/release-body.md
cat page/release-body.md >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v4
with:
name: release-body
path: page/release-body.md
if-no-files-found: error

publish:
name: Publish
needs: [app, windows, linux]
needs: [app, windows, linux, release-body]
# 演练到上面为止
if: github.ref_type == 'tag'
runs-on: macos-latest
Expand All @@ -521,8 +570,9 @@ jobs:

- run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"

# tag 上那段话就是应用里显示的发布说明 —— 自动生成的提交清单在
# 那个位置对用户没有意义。
# tag 上那段话写进 latest.json,是给已经装上的应用的发布说明(2026.9.13
# 之前的版本在更新窗口里显示它)—— 自动生成的提交清单在那个位置对用户
# 没有意义。发布页上的正文是另一份,见 `release-body`。
#
# **不从工作区的 git 里读。**`actions/checkout` 取 tag 时建的是一个
# 直接指向 commit 的轻量引用,注解对象根本不在这个克隆里;
Expand All @@ -544,6 +594,8 @@ jobs:
fi
cat notes.txt

# 各平台的安装包,和 `release-body` 写好的正文(`dist/release-body.md`,
# 下面的 `files` 不挂它)
- uses: actions/download-artifact@v4
with:
path: dist
Expand All @@ -560,8 +612,32 @@ jobs:
"linux-x86_64-appimage=dist/ThinkWatch-Lite-$VERSION-x86_64.AppImage" \
"linux-aarch64-appimage=dist/ThinkWatch-Lite-$VERSION-aarch64.AppImage"

# **正文只在建 Release 时写一次。**Release 已经在了 —— 重跑这个 job(比如
# 挂文件挂到一半断了),或者有人先手工建好了 —— 就不动它的正文:那可能是
# 发布之后有人亲手改过的。
#
# 「不存在」只认 gh 查不到时的那句 `release not found`。网络断了、令牌不对,
# gh 一样失败;把那些也当成「不存在」,就会盖掉一份已有的正文
- name: The page text, only for a release that does not exist yet
id: page
env:
GH_TOKEN: ${{ github.token }}
run: |
if out=$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json tagName 2>&1); then
echo "Release $GITHUB_REF_NAME 已经存在,正文保持原样"
echo "body_path=" >> "$GITHUB_OUTPUT"
elif grep -qx 'release not found' <<<"$out"; then
echo "body_path=dist/release-body.md" >> "$GITHUB_OUTPUT"
else
echo "$out" >&2
exit 1
fi

- uses: softprops/action-gh-release@v2
with:
name: ThinkWatch Lite ${{ env.VERSION }}
# 空的话(Release 已经在了)这个 action 保留原有的正文
body_path: ${{ steps.page.outputs.body_path }}
# `.sig` 不挂:签名已经写进 latest.json,挂上去只是多几个让人
# 不知道该下哪个的文件。`install.sh` 挂上去是为了一个固定的地址
# (`releases/latest/download/install.sh`),它读的是同一版的 latest.json
Expand All @@ -574,7 +650,6 @@ jobs:
dist/ThinkWatch-Lite-*.AppImage.sha256
dist/latest.json
scripts/install.sh
generate_release_notes: true

# Homebrew 装的应用读的是 tap 里的 cask:**cask 没跟上之前,
# `brew upgrade` 什么也装不到**,那部分用户就一直停在上一版。所以发布
Expand Down
39 changes: 39 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ That flag is set by whatever downloaded the file. An update fetched by
the app itself never carries it, so this is a one-time step rather than
one per release.

## Releases

A release is an annotated tag `v<version>` on `main`, such as
`v2026.9.16`. The version is written in `package.json`,
`src-tauri/tauri.conf.json` and `src-tauri/Cargo.toml` (and in
`src-tauri/Cargo.lock`); `bash scripts/version.sh` checks that the three
agree, and `release.yml` refuses a tag that differs from them.

Pushing the tag runs `release.yml`. It builds the five installers, looks
inside each one, signs them for the updater and publishes them in one
step, together with their `.sha256` files, `latest.json` and
`install.sh`; if one platform fails, nothing is published. To try a
change to `release.yml`, push the branch as `rehearse/<name>`: every job
runs and the installers are kept as the run's artifacts, but nothing is
published.

A release has two texts:

- **The tag message.** Its first line is `ThinkWatch Lite <version>`; the
rest becomes the `notes` of `latest.json`, the manifest for in-app
updates. It is not shown on the release page.
- **The release page**, titled `ThinkWatch Lite <version>` and written in
English by `scripts/release_notes.py`, in this order:
1. `release-notes/<version>.md`, when that file exists: a summary of the
release in paragraphs or lists, without a top-level heading. Add it in
the pull request that bumps the version, because the tag fixes what
the tree contains.
2. A table of the file for each platform, and the Homebrew and Linux
install commands.
3. How to verify a download against its `.sha256`.
4. 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 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/`; a rehearsal shows the complete text in the summary of
its run.

## Product screenshots

The images in `docs/screenshots/` — used by the READMEs and, through
Expand Down
9 changes: 9 additions & 0 deletions release-notes/2026.9.16.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**Upgrade note:** the proxy configuration now rejects misspelled field names, such as `typ` written for `type`. Such fields used to be ignored; after the upgrade, a configuration that contains one does not pass validation and the gateway does not start. A proxy configuration that was edited by hand should be checked before upgrading.

**Remote connections:** the app can now connect to a core running on another machine, such as a Linux server. A remote connection is added in Settings → Connection with its address, port and key, and once the connection test succeeds the app can switch to it. Installing and configuring the server side is described in [docs/server.md](https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md) in the ThinkWatch-Core repository. The app connects to one core at a time: while it is connected to a remote core, the local core is stopped and its data is kept. When switching, the clients already connected on this computer can be pointed at the server in the same step.

**When a connection fails**, the interface remains usable. At launch the app waits up to about 8 seconds, then shows the Not connected page, where the connection can be retried, edited or switched to this computer. If the connection drops while the app is running, pages become read-only and a notice is shown once; it is removed automatically when the connection returns. The menu in the menu bar, or in the tray on Windows and Linux, has a new Connection submenu. When Option is held at launch (Alt on Windows), or after two launches in a row did not finish, the connection choice is shown first.

**In remote mode:** the Clients and MCP pages check and change the client configuration on this computer; Settings is divided into two groups, the app on this computer and the configuration on the server; ChatGPT accounts sign in with a device code only; the diagnostic bundle is not offered.

The control channel between the app and core now begins with an encrypted handshake, and local and remote connections use the same key, which is written in the configuration file. Connecting clients, MCP management and the client configuration scan are now carried out by the app itself.
157 changes: 157 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""写出一版在 GitHub Release 页面上的正文。

用法:release_notes.py <版本> <更新列表文件>

正文是英文的,依次是:

1. `release-notes/<版本>.md`:这一版的说明。**可以没有**,没有就从下载表开始。
2. 下载表:每个平台一行,文件名和 release.yml 挂上去的一字不差;再给 Homebrew
和 Linux 安装脚本的命令。
3. 怎样用 `.sha256` 核对下载的文件。
4. 第二个参数的内容:GitHub 按上一版以来合并的 PR 生成的「What's Changed」,由
调用方用 `gh api repos/<仓库>/releases/generate-notes` 取来。拆成参数传进来,
这个脚本就不碰网络,测试可以直接喂它。

**tag 的注解不在这里。**那段中文写进 latest.json(见 manifest.py),是给已经装上
的应用的;发布页面向从网页来下载的人。

说明文件写坏了(有中文、有一级标题、是空的)就不写正文 —— 发版流水线在这一步
停下,比发出去再改好。CI 在每个 PR 上把 `release-notes/` 下的每一份都过一遍
(release_notes_test.py),所以真到打 tag 时不该再撞上。
"""

from __future__ import annotations

import pathlib
import re
import sys

REPO = "ThinkWatchProject/ThinkWatch-Lite"
ROOT = pathlib.Path(__file__).resolve().parent.parent
NOTES_DIR = ROOT / "release-notes"

# 每个平台发的那一个文件。**和 release.yml 起的名字一字不差** ——
# release_notes_test.py 拿 release.yml 核对这张表,对不上的话发布页上的链接就是 404。
DOWNLOADS = [
("macOS, Apple silicon", "ThinkWatch-Lite-{v}-arm64.dmg"),
("Windows, x64", "ThinkWatch-Lite-{v}-x64-setup.exe"),
("Windows, ARM64", "ThinkWatch-Lite-{v}-arm64-setup.exe"),
("Linux, x86_64", "ThinkWatch-Lite-{v}-x86_64.AppImage"),
("Linux, aarch64", "ThinkWatch-Lite-{v}-aarch64.AppImage"),
]

BREW = "brew install --cask thinkwatchproject/tap/thinkwatch-lite"
INSTALL_SH = f"curl -fsSL https://github.com/{REPO}/releases/latest/download/install.sh | sh"

# CalVer:2026.9.16
VERSION = re.compile(r"\d{4}\.\d{1,2}\.\d+")
# 中日韩文字和全角标点(码位区间)。发布页写英文;中文的那段在 tag 上
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} 是空的:没有要说的就删掉这个文件")
for n, line in enumerate(text.splitlines(), 1):
if CJK.search(line):
raise NotesError(
f"{name} 第 {n} 行有中文:发布页写英文,中文的说明写在 tag 的注解里\n {line}"
)
# 发布页的标题由流水线定(ThinkWatch Lite <版本>),正文里再来一个一级标题就重了
if line.startswith("# "):
raise NotesError(f"{name} 第 {n} 行是一级标题:标题由流水线写,说明里从 ## 或正文开始\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}(要的是 2026.9.16 这样的)")
base = f"https://github.com/{REPO}/releases/download/v{version}"
files = [(platform, name.format(v=version)) for platform, name in DOWNLOADS]
dmg, x64 = files[0][1], files[1][1]
appimage = files[3][1]

parts = []
if summary_text:
parts.append(summary_text)

rows = "\n".join(f"| {platform} | [`{name}`]({base}/{name}) |" for platform, name in files)
parts.append(
f"""## Downloads

| Platform | File |
|---|---|
{rows}

Each file is published with a `.sha256` file beside it. `latest.json` is the manifest for in-app updates, and `install.sh` is the Linux install script.

On macOS, Homebrew installs the same disk image:

```sh
{BREW}
```

On Linux, the install script downloads the AppImage for the machine's architecture, checks its SHA-256 and installs it as `~/Applications/ThinkWatch-Lite.AppImage`:

```sh
{INSTALL_SH}
```

Both commands install the latest release. First-launch steps for each platform, such as removing the quarantine attribute from a copy downloaded on macOS, are described in the [README](https://github.com/{REPO}#install)."""
)

parts.append(
f"""## 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 macOS:

```sh
shasum -a 256 -c {dmg}.sha256
```

On Linux:

```sh
sha256sum -c {appimage}.sha256
```

On Windows, in PowerShell, the following prints `True` when the installer matches:

```powershell
(Get-FileHash .\\{x64}).Hash -eq (Get-Content .\\{x64}.sha256).Split()[0]
```

The Homebrew cask and the Linux install script 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("用法: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()
Loading
Loading