From 8361cf4539c24c4ba4d8fb38e418b3f427538440 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Sun, 4 Oct 2026 07:03:57 +0800 Subject: [PATCH] fix: verify trimpath release binaries correctly --- RELEASING.md | 8 +++- scripts/release-rules.mts | 24 ++++++++++ scripts/release-rules.test.mts | 19 ++++++++ scripts/release.mts | 86 +++++++++++++++++++++++++--------- 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index edab954..0496d4b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -81,7 +81,9 @@ registry、模板和技能。原 `pre-push` 与测试流水线已经删除,当 文件名是安装脚本及升级器的兼容协议,保持原样。Unix 归档内的程序名为 `one`, Windows 为 `one.exe`;每个归档包含 README 和 `third_party/mise/LICENSE`。 自动验证全部归档的校验值及 Go 构建信息,并运行本机平台的 `--version`、 -中英文帮助、语言切换和模板列表。语言检查使用临时 XDG 目录,保护开发者设置。 +中英文帮助、语言切换和模板列表。本机程序通过真实升级 worker 确认正式发布通道, +并用模拟 npx 记录工作区创建时生成的技能安装命令,确认技能来源提交。冒烟检查 +在仓库外的临时目录执行,使用临时 XDG 目录,保护开发者设置。 ### 发布及失败恢复 @@ -162,7 +164,9 @@ above and `checksums.txt` containing their SHA256 hashes. Archive names are the installer/updater compatibility contract. Every archive contains `one` (`one.exe` on Windows), README, and the mise license. Check all hashes and Go build settings; run the host binary's version, both help languages, locale switching, and template -list with temporary XDG directories. +list. Check the release channel through the real upgrade worker and record the +skill-install command with a simulated npx to verify its source commit. Smoke +checks use a temporary directory outside the workspace and temporary XDG directories. ### Publishing and recovery diff --git a/scripts/release-rules.mts b/scripts/release-rules.mts index 5d9c91a..8babc61 100644 --- a/scripts/release-rules.mts +++ b/scripts/release-rules.mts @@ -133,3 +133,27 @@ export function parseChecksums(text: string): Map { assertAssets(["checksums.txt", ...checksums.keys()]); return checksums; } + +export function assertBuildSettings(info: string, sha: string, os: string, architecture: string) { + const settings = new Map( + info.split("\n").flatMap((line) => { + const match = /^\s*build\s+([^=]+)=(.*)$/.exec(line); + return match ? [[match[1], match[2]] as [string, string]] : []; + }), + ); + // Go intentionally omits -ldflags from build info when -trimpath is used. + for (const [key, value] of Object.entries({ + "-trimpath": "true", + CGO_ENABLED: "0", + GOOS: os, + GOARCH: architecture, + "vcs.revision": sha, + "vcs.modified": "false", + })) { + if (settings.get(key) !== value) + fail( + `Unexpected build setting ${key}: expected ${value}.`, + `构建设置 ${key} 不正确,应为 ${value}。`, + ); + } +} diff --git a/scripts/release-rules.test.mts b/scripts/release-rules.test.mts index a2a4d6e..6e8db7d 100644 --- a/scripts/release-rules.test.mts +++ b/scripts/release-rules.test.mts @@ -3,6 +3,7 @@ import test from "node:test"; import { assets, assertAssets, + assertBuildSettings, bumpTag, compareTags, decidePlan, @@ -97,3 +98,21 @@ test("asset set and checksum manifest reject duplicates, extras, traversal and o assert.throws(() => parseChecksums(`${checksums}\n${checksums.split("\n")[0]}`), /duplicate/); assert.throws(() => parseChecksums(checksums.split("\n").slice(1).join("\n")), /Unexpected/); }); + +test("trimpath builds verify source/platform metadata without expecting linker flags", () => { + const info = + "\tbuild\t-trimpath=true\n\tbuild\tCGO_ENABLED=0\n\tbuild\tGOOS=darwin\n\tbuild\tGOARCH=arm64\n\tbuild\tvcs.revision=source-sha\n\tbuild\tvcs.modified=false\n"; + assertBuildSettings(info, "source-sha", "darwin", "arm64"); + assert.throws(() => assertBuildSettings(info, "other-sha", "darwin", "arm64"), /vcs.revision/); + assert.throws(() => assertBuildSettings(info, "source-sha", "linux", "arm64"), /GOOS/); + assert.throws( + () => + assertBuildSettings( + info.replace("CGO_ENABLED=0", "CGO_ENABLED=1"), + "source-sha", + "darwin", + "arm64", + ), + /CGO_ENABLED/, + ); +}); diff --git a/scripts/release.mts b/scripts/release.mts index 5025aab..352a967 100644 --- a/scripts/release.mts +++ b/scripts/release.mts @@ -9,12 +9,13 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { arch, platform } from "node:os"; +import { arch, platform, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { assets, assertAssets, + assertBuildSettings, compareTags, decidePlan, fail, @@ -181,9 +182,9 @@ function verifyArchives(build: Build) { } } -function smoke(build: Build) { +export function smoke(build: Build) { const p = build.plan; - const verify = mkdtempSync(join(dirname(build.source), "verify-")); + const verify = mkdtempSync(join(tmpdir(), "one-cli-release-smoke-")); const hostOS = platform() === "darwin" ? "darwin" : platform() === "linux" ? "linux" : ""; const hostArch = arch() === "x64" ? "amd64" : arch() === "arm64" ? "arm64" : ""; if (!hostOS || !hostArch) @@ -210,18 +211,8 @@ function smoke(build: Build) { ); } const info = read("mise", ["exec", "--", "go", "version", "-m", exe], { cwd: build.source }); - for (const value of [ - `main.version=${p.version}`, - "updatecheck.buildChannel=release", - `skills.bundledSourceRef=${p.sourceSha}`, - "CGO_ENABLED=0", - ]) { - if (!info.includes(value)) - fail( - `Archive ${name} lacks build setting ${value}.`, - `归档 ${name} 缺少构建设置 ${value}。`, - ); - } + const target = /^one-cli_(darwin|linux|windows)_(amd64|arm64)\./.exec(name)!; + assertBuildSettings(info, p.sourceSha, target[1], target[2]); if (name === `one-cli_${hostOS}_${hostArch}.tar.gz`) binary = exe; } const config = join(verify, "config"); @@ -235,15 +226,68 @@ function smoke(build: Build) { }; if (read(binary, ["--version"], { cwd: verify, env }) !== p.version) fail("Packaged CLI version is incorrect.", "发布包中的 CLI 版本不正确。"); - for (const [locale, helpText] of [ - ["zh-CN", "创建工作区"], - ["en-US", "Create a workspace"], - ]) { - read(binary, ["locale", locale, "-o", "json"], { cwd: verify, env }); - if (!read(binary, ["--help"], { cwd: verify, env }).includes(helpText)) + for (const locale of ["zh-CN", "en-US"]) { + const switched = JSON.parse( + read(binary, ["locale", locale, "-o", "json"], { cwd: verify, env }), + ); + const dictionary = JSON.parse( + readFileSync( + join(build.source, "packages/cli/internal/platform/i18n/locales", `${locale}.json`), + "utf8", + ), + ); + if ( + switched.resolved !== locale || + read(binary, ["--help"], { cwd: verify, env }) !== dictionary["root.help"].trim() + ) fail(`CLI help did not switch to ${locale}.`, `CLI 帮助未切换到 ${locale}。`); JSON.parse(read(binary, ["templates", "-o", "json"], { cwd: verify, env })); } + // Validate the release channel through the real manual-upgrade worker. + // The worker and any replacement stay inside this temporary extraction. + const upgrade = JSON.parse(read(binary, ["upgrade", "-o", "json"], { cwd: verify, env })); + if ( + upgrade.schema !== "one-cli/upgrade/v1" || + upgrade.current_version !== p.version || + upgrade.status !== "current" + ) { + fail( + "The packaged CLI did not report a current release-channel installation.", + "发布包中的 CLI 未确认当前正式发布通道版本。", + ); + } + // Record the external skills command instead of installing third-party skills. + // This checks the source SHA actually injected into the host executable. + const bin = join(verify, "bin"); + mkdirSync(bin); + const calls = join(verify, "skills-calls.jsonl"); + writeFileSync( + join(bin, "npx"), + `#!/usr/bin/env node +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args) + "\\n"); +for (const name of args.slice(args.indexOf("--skill") + 1, args.indexOf("--agent"))) { + mkdirSync(".agents/skills/" + name, { recursive: true }); + writeFileSync(".agents/skills/" + name + "/SKILL.md", "---\\nname: " + name + "\\ndescription: Release smoke fixture\\n---\\n"); +} +`, + { mode: 0o755 }, + ); + read(binary, ["create", join(verify, "workspace"), "--yes", "-o", "json"], { + cwd: verify, + env: { ...env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + const source = `1cli-team/one-cli/packages/agent-skills#${p.sourceSha}`; + const commands = readFileSync(calls, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + if (!commands.some((args) => args.includes(source))) + fail( + "The packaged CLI did not use its exact skill-source commit.", + "发布包中的 CLI 未使用精确的技能来源提交。", + ); say( "All five archives, build metadata, versions, and bilingual CLI smoke checks passed.", "五个平台的归档、构建信息、版本及中英文 CLI 冒烟检查均通过。",