[feat] synchronize client and agent component versions

This commit is contained in:
dijunkun committed 2026-11-12 12:36:53 +08:00
1 parent 2a9faf1eb1
commit b1236534ac
28 files changed
+548 -37

No files matched your search

+4
View File
@@ -79,6 +79,10 @@ jobs:
# also exists when source_tag checks out an older Android release.
cache-dependency-path: .github/workflows/build-android.yml
- name: Check shared client version
if: hashFiles('scripts/version.py') != ''
run: python3 scripts/version.py check client
- name: Install native build tools
run: |
crossdesk_missing_packages=()
+5
View File
@@ -49,6 +49,11 @@ jobs:
with:
node-version: 22
package-manager-cache: false
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Check shared CLI and MCP version
run: python scripts/version.py check cli
# A runner label can receive compiler/SDK updates without changing its name.
- name: Set cache environment
id: runner-image
+4
View File
@@ -163,6 +163,10 @@ jobs:
cache: pip
cache-dependency-path: .github/workflows/build-ios.yml
- name: Check shared client version
if: hashFiles('scripts/version.py') != ''
run: python3 scripts/version.py check client
- name: Install Python build tools
shell: bash
run: |
+2
View File
@@ -14,6 +14,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- name: Check shared CLI and MCP version
run: python3 scripts/version.py check mcp
- uses: actions/setup-node@v6
with:
node-version: 22
+5 -2
View File
@@ -13,7 +13,7 @@ on:
workflow_dispatch:
inputs:
desktop_version:
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for 0.0.0; tags use their own version."
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for the source version; tags use their own version."
required: false
type: string
patch:
@@ -49,6 +49,9 @@ jobs:
version: ${{ steps.version.outputs.version }}
# Resolve the date once for every desktop build and the release job.
steps:
- uses: actions/checkout@v5
- name: Check shared client version
run: python3 scripts/version.py check client
- name: Resolve desktop version
id: version
shell: bash
@@ -57,7 +60,7 @@ jobs:
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-$(python3 scripts/version.py show desktop --field version)}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
+19
View File
@@ -0,0 +1,19 @@
name: Check component versions
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- run: python3 scripts/version.py check
+3 -1
View File
@@ -31,7 +31,9 @@ jobs:
id: release
env:
RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
run: node apps/cli/scripts/release-npm.cjs plan
run: |
python3 scripts/version.py check cli
node apps/cli/scripts/release-npm.cjs plan
build:
needs: prepare
+1
View File
@@ -347,6 +347,7 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [Agent / MCP 用户指南](apps/mcp/README.md):下载安装、Codex / DSH 配置、连接授权、日常使用与常见问题。
- [MCP 开发参考](apps/mcp/DEVELOPMENT.md):源码构建、宿主集成、工具接口、打包与验证范围。
- [源码构建](docs/BUILD.md):桌面端依赖、编译参数与打包;移动端请使用上方各平台的构建指南。
- [组件版本管理](docs/VERSIONING.md):通过统一脚本查看、设置、递增各组件版本,以及同步构建号和锁文件。
- [GUI 架构](docs/gui-architecture.md):桌面界面、平台实现与工程结构。
- [连接模式架构](docs/connection-architecture.md):ID 连接与 IP 直连的寻址、认证、协商、数据路径及生命周期。
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
+1
View File
@@ -347,6 +347,7 @@ Host your own signaling and TURN services, then enable them under **Settings →
- [MCP developer reference](apps/mcp/DEVELOPMENT.md) (Chinese): source builds, host integration, tool interfaces, packaging, and validation scope.
- [Terminal client CLI](apps/cli/README.md) (Chinese): independent builds, terminal usage, Pi extension, bridge protocol, and npm packaging.
- [Build from source](docs/BUILD_EN.md): desktop dependencies, build options, and packaging. Use the platform guides above for mobile builds.
- [Component versions](docs/VERSIONING.md) (Chinese): inspect, set, and increment component versions, with synchronized build numbers and lockfiles.
- [GUI architecture](docs/gui-architecture.md) (Chinese): desktop UI, platform implementation, and project structure.
- [Connection architecture](docs/connection-architecture.md) (Chinese): discovery, authentication, negotiation, data paths, and lifecycle for ID and direct IP modes.
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
+4 -4
View File
@@ -1,6 +1,6 @@
# CrossDesk Android
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。当前版本为 1.6.1,支持 Android 8.0(API 26)及以上的 arm64 手机和平板。
原生 Android 控制端,使用 Java 界面、JNI 和 MiniRTC,与桌面端使用相同的信令、ICE、SRTP 和输入协议。没有 WebView。支持 Android 8.0(API 26)及以上的 arm64 手机和平板。桌面、Android、iOS 和 HarmonyOS 共用客户端发布版本,各移动平台构建号独立;可在仓库根目录执行 `python3 scripts/version.py show client` 查看,使用 `set client <版本>` 同步修改,详见[组件版本管理](../../docs/VERSIONING.md)。
## 当前功能
@@ -85,11 +85,11 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先更新并提交 `gradle.properties` 中的 `crossdeskVersionName` 和递增的 `crossdeskVersionCode`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先在仓库根目录运行 `python3 scripts/version.py set client <版本>` 同步全部客户端版本及各移动端构建号,检查并提交改动,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
```sh
git tag android-v1.6.1-20261010
git push origin android-v1.6.1-20261010
git tag android-v1.7.6-20261112
git push origin android-v1.7.6-20261112
```
该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
+2 -2
View File
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
crossdeskVersionName=1.6.1
crossdeskVersionCode=3
crossdeskVersionName=1.7.6
crossdeskVersionCode=4
+3 -3
View File
@@ -100,11 +100,11 @@ Pi 等宿主应直接启动原生程序以可靠控制进程和专用密码描
| 推送 `cli-v0.2.0-rc.1` | 校验源码版本为 `0.2.0-rc.1`,构建并发布到 `next` |
| 在对应 `cli-v*` 标签上手动运行并取消 `dry_run` | 执行真实发布;分支上不允许关闭预演 |
准备下一版时同步修改 `apps/cli/package.json` 的 `version` 与 `apps/cli/xmake.lua` 的 `set_version`,提交并推送代码后创建同版本标签。例如源码已更新为 `0.1.1` 时:
准备下一版时,在仓库根目录运行 `python3 scripts/version.py set cli <版本>`,同步 CLI、MCP、DSH 插件及 MCP 的原生依赖版本,再执行 `python3 scripts/version.py check cli`。`set mcp <版本>` 的修改范围相同,详见[组件版本管理](../../../docs/VERSIONING.md)。先发布 CLI 的平台包和入口包,再发布同版本 MCP。提交并推送代码后创建同版本标签。例如源码已更新为 `0.2.1` 时:
```sh
git tag cli-v0.1.1
git push origin cli-v0.1.1
git tag cli-v0.2.1
git push origin cli-v0.2.1
```
已经上传过 `0.1.0` 的包不能被新构建覆盖;下一次原生程序发布应使用新版本。手动预演使用 `npm pack --dry-run` 加上内容校验,不调用 `npm publish` 或交换 OIDC 凭据;可对分支运行,也允许检查已发布的版本,但不验证 npm 发布权限。标签运行始终校验版本一致性。GitHub 的手动运行入口需要该工作流先进入默认分支。
+1 -1
View File
@@ -5,7 +5,7 @@ function setup_options_and_dependencies(enable_dependencies)
option_end()
option("CROSSDESK_VERSION")
set_default("0.0.0")
set_default("1.7.6")
set_showmenu(true)
set_description("Set CROSSDESK_VERSION for build")
option_end()
+2 -2
View File
@@ -2,8 +2,8 @@
"app": {
"bundleName": "com.chunkdash.crossdesk",
"vendor": "CrossDesk",
"versionCode": 10000,
"versionName": "0.1.0",
"versionCode": 10001,
"versionName": "1.7.6",
"icon": "$media:app_icon",
"label": "$string:app_name"
}
+7 -4
View File
@@ -204,13 +204,16 @@ The main workflow collects this same ZIP for GitHub Releases and the download
server. Standalone `Build iOS` runs only upload the build artifact.
For an independent release, use the
[`Release Mobile`](../../.github/workflows/release-mobile.yml) workflow. Commit
the intended `MARKETING_VERSION` in the iOS target, then tag that commit with
[`Release Mobile`](../../.github/workflows/release-mobile.yml) workflow. Desktop,
Android, iOS and HarmonyOS share the same client release version while mobile
build numbers remain independent. From the repository root, run
`python3 scripts/version.py set client <version>` to update all clients,
then commit the changes and tag that commit with
`ios-v<marketing-version>-YYYYMMDD`, for example:
```sh
git tag ios-v0.0.1-20261002
git push origin ios-v0.0.1-20261002
git tag ios-v1.7.6-20261112
git push origin ios-v1.7.6-20261112
```
The tag starts only the iOS build and publishes its unsigned ZIP to a dedicated
+2 -2
View File
@@ -6,7 +6,7 @@
## 版本与依赖
MCP 包 `@chunkdash/crossdesk-mcp` 0.1.0 绑定原生 CLI 0.2.0,内置 DSH 插件随 MCP 同包分发。源码版本和本地打包不代表版本已经发布到 npm;内含原生程序的平台安装包可独立分发。
CLI、MCP 包 `@chunkdash/crossdesk-mcp` 和内置 DSH 插件共用版本,当前均为 0.2.0,MCP 原生依赖也固定为 0.2.0。使用[版本脚本](../../docs/VERSIONING.md)修改任一工具版本会同步整个分组。源码版本和本地打包不代表版本已经发布到 npm;内含原生程序的平台安装包可独立分发。
当前提供 31 个模型工具和 11 个 MCP Apps 面板专用接口。本机 MCP 接入使用 stdio,不提供 MCP Streamable HTTP 或通用远程 shell 工具。控制端原生 CLI、被控端和信令服务器需要支持对应自动化协议。
@@ -497,7 +497,7 @@ npm run pack:npm -- --native-package-dir "$native_package_dir/package" --out-dir
rm -rf "$native_package_dir"
```
平台包保留原生包的程序、源码、许可和构建信息,设置对应 OS / CPU / libc 限制,不再下载原生平台依赖;其他 JavaScript 依赖仍由包管理器安装。CLI 构建工作流为每个平台生成 `crossdesk-mcp-<平台>` 产物;平台安装包要求实际传入的原生包与 MCP 的依赖版本一致。通用包和平台包名称、版本相同,应放在不同输出目录,不要将平台包当成通用版本发布到 npm。
平台包保留原生包的程序、源码、许可和构建信息,设置对应 OS / CPU / libc 限制,不再下载原生平台依赖;其他 JavaScript 依赖仍由包管理器安装。CLI 构建工作流为每个平台生成 `crossdesk-mcp-<平台>` 产物,CLI、MCP、DSH 和原生依赖版本不一致时直接停止构建或打包。通用包和平台包名称、版本相同,应放在不同输出目录,不要将平台包当成通用版本发布到 npm。
原生 `doctor` 必须同时声明自动化协议和 `automation_video_protocols: ["h264-annexb/1", "nv12/1"]`、`automation_video_decoders: ["auto", "software"]`,以及 `automation_video_transport: "loopback-websocket/1"`、`automation_terminal_protocol: "ansi/1"`。通用 npm 分发需要先发布对应 0.2.0 平台包,再发布 MCP;内含原生程序的平台包不受此顺序限制。构建通过不等于各宿主和设备上的联调完成。
+6 -6
View File
@@ -31,7 +31,7 @@ DSH 和 Codex 都使用下表中的**平台安装包**,其中已包含所需
1. 登录 GitHub,打开 [CrossDesk 构建页面](https://github.com/kunkundi/crossdesk/actions/workflows/build-cli.yml)。
2. 选择一次成功的运行,在页面的 **Artifacts** 区域下载与你的本机对应的文件。
3. 解压下载的 `.zip`,取出里面的 `.tgz` 安装包,例如 `chunkdash-crossdesk-mcp-0.1.0.tgz`。保留 `.tgz`,安装时直接使用它。
3. 解压下载的 `.zip`,取出里面的 `.tgz` 安装包,例如 `chunkdash-crossdesk-mcp-0.2.0.tgz`。保留 `.tgz`,安装时直接使用它。
GitHub 要求登录后才能下载构建产物,操作说明见 [下载构建产物](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts)。
@@ -47,7 +47,7 @@ Mac 可在“关于本机”中查看芯片;Windows 可在“设置 → 系统
不同平台解压后的 `.tgz` 文件名相同,请以下载时的产物名称区分。如果某次运行没有表中对应的产物,选择另一次提供该产物的运行;也可以按 [源码构建说明](DEVELOPMENT.md#从源码运行) 自行构建。
下文以 `0.1.0` 为例。**所有 `/absolute/path/...` 都需要换成自己电脑上的完整路径**,版本号也应与实际下载的文件一致。路径中有空格时保留引号。
下文以 `0.2.0` 为例。**所有 `/absolute/path/...` 都需要换成自己电脑上的完整路径**,版本号也应与实际下载的文件一致。路径中有空格时保留引号。
<a id="install-dsh"></a>
@@ -58,7 +58,7 @@ Mac 可在“关于本机”中查看芯片;Windows 可在“设置 → 系统
在 Harness 的插件页面选择本地 `.tgz` 安装包,或在终端执行:
```sh
dsh plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.1.0.tgz"
dsh plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.2.0.tgz"
```
这个插件包会自动提供 MCP 工具和侧栏画面,无需另外安装 Node.js、原生 CLI 或填写 MCP 启动配置。
@@ -66,7 +66,7 @@ dsh plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.1.0.t
如果 macOS 终端提示找不到 `dsh`,可使用应用自带的命令:
```sh
"/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh" plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.1.0.tgz"
"/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh" plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.2.0.tgz"
```
上述命令对应默认安装位置和 `desktop` 配置。使用自定义安装位置或 profile 时替换为自己的值。
@@ -93,7 +93,7 @@ macOS / Linux:
mkdir -p "$HOME/crossdesk-mcp"
cd "$HOME/crossdesk-mcp"
npm init -y
npm install "/absolute/path/chunkdash-crossdesk-mcp-0.1.0.tgz"
npm install "/absolute/path/chunkdash-crossdesk-mcp-0.2.0.tgz"
```
Windows PowerShell:
@@ -102,7 +102,7 @@ Windows PowerShell:
New-Item -ItemType Directory -Force "$env:USERPROFILE/crossdesk-mcp"
Set-Location "$env:USERPROFILE/crossdesk-mcp"
npm init -y
npm install "C:/Downloads/chunkdash-crossdesk-mcp-0.1.0.tgz"
npm install "C:/Downloads/chunkdash-crossdesk-mcp-0.2.0.tgz"
```
安装完成后,在同一目录运行:
@@ -8,7 +8,7 @@
2. 在 Harness 桌面版的插件页面安装该文件,或执行以下命令。将示例中的路径和版本号替换为实际文件:
```sh
dsh plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.1.0.tgz"
dsh plugin --profile desktop add "/absolute/path/chunkdash-crossdesk-mcp-0.2.0.tgz"
```
3. 重启 Harness,确认 CrossDesk 插件已启用。
@@ -1,6 +1,6 @@
{
"name": "@chunkdash/dsh-crossdesk-sidebar",
"version": "0.1.0",
"version": "0.2.0",
"description": "Open CrossDesk remote desktop panels in DeepSeek Harness Desktop's sidebar",
"type": "module",
"main": "dist/host.js",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-only",
"dependencies": {
"@modelcontextprotocol/client": "2.4.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "Local CrossDesk MCP tools and remote desktop panels",
+8 -3
View File
@@ -23,12 +23,17 @@ const { values } = parseArgs({ options: {
const output = resolve(values["out-dir"] ?? join(repository, "build/npm-mcp"));
const project = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
const cli = JSON.parse(await readFile(join(root, "../cli/package.json"), "utf8"));
const harness = JSON.parse(await readFile(join(root, "integrations/deepseek-harness/package.json"), "utf8"));
const platforms = JSON.parse(
await readFile(join(root, "../cli/npm/platforms.json"), "utf8"),
);
const native = await import(new URL("../dist/native.js", import.meta.url));
if (native.NATIVE_VERSION !== cli.version)
throw new Error("MCP and CLI native versions must match.");
const nativeVersion = (await readFile(join(root, "src/native.ts"), "utf8"))
.match(/export const NATIVE_VERSION = "([^"]+)";/)?.[1];
if (project.version !== cli.version || project.version !== harness.version || project.version !== nativeVersion)
throw new Error("CLI, MCP, DSH and NATIVE_VERSION must match. Run python3 scripts/version.py set mcp VERSION from the repository root.");
if (!nativeVersion || native.NATIVE_VERSION !== nativeVersion)
throw new Error("Built MCP native version differs from source. Run npm run build first.");
for (const file of ["main.js", "viewer-browser.html", "viewer-apps.html", "harness.js", "harness-client.js"])
await access(join(root, "dist", file));
const staging = await mkdtemp(join(tmpdir(), "crossdesk-mcp-pack-"));
@@ -72,7 +77,7 @@ try {
"LICENSE",
],
optionalDependencies: Object.fromEntries(
Object.values(platforms).map((p) => [p.packageName, cli.version]),
Object.values(platforms).map((p) => [p.packageName, nativeVersion]),
),
publishConfig: { access: "public" },
};
+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { VERSION } from "./version.js";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import {
@@ -149,7 +150,7 @@ async function main() {
},
});
if (values.version) {
process.stdout.write("0.1.0\n");
process.stdout.write(`${VERSION}\n`);
return;
}
if (values.help || !positionals.length) {
+2 -1
View File
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { VERSION } from "./version.js";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/server";
import type {
@@ -80,7 +81,7 @@ export function createServer(
terminal: TerminalPanel,
): McpServer {
const server = new McpServer(
{ name: "crossdesk-mcp", version: "0.1.0" },
{ name: "crossdesk-mcp", version: VERSION },
{
capabilities: { tools: {}, resources: {} },
instructions:
+5
View File
@@ -0,0 +1,5 @@
import { readFileSync } from "node:fs";
export const VERSION: string = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
).version;
+2
View File
@@ -2,6 +2,8 @@
[返回 README](../README.md) · [English](BUILD_EN.md)
各组件版本可通过 `python3 scripts/version.py show` 查看,使用 `set` 或 `bump` 修改;同步范围和发布衔接见[组件版本管理](VERSIONING.md)。
安装版的升级器随主程序构建。发布公钥、清单签名和验证方法见
[桌面端应用内升级](UPDATER.md);便携版不包含升级器。
+90
View File
@@ -0,0 +1,90 @@
# 组件版本管理
在仓库根目录使用 `python3 scripts/version.py` 查看、修改和检查版本,需要 Python 3.9 或更新版本,无需安装第三方依赖。版本分为三组:桌面、Android、iOS 和 HarmonyOS 共用客户端版本;CLI、MCP 和 DSH 插件共用工具版本;Pi 扩展独立编号。脚本只修改源码配置,不会创建 Git 标签、提交代码、构建或发布安装包。
## 查看和检查
```sh
python3 scripts/version.py show
python3 scripts/version.py show client
python3 scripts/version.py show mcp
python3 scripts/version.py show --json
python3 scripts/version.py show desktop --field version
python3 scripts/version.py check
```
`check` 校验版本格式、移动端构建号、重复字段和分组版本一致性。发现不一致时返回非零退出码并指出组件;用 `set client <版本>` 统一客户端,用 `set cli <版本>` 或 `set mcp <版本>` 统一 CLI、MCP、DSH 及原生依赖。单独检查任一客户端都会检查全部客户端;`check cli` 和 `check mcp` 也都会检查整个工具分组。
`Check component versions` GitHub Action 在 push、PR 和手动运行时执行全部检查。各构建及 npm 发布工作流也会检查对应分组,不一致时停止。版本脚本不查询 npm 或应用商店;正式发布仍使用对应工作流的发布前检查。
## 可管理的组件
| 组件参数 | 版本来源及同步范围 |
| --- | --- |
| `client` | 统一管理所有客户端版本,分别保留 Android、iOS、HarmonyOS 的构建号;`mobile` 是同一分组的别名,也会同步桌面版本 |
| `desktop` | 修改版本时同步全部客户端;版本写入 `apps/desktop/xmake/options.lua` 的 `CROSSDESK_VERSION` 默认值,供 Windows、macOS、Linux 共用 |
| `android` | 修改版本时同步全部客户端;`--build` 和 `bump android build` 只指定 Gradle 的 `crossdeskVersionCode` |
| `ios` | 修改版本时同步全部客户端;`--build` 和 `bump ios build` 只指定 `CURRENT_PROJECT_VERSION`,同步 Debug 和 Release |
| `harmony` | 修改版本时同步全部客户端;`--build` 和 `bump harmony build` 只指定 `AppScope/app.json5` 的 `versionCode` |
| `cli` | 同步 CLI、MCP、DSH 和 MCP 的 CLI 依赖;CLI 写入 `apps/cli/package.json` 与 `apps/cli/xmake.lua`,五个平台原生 npm 包及入口包在打包时继承此版本 |
| `mcp` | 与 `cli` 修改范围相同;MCP 写入 `apps/mcp/package.json`、锁文件中的根版本与根包版本、DSH 集成目录的包版本,以及 `src/native.ts` 的 `NATIVE_VERSION` |
| `pi` | `apps/cli/plugins/pi/package.json` 的扩展版本 |
npm 组件若有 `package-lock.json`,脚本会同步其中的根版本和 `packages[""].version`,不会改动依赖包版本。DSH 与 MCP 一起分发,不需要独立设置 DSH 发布版本。HarmonyOS 的模块描述、SDK `modelVersion` 和本地类型库版本不属于应用发布版本,保持各自的用途。
## 设置版本
以下均为命令示例,替换为准备发布的版本:
```sh
python3 scripts/version.py set client 1.7.7 --android-build 5 --ios-build 7 --harmony-build 10002
python3 scripts/version.py set cli 0.3.0
python3 scripts/version.py set mcp 0.3.0
python3 scripts/version.py set pi 0.1.1
```
所有修改命令都支持 `--dry-run`,先显示将要修改的文件差异:
```sh
python3 scripts/version.py set mcp 0.3.0 --dry-run
```
版本参数填写 `X.Y.Z`,不包含标签前缀 `v`、日期或构建元数据。CLI、MCP、Pi 另支持 `X.Y.Z-rc.N` 等预发布版本。客户端共用版本各段限制在 `0–65535`,以适配 Windows 版本资源。
`set client` 可分别传入 `--android-build`、`--ios-build`、`--harmony-build`,不接受含义不明确的 `--build`。`set android 1.7.7 --build 5` 同样会将全部客户端发布版本设为 `1.7.7`,但 `--build 5` 只指定 Android 构建号,其余移动端按各自的构建号递增。`set ios`、`set harmony` 对称处理;`set desktop` 同样同步全部客户端,但不接受 `--build`。
各端发布版本变化而未显式指定构建号时,该端构建号自动加一;相同版本且未指定构建号时不改动文件。例如从所有客户端 `1.7.6`、Android / iOS / HarmonyOS 构建号分别为 `4` / `6` / `10001` 执行 `set client 1.7.7`,结果为所有客户端 `1.7.7`、构建号 `5` / `7` / `10002`。修复版本差异时,仅版本实际变化的端自动增加构建号。显式构建号不得降低,修改应用版本时构建号必须增加。脚本统一使用正整数构建号:Android 最大 `2100000000`,iOS 最大 `9999`,HarmonyOS 最大 `2147483647`。
修改前会先验证全部目标字段,验证失败不写文件;写入保留文件格式和权限,并使用逐文件原子替换。发生写入错误时会尝试还原已经写入的文件,修改后仍应检查 `git diff`。
## 递增版本
```sh
python3 scripts/version.py bump cli patch
python3 scripts/version.py bump mcp minor
python3 scripts/version.py bump client patch
python3 scripts/version.py bump client major --dry-run
python3 scripts/version.py bump android build
```
`major`、`minor`、`patch` 递增对应数字,并清零较低位;例如 `1.2.3` 递增 `minor` 得到 `1.3.0`。`bump client patch` 和对任一客户端执行 `bump ... patch` 都会同步提升全部客户端发布版本,并分别增加移动端构建号。`bump cli` 和 `bump mcp` 都会同步 CLI、MCP、DSH 和 MCP 的原生依赖。分组内原本不一致时,先使用 `set` 明确统一到哪个版本。
`bump android build`、`bump ios build`、`bump harmony build` 只增加指定端的构建号,其余端保持不变;不提供 `bump client build` 或 `bump mobile build`。当前是预发布版本时使用 `set` 明确指定下一个版本,例如 `set mcp 0.2.0-rc.2` 或 `set mcp 0.2.0`。
## CLI、MCP 与 DSH 的共同版本
CLI、MCP、DSH 集成元数据和 MCP 的原生依赖版本必须完全相同,当前均为 `0.2.0`。执行 `set cli 0.3.0` 或 `set mcp 0.3.0` 会将它们一起设为 `0.3.0`,只需执行其中一条命令。预发布后缀也必须一致。不提供单独设置原生依赖版本的参数。
MCP 通用 npm 包从 `NATIVE_VERSION` 生成五个平台包的精确依赖;MCP 的 `--version` 和协议握手读取包版本。DSH 随 MCP 同包分发,不另发插件版本。
先通过 CLI 发布工作流发布同版本的五个平台原生包和 CLI 入口,再发布 MCP。即使只修改 MCP,也要为两者准备同一个新版本并按此顺序发布。MCP 平台安装包由 CLI 构建工作流一并生成,通用包使用 `npm run pack:npm` 打包。
CLI 构建工作流为每个平台附带生成 `crossdesk-mcp-<平台>` 安装包。源码版本不一致时,构建、MCP 打包和发布检查会报错;平台安装包还要求传入的原生包版本一致。工具版本不影响客户端版本和 Pi 扩展版本。
## 与构建和发布配合
客户端版本同时控制本地 Xmake 默认值,以及 GitHub Actions 分支构建未填写 `desktop_version` 时的默认版本。已有 Xmake 配置可能保留缓存,应重新配置;显式 `--CROSSDESK_VERSION`、Actions 的 `desktop_version` 和发布标签继续按原流程覆盖默认值。正式发布时应使标签的基础版本与客户端源码版本一致。日期和热修订后缀仍由发布流程处理,本脚本不修改既有标签格式。各端可分开构建和发布,但共用同一个源码发布版本。
修改并检查后,审阅、提交和推送源码,再按实际版本创建对应标签:CLI 使用 `cli-v<版本>`。移动端沿用 `android-v<版本>-YYYYMMDD`、`ios-v<版本>-YYYYMMDD`。已发布版本不能用不同内容覆盖;版本修改脚本不会替你判断某个版本是否已在注册表中使用。
相关说明:[桌面构建](BUILD.md) · [CLI 发布](../apps/cli/docs/npm-distribution.md#ci-与发布) · [MCP 打包](../apps/mcp/DEVELOPMENT.md#打包)。
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""Inspect and update CrossDesk component versions without building or publishing."""
import argparse
import difflib
import json
import os
from pathlib import Path
import re
import stat
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?")
COMPONENTS = ("desktop", "android", "ios", "harmony", "cli", "mcp", "pi")
MOBILE = ("android", "ios", "harmony")
CLIENTS = ("desktop", *MOBILE)
CLIENT_GROUPS = ("client", "mobile")
TOOLS = ("cli", "mcp")
COMPONENT_CHOICES = (*CLIENT_GROUPS, *COMPONENTS)
def validate_version(value, prerelease=False):
match = SEMVER.fullmatch(value)
if not match or (match[4] and not prerelease):
raise ValueError(f"Invalid version: {value!r}; use X.Y.Z" + (" or X.Y.Z-rc.N" if prerelease else ""))
if match[4] and any(part.isdigit() and len(part) > 1 and part[0] == "0" for part in match[4].split(".")):
raise ValueError(f"Invalid prerelease version: {value!r}; numeric identifiers cannot have leading zeros")
return match
def json_span(text, keys, start=0):
"""Find a JSON scalar by object path, preserving all unrelated formatting."""
decoder = json.JSONDecoder()
def skip(index):
while index < len(text) and text[index].isspace():
index += 1
return index
index = skip(start)
if text[index:index + 1] != "{":
raise ValueError(f"Expected a JSON object at {keys!r}")
index = skip(index + 1)
matches = []
while text[index:index + 1] != "}":
key, index = decoder.raw_decode(text, index)
index = skip(index)
if text[index:index + 1] != ":":
raise ValueError("Expected ':' in JSON object")
begin = skip(index + 1)
value, end = decoder.raw_decode(text, begin)
if key == keys[0]:
matches.append(json_span(text, keys[1:], begin) if len(keys) > 1 else (begin, end, value))
index = skip(end)
if text[index:index + 1] == "}":
break
if text[index:index + 1] != ",":
raise ValueError("Expected ',' in JSON object")
index = skip(index + 1)
if len(matches) != 1:
raise ValueError(f"Expected one JSON field at {'.'.join(keys)!r}, found {len(matches)}")
return matches[0]
class Field:
def __init__(self, file, pattern=None, keys=None, multiple=False):
self.file, self.pattern, self.keys, self.multiple = file, pattern, keys, multiple
def spans(self, text):
if self.keys is not None:
json.loads(text)
return [json_span(text, self.keys)]
matches = list(re.finditer(self.pattern, text, re.MULTILINE))
if not matches or (not self.multiple and len(matches) != 1):
raise ValueError(f"{self.file}: expected {'at least ' if self.multiple else ''}one version field, found {len(matches)}")
return [(m.start('value'), m.end('value'), m['value']) for m in matches]
def values(self, snapshot):
values = [value for _, _, value in self.spans(snapshot.read(self.file))]
if any(not isinstance(value, str) for value in values):
raise ValueError(f"{self.file}: expected a string version")
return values
def update(self, snapshot, value):
text = snapshot.read(self.file)
for start, end, _ in reversed(self.spans(text)):
text = text[:start] + (json.dumps(value) if self.keys is not None else value) + text[end:]
snapshot.set(self.file, text)
def package_fields(snapshot, directory):
fields = [Field(f"{directory}/package.json", keys=("version",))]
if (snapshot.root / directory / "package-lock.json").exists():
fields.extend([Field(f"{directory}/package-lock.json", keys=("version",)),
Field(f"{directory}/package-lock.json", keys=("packages", "", "version"))])
return fields
def fields(snapshot, component):
if component in ("cli", "mcp", "pi"):
directory = "apps/cli/plugins/pi" if component == "pi" else f"apps/{component}"
result = {"version": package_fields(snapshot, directory)}
if component == "cli":
result["version"].append(Field("apps/cli/xmake.lua", r'set_version\("(?P<value>[^"\n]+)"\)'))
elif component == "mcp":
result["version"].append(Field("apps/mcp/integrations/deepseek-harness/package.json", keys=("version",)))
result["native_version"] = [Field("apps/mcp/src/native.ts", r'^export const NATIVE_VERSION = "(?P<value>[^"\n]+)";')]
return result
if component == "desktop":
return {"version": [Field("apps/desktop/xmake/options.lua", r'option\("CROSSDESK_VERSION"\)\s*set_default\("(?P<value>[^"\n]+)"\)')]}
if component == "android":
return {key: [Field("apps/android/gradle.properties", rf'^{prop}=(?P<value>[^\r\n]+)\r?$')]
for key, prop in (("version", "crossdeskVersionName"), ("build", "crossdeskVersionCode"))}
if component == "ios":
return {key: [Field("apps/ios/CrossDeskMobile.xcodeproj/project.pbxproj",
rf'^\s*{prop} = (?P<value>[^;\r\n]+);', multiple=True)]
for key, prop in (("version", "MARKETING_VERSION"), ("build", "CURRENT_PROJECT_VERSION"))}
if component == "harmony":
return {"version": [Field("apps/harmony/AppScope/app.json5", r'"versionName"\s*:\s*"(?P<value>[^"\n]+)"')],
"build": [Field("apps/harmony/AppScope/app.json5", r'"versionCode"\s*:\s*(?P<value>[0-9]+)(?=\s*[,}])')]}
raise ValueError(f"Unknown component: {component}")
def validate_build(component, value):
# Keep an integer build number for all three mobile applications.
maximum = {"android": 2100000000, "ios": 9999, "harmony": 2147483647}[component]
if not re.fullmatch(r"[1-9][0-9]*", value) or int(value) > maximum:
raise ValueError(f"{component}: build must be an integer from 1 to {maximum}")
class Snapshot:
def __init__(self, root=ROOT):
self.root = Path(root)
self.original = {}
self.current = {}
def read(self, file):
if file not in self.original:
text = (self.root / file).read_bytes().decode("utf-8")
self.original[file] = self.current[file] = text
return self.current[file]
def set(self, file, value):
self.read(file)
self.current[file] = value
def state(self, component, check=True):
if component in CLIENT_GROUPS:
clients = {platform: self.state(platform, check=check) for platform in CLIENTS}
if len({data["version"] for data in clients.values()}) != 1:
versions = ", ".join(f"{platform}={data['version']}" for platform, data in clients.items())
raise ValueError(f"Client versions differ: {versions}; use set client X.Y.Z to synchronize them")
return {"version": clients["desktop"]["version"],
**{f"{platform}_build": clients[platform]["build"] for platform in MOBILE}}
result = {}
for key, group in fields(self, component).items():
values = [value for field in group for value in field.values(self)]
if check and len(set(values)) != 1:
raise ValueError(f"{component}.{key} is inconsistent: {', '.join(values)}; use set to synchronize it")
value = values[0]
if key == "build":
validate_build(component, value)
else:
validate_version(value, component in ("cli", "mcp", "pi"))
if component == "desktop" and any(int(part) > 65535 for part in value.split(".")):
raise ValueError("Desktop version components must fit Windows version resources (0–65535)")
result[key] = value
return result
def changes(self):
return {file: text for file, text in self.current.items() if text != self.original[file]}
def save(self, dry_run=False):
changes = self.changes()
for file, text in changes.items():
if dry_run:
print("".join(difflib.unified_diff(self.original[file].splitlines(keepends=True),
text.splitlines(keepends=True), fromfile=file, tofile=file)), end="")
if dry_run or not changes:
return
for file in changes:
if (self.root / file).read_bytes().decode("utf-8") != self.original[file]:
raise ValueError(f"{file} changed while preparing the update; retry")
written = []
try:
for file, text in changes.items():
atomic_write(self.root / file, text)
written.append(file)
except OSError:
for file in reversed(written):
if (self.root / file).read_bytes().decode("utf-8") == changes[file]:
atomic_write(self.root / file, self.original[file])
raise
for file in changes:
print(f"Updated {file}")
def atomic_write(file, text):
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=file.parent, prefix=f".{file.name}.", delete=False) as stream:
temporary = Path(stream.name)
stream.write(text.encode("utf-8"))
os.chmod(temporary, stat.S_IMODE(file.stat().st_mode))
os.replace(temporary, file)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def check_tools(snapshot):
cli, mcp = (snapshot.state(component) for component in TOOLS)
if not (cli["version"] == mcp["version"] == mcp["native_version"]):
raise ValueError(f"CLI/MCP versions differ: cli={cli['version']}, mcp={mcp['version']}, "
f"mcp.native_version={mcp['native_version']}; use set cli X.Y.Z or set mcp X.Y.Z to synchronize them")
def update_component(snapshot, component, version, build=None):
previous = snapshot.state(component, check=False)
validate_version(version, component in ("cli", "mcp", "pi"))
if build is not None and component not in MOBILE:
raise ValueError("--build is only available for android, ios and harmony")
values = {"version": version}
if component in MOBILE:
build = build if build is not None else str(int(previous["build"]) + (version != previous["version"]))
validate_build(component, build)
if int(build) < int(previous["build"]) or (version != previous["version"] and build == previous["build"]):
raise ValueError(f"{component}: a changed app version requires a higher build number; build numbers cannot decrease")
values["build"] = build
if component == "mcp":
values["native_version"] = version
for key, value in values.items():
for field in fields(snapshot, component)[key]:
field.update(snapshot, value)
snapshot.state(component)
def update(snapshot, component, version, build=None,
android_build=None, ios_build=None, harmony_build=None):
if component in CLIENT_GROUPS or component in CLIENTS:
if build is not None and component not in MOBILE:
raise ValueError("--build requires android, ios or harmony; use platform-specific build options with client")
if component not in CLIENT_GROUPS and any(value is not None for value in (android_build, ios_build, harmony_build)):
raise ValueError("Use client with --android-build/--ios-build/--harmony-build, or --build for the selected platform")
builds = {"android": android_build, "ios": ios_build, "harmony": harmony_build}
if component in MOBILE:
builds[component] = build
for platform in CLIENTS:
update_component(snapshot, platform, version, builds.get(platform))
snapshot.state("client")
else:
if any(value is not None for value in (android_build, ios_build, harmony_build)):
raise ValueError("Platform-specific build options are only available for client")
if component in TOOLS:
for tool in TOOLS:
update_component(snapshot, tool, version, build)
check_tools(snapshot)
else:
update_component(snapshot, component, version, build)
def parser():
result = argparse.ArgumentParser(description=__doc__,
epilog="Desktop, Android, iOS and HarmonyOS share one client version; build numbers remain independent. mobile is an alias for client. CLI, MCP, DSH and MCP's native CLI dependency share another version; set/bump cli or mcp updates all of them.")
sub = result.add_subparsers(dest="command", required=True)
show = sub.add_parser("show", help="Show current versions")
show.add_argument("component", nargs="?", choices=COMPONENT_CHOICES)
show.add_argument("--json", action="store_true", help="Print machine-readable values")
show.add_argument("--field", choices=("version", "build", "native_version", "android_build", "ios_build", "harmony_build"), help="Print one field of one component")
check = sub.add_parser("check", help="Validate version formats and synchronized fields")
check.add_argument("component", nargs="?", choices=COMPONENT_CHOICES)
for name, description in (("set", "Set an exact version"), ("bump", "Increment a version or mobile build number")):
command = sub.add_parser(name, help=description)
command.add_argument("component", choices=COMPONENT_CHOICES)
if name == "set":
command.add_argument("version")
else:
command.add_argument("part", choices=("major", "minor", "patch", "build"))
command.add_argument("--build", help="Explicit mobile build number; app version changes otherwise increment it")
command.add_argument("--android-build", help="Android build number when setting/bumping client")
command.add_argument("--ios-build", help="iOS build number when setting/bumping client")
command.add_argument("--harmony-build", help="HarmonyOS build number when setting/bumping client")
command.add_argument("--dry-run", action="store_true", help="Preview the diff without writing files")
return result
def main(argv=None, root=ROOT):
args = parser().parse_args(argv)
snapshot = Snapshot(root)
if args.command in ("show", "check"):
selected = (args.component,) if args.component else COMPONENTS
values, errors = {}, []
for component in selected:
try:
values[component] = snapshot.state(component)
except (ValueError, OSError) as error:
errors.append(f"{component}: {error}")
if any(component in CLIENTS for component in selected) and not errors:
try:
snapshot.state("client")
except (ValueError, OSError) as error:
errors.append(str(error))
if any(component in TOOLS for component in selected) and not errors:
try:
check_tools(snapshot)
except (ValueError, OSError) as error:
errors.append(str(error))
if errors:
raise ValueError("\n".join(errors))
if args.command == "check":
print(f"Version checks passed: {', '.join(selected)}")
elif args.field:
if not args.component or args.json or args.field not in values[args.component]:
raise ValueError("--field requires one component with that field and cannot be combined with --json")
print(values[args.component][args.field])
elif args.json:
print(json.dumps(values[args.component] if args.component else values, indent=2))
else:
for component, data in values.items():
print(f"{component:8} " + " ".join(f"{key}={value}" for key, value in data.items()))
return
version, build = getattr(args, "version", None), args.build
if args.command == "bump":
if args.component in CLIENTS:
snapshot.state("client")
if args.component in TOOLS:
check_tools(snapshot)
current = snapshot.state(args.component)
version = current["version"]
if args.part == "build":
if args.component not in MOBILE or any(value is not None for value in (build, args.android_build, args.ios_build, args.harmony_build)):
raise ValueError("bump build requires android, ios or harmony and cannot be combined with explicit build numbers")
build = str(int(current["build"]) + 1)
else:
match = validate_version(version, prerelease=True)
if match[4]:
raise ValueError("Use set to choose the next version explicitly when the current version is a prerelease")
numbers = [int(match[index]) for index in range(1, 4)]
index = ("major", "minor", "patch").index(args.part)
numbers[index] += 1
numbers[index + 1:] = [0] * (2 - index)
version = ".".join(map(str, numbers))
build_only = args.command == "bump" and args.part == "build"
if build_only:
update_component(snapshot, args.component, version, build)
else:
update(snapshot, args.component, version, build,
args.android_build, args.ios_build, args.harmony_build)
snapshot.save(args.dry_run)
reported = "client" if (args.component in CLIENTS or args.component in CLIENT_GROUPS) and not build_only else args.component
label = "cli/mcp/dsh" if reported in TOOLS else reported
print(f"{'Preview' if args.dry_run else 'Current'} {label}: " + json.dumps(snapshot.state(reported)))
if __name__ == "__main__":
try:
main()
except (ValueError, OSError) as error:
print(f"version: {error}", file=sys.stderr)
sys.exit(1)