diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..a8dd129 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,87 @@ +# Local releases: mise run release -- build patch --notes +# See RELEASING.md. The release task prepares a clean source checkout, +# builds with this configuration, then uploads the verified archives to a draft. +# 本地发布流程见 RELEASING.md;版本来自 tag,不修改源码版本文件。 + +version: 2 + +project_name: one-cli + +builds: + - id: one + dir: packages/cli + main: ./cmd/one + binary: one + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + # No need for windows/arm64 yet — too few users. + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release + # Published releases restore One skills from this exact source commit. + # Local snapshots retain their embedded, unpublished skill files. + - -X github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills.bundledSourceRef={{ if not .IsSnapshot }}{{ .FullCommit }}{{ end }} + flags: + - -trimpath + +archives: + - id: default + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + # install.sh resolves binaries by archive name. Keep the template shape + # (one-cli_{os}_{arch}) stable so older installers keep working when we + # cut newer releases. + name_template: "one-cli_{{ .Os }}_{{ .Arch }}" + files: + - README* + - third_party/mise/LICENSE + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot" + +changelog: + # Release notes are reviewed separately and passed to the local release task. + disable: true + +release: + # install.sh downloads from /releases/download// so the + # exact GitHub repo coordinates here matter. Adjust if the repo moves. + github: + owner: 1cli-team + name: one-cli + draft: true + # A failed publish may already have created the tag and part of its draft. + # Reuse that draft and replace duplicate assets so the publish operation is + # safe to retry instead of silently skipping to the next version. + use_existing_draft: true + replace_existing_artifacts: true + make_latest: true + prerelease: auto + header: | + # one-cli {{ .Tag }} + + Pre-built binaries for darwin/linux/windows × amd64/arm64. + Install via the curl one-liner (see README), or download the + matching archive below and extract `one` onto your PATH. + + footer: | + Verify your download with `sha256sum -c checksums.txt`. diff --git a/AGENTS.md b/AGENTS.md index fd277a9..8993cd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,3 +42,10 @@ - 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 - 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 - 文案变化需要审阅相关帮助和输出;运行与改动相关的构建及静态检查,并按仓库现有流程执行 `mise run check`。 + +## 打包发布 + +- 发布前阅读 [RELEASING.md](./RELEASING.md),统一使用 `mise run release`。工具及配置维护在 `scripts/release.mts`、`.goreleaser.yaml` 和根 `mise.toml`,GoReleaser 固定为 2.18.0。 +- 版本根据远端最高稳定 `vX.Y.Z` tag 递增,默认 patch;固定已提交的 master 源码 SHA,在独立干净副本中检查和构建,并保留 release channel 与技能来源 SHA 注入。 +- 保持五个平台归档的现有文件名及 SHA256 校验文件。先验证本地产物,再推送 tag、上传草稿并下载校验;通过后才公开并设为 Latest。 +- 中断后续发原 tag 和原提交,已经完整发布则返回已有结果;已公开版本附件异常时停止。不要移动已发布 tag 或替换已公开附件。发布说明提供中英文,凭据不得写入文件或输出。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f577321..8103bdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,10 @@ mise run install # 打包并安装本地启动器,无需预先 ## 改不同部分的注意事项 +打包与发布流程见 [RELEASING.md](./RELEASING.md)。先运行 `mise run release -- plan patch` 查看版本与源码,再通过 `build` 验证产物、通过 `publish` 发布;GoReleaser 固定为 2.18.0。流程会在独立源码副本中检查和构建,并保留草稿及失败重试规则。 + +For packaging and publishing, see [RELEASING.md](./RELEASING.md). Start with `mise run release -- plan patch`, then validate with `build` and publish with `publish`. GoReleaser is pinned to 2.18.0; checks and builds use an independent source checkout and retain draft/retry safeguards. + ### 改 Go 代码(`packages/cli/internal/` / `packages/cli/pkg/`) - 公开 API(`packages/cli/pkg/`)改动要考虑 semver;详见 [CLAUDE.md 的 Public API stability](./CLAUDE.md) diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..edab954 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,180 @@ +# One CLI 发布 / Releasing One CLI + +## 中文 + +发布对象是包含 Dashboard、模板和 One 技能的 CLI。GitHub Release 位于 +`1cli-team/one-cli`,文档网站的部署独立维护。统一入口是根目录的 +`mise run release`;流程从已删除的 `.github/workflows/cli.yml` 迁移而来。 + +### 工具与凭据 + +- 在 Linux 或 macOS 的 amd64/arm64 主机上运行,需要 Git、GitHub CLI + (`gh`)、mise、bash、tar、unzip。 +- Go、Node、pnpm、Task、Process Compose 使用根 `mise.toml` 的固定版本。 + 发布任务单独加载固定的 GoReleaser **2.18.0**,配置为 `.goreleaser.yaml`。 +- 使用现有 `gh auth login` 会话,或用户已配置的 Shared credentials。 + 共享凭据通过 `one exec --global --env <环境> --path <目录> --keys GH_TOKEN -- ...` + 注入;`GH_TOKEN`/`GITHUB_TOKEN` 需要仓库内容写权限。凭据不写入文件、发布说明或日志。 + +### 常用命令 + +从仓库根目录的 `master` 分支执行: + +```bash +mise run release -- plan patch +mise run release:check +mise run release -- build patch --notes /absolute/path/release-notes.md +# 上一命令输出已验证的 release.json 路径;下面填入该路径。 +mise run release -- publish --from /absolute/path/release.json +``` + +也可以一次完成构建和发布: + +```bash +mise run release -- publish patch --notes /absolute/path/release-notes.md +``` + +`plan` 只读取远端发布状态并同步本地 Git 元数据,不创建发布 tag 或 Release。 +`build` 在 `.cache/releases/-<随机后缀>/source` 创建独立源码副本,完成 +全部检查与打包,只在该副本创建本地 tag。`publish --from` 复用同一组已验证 +产物,重新验证后推送 tag、上传草稿并公开。源副本、发布说明、SHA256 和 +`release.json` 保留在 `.cache/releases/`,失败时可检查和重试。 + +### 版本与源码规则 + +1. 发布前,本地 `master` 的 HEAD 必须与远端 `origin/master` 一致。 + 构建只使用指定提交的干净副本;操作工作区尚未提交的改动不会进入发行版。 + 准备发布的产品改动必须先合入 master。 +2. 版本以远端最高稳定 tag `vX.Y.Z` 为准,默认递增 `patch`;也支持 `minor` + 和 `major`,递增较高位时归零较低位。预发布 tag 不参与稳定版本计算。 + 无稳定 tag 时从 `v0.0.0` 计算。 +3. 版本来源是 tag,不从 `package.json`、源码默认值或历史 CHANGELOG 标题推断。 + GoReleaser 注入 `main.version`、`updatecheck.buildChannel=release`,以及精确 + 源码 SHA 的 `skills.bundledSourceRef`。保留现有 Go module path。 +4. 打包前根据上次正式发布以来的实际提交编写中英文发布说明,使用 `--notes` + 传入。历史 `[Unreleased]` 可能包含早已发布的内容,不直接全量复制。 +5. 开始构建及推送前均检查远端 master、基础 tag 对象和目标 Release。 + master 前进、tag 移动或出现冲突发布时停止并重新规划。同一时间只运行一次发布。 + `--from` 是本机生成的可信构建记录,请勿使用外部提供的记录文件。 + 本地任务使用 `.cache/releases/release.lock` 防止同时运行;进程被强制终止后, + 确认锁文件记录的 PID 已退出再删除残留锁。 + +### 检查与产物 + +任务使用 `GOWORK=off go mod download` 准备两个 Go module;禁用 mise 任务产物 +缓存,并强制运行 `mise run --jobs 1 --force check`。检查会重建并同步 Dashboard、 +registry、模板和技能。原 `pre-push` 与测试流水线已经删除,当前检查以静态检查、 +构建验证和发布包冒烟检查为准。检查结束后受版本控制的源码必须保持干净。 + +通过 GoReleaser 的 `release --skip=publish --clean` 生成正式版产物, +`CGO_ENABLED=0`、`-trimpath`、`-s -w` 保持原规则。附件必须恰好为以下六个文件: + +| 文件 | 平台 | +| --- | --- | +| `one-cli_darwin_amd64.tar.gz` | macOS Intel | +| `one-cli_darwin_arm64.tar.gz` | macOS Apple Silicon | +| `one-cli_linux_amd64.tar.gz` | Linux amd64 | +| `one-cli_linux_arm64.tar.gz` | Linux arm64 | +| `one-cli_windows_amd64.zip` | Windows amd64 | +| `checksums.txt` | 五个归档的 SHA256 | + +文件名是安装脚本及升级器的兼容协议,保持原样。Unix 归档内的程序名为 `one`, +Windows 为 `one.exe`;每个归档包含 README 和 `third_party/mise/LICENSE`。 +自动验证全部归档的校验值及 Go 构建信息,并运行本机平台的 `--version`、 +中英文帮助、语言切换和模板列表。语言检查使用临时 XDG 目录,保护开发者设置。 + +### 发布及失败恢复 + +- 先通过全部本地验证,再推送指向固定源码 SHA 的 annotated tag,始终禁止 force push。 +- 创建或复用 GitHub Release 草稿,上传本地已验证的六个附件。附件名称必须完全 + 对应;重新下载草稿附件,逐一比较 SHA256。所有检查通过后再公开并设为 Latest。 +- 最高稳定 tag 在 master 历史上,且 Release 缺失或仍为草稿:继续该 tag 对应的 + **原提交**,不会因为重试而递增版本;master 后续前进也不改变续发源码。 +- 当前提交已完整发布:返回已有结果。已公开版本附件不完整或包含意外附件、 + 稳定 tag 被标成预发布、无 Release 的最高 tag 不在 master 历史上,均停止检查。 +- 上传失败保留草稿及本地构建;优先用相同 `release.json` 重试。已存在草稿中的 + 同名附件可以替换,已公开版本不替换附件。禁止删除或移动已公开的 tag。 +- 公开后,在临时安装目录通过安装脚本验证 Latest 下载和版本;复制旧发行版到 + 临时目录验证 `one upgrade`。不要覆盖开发者现有 `one` 启动器。 + +## English + +One CLI releases contain the Dashboard, templates, and One skills. Releases are +published to `1cli-team/one-cli`; the documentation website is deployed separately. +The root `mise run release` task replaces the deleted release workflow. + +### Requirements and commands + +Use Linux or macOS on amd64/arm64 with Git, `gh`, mise, bash, tar, and unzip. +The source checkout uses the tool versions pinned in `mise.toml`; release tooling +loads GoReleaser **2.18.0** with `.goreleaser.yaml`. Use an existing `gh auth login` +session or user-configured Shared credentials injected by +`one exec --global --env --path --keys GH_TOKEN -- ...`. +Tokens need repository contents write access and must never be printed or saved. + +```bash +mise run release -- plan patch +mise run release:check +mise run release -- build patch --notes /absolute/path/release-notes.md +mise run release -- publish --from /absolute/path/release.json +# Alternatively, build and publish together: +mise run release -- publish patch --notes /absolute/path/release-notes.md +``` + +Run from the repository root on `master`. `plan` reads remote release state and +refreshes local Git metadata, without creating a release tag or Release. `build` +creates an independent checkout of the selected commit and a local annotated tag, +then validates and packages it. The verified manifest, source, notes, and hashes +remain under `.cache/releases/-/`. `publish --from` uploads the +same verified files after rechecking their hashes and remote state. + +### Version and source rules + +- Local master HEAD must equal remote master when planning. Only committed source + from a clean independent checkout is built; operator workspace edits are excluded. + Merge product changes to master before releasing. +- Derive versions from the highest remote stable `vX.Y.Z` tag. Default to `patch`; + `minor` and `major` reset lower components. Ignore prerelease tags. With no stable + tags, start from `v0.0.0`. +- Inject the tag version into `main.version`, set `updatecheck.buildChannel=release`, + and pin `skills.bundledSourceRef` to the exact source SHA. Preserve the existing + Go module path. Package versions, source defaults, and historical changelog headings + do not determine the release version. +- Supply reviewed Chinese and English notes based on commits since the previous + release. Do not copy all historical Unreleased entries without checking them. +- Recheck master, the base tag object, and release state before pushing. Stop on + concurrent state changes. Run only one release at a time. Only use trusted, + locally generated build manifests with `--from`. A local lock at + `.cache/releases/release.lock` prevents concurrent runs. After a forced stop, + confirm the recorded PID has exited before deleting a stale lock. + +### Verification and assets + +Download both Go modules with `GOWORK=off`, disable mise task artifact caching, +and force `mise run --jobs 1 --force check` to regenerate and check Dashboard, +registry, templates, and skills. Tracked source must remain clean. The deleted +pre-push/test pipeline is replaced by current static checks, builds, and artifact +smoke checks. + +GoReleaser runs `release --skip=publish --clean` with `CGO_ENABLED=0`, `-trimpath`, +and `-s -w`. Exactly six files are published: the five platform archives listed +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. + +### Publishing and recovery + +Push the annotated tag only after validation, never with force. Create or reuse +a draft, upload the six validated assets, download them again, and compare every +SHA256 with the build manifest. Publish and mark Latest only after these checks. + +A highest stable tag on master with a missing/draft Release resumes its original +commit without bumping again, even if master has advanced. A complete published +release for the current commit is idempotent. Stop if published assets are +incomplete/unexpected, a stable tag is marked prerelease, or an orphan highest tag +is off master. Keep failed drafts and build records, retry with the same manifest, +and replace duplicate assets only in drafts. Never move/delete published tags or +replace published assets. After publication, verify latest installation and +upgrade from an older release in temporary directories, preserving local launchers. diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx index 557083f..5f78a33 100644 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx +++ b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx @@ -42,12 +42,7 @@ import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Field, FieldLabel } from "@/components/ui/field"; import { IconButton } from "@/components/ui/icon-button"; -import { - ErrorNotice, - SearchInput, - SectionHeading, - StatePanel, -} from "@/components/ui/page-layout"; +import { ErrorNotice, SearchInput, SectionHeading, StatePanel } from "@/components/ui/page-layout"; import { DiscardDialog } from "@/components/ui/discard-dialog"; import { Spinner } from "@/components/ui/spinner"; import { Skeleton } from "@/components/ui/skeleton"; diff --git a/mise.toml b/mise.toml index 7d1e982..7c13f1f 100644 --- a/mise.toml +++ b/mise.toml @@ -8,6 +8,15 @@ pnpm = "12.3.4" task = "3.53.1" process-compose = "1.122.0" +[tasks.release] +description = "Plan, build, or publish a release / 规划、打包或发布版本" +run = "node scripts/release.mts" +raw_args = true + +[tasks."release:check"] +description = "Check release state and artifact rules / 检查发布状态与产物规则" +run = "node scripts/release-rules.test.mts" + [tasks.install-deps] description = "Install the root pnpm workspace dependencies" run = "pnpm install --frozen-lockfile" @@ -74,7 +83,7 @@ run = "pnpm run check" [tasks."check:static"] description = "Check documentation, Go, and Dashboard" -depends = ["verify-node-workspace", "verify-docs", "check:go", "check:dashboard"] +depends = ["verify-node-workspace", "verify-docs", "check:go", "check:dashboard", "release:check"] [tasks.check] description = "Run static checks on the current platform" diff --git a/scripts/release-rules.mts b/scripts/release-rules.mts new file mode 100644 index 0000000..5d9c91a --- /dev/null +++ b/scripts/release-rules.mts @@ -0,0 +1,135 @@ +export const repository = "1cli-team/one-cli"; +export const goreleaserVersion = "2.18.0"; +export const assets = [ + "checksums.txt", + "one-cli_darwin_amd64.tar.gz", + "one-cli_darwin_arm64.tar.gz", + "one-cli_linux_amd64.tar.gz", + "one-cli_linux_arm64.tar.gz", + "one-cli_windows_amd64.zip", +]; +export const stableTag = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +export type Bump = "patch" | "minor" | "major"; +export type Tag = { name: string; object: string; sha: string }; +export type Release = { + tag_name: string; + draft: boolean; + prerelease: boolean; + html_url: string; + assets: { name: string; digest?: string; state: string }[]; +}; +export type Plan = { + mode: "bump" | "resume" | "idempotent"; + tag: string; + version: string; + sourceSha: string; + masterSha: string; + baseTag: string; + baseTagObject: string; +}; + +export function fail(en: string, zh: string): never { + throw new Error(`${en}\n${zh}`); +} + +export function assertAssets(names: string[]) { + if (JSON.stringify([...names].sort()) !== JSON.stringify([...assets].sort())) { + fail( + `Unexpected release assets: ${names.join(", ")}. Expected: ${assets.join(", ")}.`, + `发布附件不完整或包含意外文件:${names.join(",")}。应包含:${assets.join(",")}。`, + ); + } +} + +export function compareTags(a: string, b: string) { + const left = a.slice(1).split(".").map(BigInt); + const right = b.slice(1).split(".").map(BigInt); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1; + } + return 0; +} + +export function bumpTag(tag: string, bump: Bump) { + const version = tag.slice(1).split(".").map(BigInt); + const index = { major: 0, minor: 1, patch: 2 }[bump]; + version[index]++; + for (let i = index + 1; i < 3; i++) version[i] = 0n; + return `v${version.join(".")}`; +} + +export function decidePlan(input: { + head: string; + master: string; + tags: Tag[]; + releases: Release[]; + bump: Bump; + highestOnMaster: boolean; +}): Plan { + const { head, master, releases, highestOnMaster, bump } = input; + if (head !== master) + fail( + "HEAD differs from origin/master; update the checkout first.", + "HEAD 与远端 master 不一致,请先更新工作区。", + ); + const tags = input.tags + .filter((t) => stableTag.test(t.name)) + .sort((a, b) => compareTags(b.name, a.name)); + const highest = tags[0]; + const latest = releases.find((r) => r.tag_name === highest?.name); + let mode: Plan["mode"] = "bump"; + if (highest) { + if (latest?.prerelease) + fail( + `Stable tag ${highest.name} is marked as a prerelease; resolve it first.`, + `稳定 tag ${highest.name} 被标为预发布,请先处理该状态。`, + ); + if (!latest) { + if (!highestOnMaster) + fail( + `Tag ${highest.name} is off master and has no release; resolve it first.`, + `tag ${highest.name} 不在 master 上且没有 Release,请先处理。`, + ); + mode = "resume"; + } else if (latest.draft) { + if (highestOnMaster) mode = "resume"; + } else { + assertAssets(latest.assets.map((a) => a.name)); + if (highestOnMaster && highest.sha === head) mode = "idempotent"; + } + } + const tag = mode === "bump" ? bumpTag(highest?.name ?? "v0.0.0", bump) : highest!.name; + if (mode === "bump" && tags.some((t) => t.sha === head)) { + fail( + "This commit already has a stable tag; resolve its release before creating another.", + "当前提交已有稳定 tag,请先处理对应 Release,避免重复打版本。", + ); + } + if (mode === "bump" && releases.some((r) => r.tag_name === tag)) { + fail( + `Release ${tag} exists without its Git tag; resolve it first.`, + `Release ${tag} 存在但缺少 Git tag,请先处理。`, + ); + } + return { + mode, + tag, + version: tag.slice(1), + sourceSha: mode === "bump" ? head : highest!.sha, + masterSha: master, + baseTag: highest?.name ?? "", + baseTagObject: highest?.object ?? "", + }; +} + +export function parseChecksums(text: string): Map { + const checksums = new Map(); + for (const line of text.trim().split(/\r?\n/)) { + const match = /^([a-f0-9]{64})\s+\*?([^/\\\s]+)$/.exec(line); + if (!match || checksums.has(match[2])) + fail("Invalid or duplicate checksum entry.", "校验文件含无效或重复条目。"); + checksums.set(match[2], match[1]); + } + assertAssets(["checksums.txt", ...checksums.keys()]); + return checksums; +} diff --git a/scripts/release-rules.test.mts b/scripts/release-rules.test.mts new file mode 100644 index 0000000..a2a4d6e --- /dev/null +++ b/scripts/release-rules.test.mts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assets, + assertAssets, + bumpTag, + compareTags, + decidePlan, + parseChecksums, +} from "./release-rules.mts"; + +const tag = { name: "v0.1.16", object: "tag-object", sha: "previous" }; +const release = { + tag_name: tag.name, + draft: false, + prerelease: false, + html_url: "https://github.com/1cli-team/one-cli/releases/tag/v0.1.16", + assets: assets.map((name) => ({ name, state: "uploaded" })), +}; +const input = { + head: "current", + master: "current", + tags: [tag], + releases: [release], + bump: "patch" as const, + highestOnMaster: true, +}; + +test("semantic versions sort numerically and reset lower components", () => { + assert.ok(compareTags("v0.1.16", "v0.1.9") > 0); + assert.equal(bumpTag("v0.1.16", "patch"), "v0.1.17"); + assert.equal(bumpTag("v0.1.16", "minor"), "v0.2.0"); + assert.equal(bumpTag("v0.1.16", "major"), "v1.0.0"); +}); +test("first release starts at v0.0.1 and nonstable tags are ignored", () => { + assert.equal(decidePlan({ ...input, tags: [], releases: [] }).tag, "v0.0.1"); + assert.equal( + decidePlan({ ...input, tags: [tag, { ...tag, name: "v9.0.0-rc.1" }] }).tag, + "v0.1.17", + ); +}); +test("new release uses master and completed release on the same commit is idempotent", () => { + assert.equal(decidePlan(input).sourceSha, "current"); + assert.equal(decidePlan({ ...input, head: "previous", master: "previous" }).mode, "idempotent"); +}); +test("interrupted releases resume the tagged commit even after master advances", () => { + for (const releases of [[], [{ ...release, draft: true }]]) { + const plan = decidePlan({ ...input, releases, bump: "major" }); + assert.equal(plan.mode, "resume"); + assert.equal(plan.tag, "v0.1.16"); + assert.equal(plan.sourceSha, "previous"); + } +}); +test("off-master orphan tags stop while off-master drafts reserve their version", () => { + assert.throws(() => decidePlan({ ...input, releases: [], highestOnMaster: false }), /off master/); + assert.equal( + decidePlan({ ...input, releases: [{ ...release, draft: true }], highestOnMaster: false }).tag, + "v0.1.17", + ); +}); +test("changed master, malformed published assets, and stable prereleases stop", () => { + assert.throws(() => decidePlan({ ...input, master: "advanced" }), /HEAD differs/); + assert.throws( + () => decidePlan({ ...input, releases: [{ ...release, assets: [] }] }), + /Unexpected release assets/, + ); + assert.throws( + () => decidePlan({ ...input, releases: [{ ...release, prerelease: true }] }), + /prerelease/, + ); +}); +test("duplicate stable tags and releases without matching tags stop", () => { + assert.throws( + () => + decidePlan({ + ...input, + tags: [{ ...tag, sha: "current" }], + releases: [{ ...release, draft: true }], + highestOnMaster: false, + }), + /already has a stable tag/, + ); + assert.throws( + () => decidePlan({ ...input, releases: [release, { ...release, tag_name: "v0.1.17" }] }), + /without its Git tag/, + ); +}); +test("asset set and checksum manifest reject duplicates, extras, traversal and omissions", () => { + assertAssets([...assets].reverse()); + assert.throws(() => assertAssets([...assets, assets[0]]), /Unexpected/); + const checksums = assets + .slice(1) + .map((name) => `${"a".repeat(64)} ${name}`) + .join("\n"); + assert.equal(parseChecksums(checksums).size, 5); + assert.throws(() => parseChecksums(`${checksums}\n${"a".repeat(64)} ../one`), /Invalid/); + assert.throws(() => parseChecksums(`${checksums}\n${checksums.split("\n")[0]}`), /duplicate/); + assert.throws(() => parseChecksums(checksums.split("\n").slice(1).join("\n")), /Unexpected/); +}); diff --git a/scripts/release.mts b/scripts/release.mts new file mode 100644 index 0000000..5025aab --- /dev/null +++ b/scripts/release.mts @@ -0,0 +1,544 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { arch, platform } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + assets, + assertAssets, + compareTags, + decidePlan, + fail, + goreleaserVersion, + parseChecksums, + repository, + stableTag, +} from "./release-rules.mts"; +import type { Bump, Plan, Release, Tag } from "./release-rules.mts"; + +type Options = { cwd?: string; env?: NodeJS.ProcessEnv }; +type State = { master: string; tags: Tag[]; releases: Release[] }; +type Build = { + schema: "one-cli/release-build/v1"; + plan: Plan; + source: string; + config: string; + notes: string; + hashes: Record; +}; + +function read(command: string, args: string[], options: Options = {}) { + return execFileSync(command, args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + ...options, + }).trim(); +} +function run(command: string, args: string[], options: Options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) + fail( + `${command} failed (${result.status ?? result.signal}); see the original output above.`, + `${command} 执行失败(${result.status ?? result.signal}),具体原因见上方原始输出。`, + ); +} +function say(en: string, zh: string) { + console.log(`${en}\n${zh}`); +} +function sha256(path: string) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} +function git(root: string, ...args: string[]) { + return read("git", args, { cwd: root }); +} + +function state(root: string): State { + const refs = git(root, "ls-remote", "origin", "refs/heads/master", "refs/tags/v*"); + const objects = new Map( + refs.split("\n").map((line) => { + const [sha, ref] = line.split(/\s+/); + return [ref, sha]; + }), + ); + const tags: Tag[] = []; + for (const [ref, object] of objects) { + if (!ref?.startsWith("refs/tags/") || !stableTag.test(ref.slice(10))) continue; + tags.push({ name: ref.slice(10), object, sha: objects.get(`${ref}^{}`) ?? object }); + } + // JSON Lines works with older gh versions that do not support --slurp. + const releaseLines = read("gh", [ + "api", + "--paginate", + `repos/${repository}/releases?per_page=100`, + "--jq", + ".[] | @json", + ]); + const master = objects.get("refs/heads/master"); + if (!master) fail("origin/master was not found.", "找不到远端 master。"); + return { + master, + tags: tags.sort((a, b) => compareTags(b.name, a.name)), + releases: releaseLines ? releaseLines.split("\n").map((line) => JSON.parse(line)) : [], + }; +} + +function plan(root: string, bump: Bump) { + const remote = git(root, "remote", "get-url", "origin"); + if (!/^(https:\/\/github\.com\/|git@github\.com:)1cli-team\/one-cli(?:\.git)?$/.test(remote)) { + fail( + `Unexpected origin: ${remote}. Expected ${repository}.`, + `origin 不正确:${remote}。应指向 ${repository}。`, + ); + } + if (git(root, "branch", "--show-current") !== "master") + fail("Run from the master branch.", "请从 master 分支运行。"); + run("git", ["fetch", "origin", "master", "--tags"], { cwd: root }); + const remoteState = state(root); + const highest = remoteState.tags[0]; + const highestOnMaster = + !highest || + spawnSync("git", ["merge-base", "--is-ancestor", highest.sha, remoteState.master], { + cwd: root, + }).status === 0; + return decidePlan({ + head: git(root, "rev-parse", "HEAD"), + master: remoteState.master, + tags: remoteState.tags, + releases: remoteState.releases, + bump, + highestOnMaster, + }); +} + +function revalidate(root: string, p: Plan): Release | undefined { + const current = state(root); + const targetTag = current.tags.find((t) => t.name === p.tag); + const release = current.releases.find((r) => r.tag_name === p.tag); + if (targetTag && targetTag.sha !== p.sourceSha) + fail(`Tag ${p.tag} moved; stop and inspect it.`, `tag ${p.tag} 已移动,请停止并检查。`); + if (release?.prerelease) + fail(`Stable release ${p.tag} is marked as a prerelease.`, `稳定版本 ${p.tag} 被标为预发布。`); + if (release && !targetTag) + fail(`Release ${p.tag} has no matching remote tag.`, `Release ${p.tag} 缺少对应的远端 tag。`); + const highest = current.tags[0]; + // A retry may see the tag pushed by this same build, but no newer release. + if (targetTag) { + if (highest?.name !== p.tag || (p.mode === "resume" && targetTag.object !== p.baseTagObject)) { + fail( + "The highest stable tag changed; restart the release.", + "最高稳定 tag 已变化,请重新规划发布。", + ); + } + } else if ((highest?.name ?? "") !== p.baseTag || (highest?.object ?? "") !== p.baseTagObject) { + fail("The base tag changed; restart the release.", "基础 tag 已变化,请重新规划发布。"); + } + const base = current.tags.find((t) => t.name === p.baseTag); + if (p.baseTag && base?.object !== p.baseTagObject) + fail("The base tag moved; restart the release.", "基础 tag 已移动,请重新规划发布。"); + if (p.mode === "bump" && !targetTag && current.master !== p.sourceSha) { + fail( + "master advanced during the build; restart the release.", + "构建期间 master 已前进,请重新规划发布。", + ); + } + if ( + spawnSync("git", ["merge-base", "--is-ancestor", p.sourceSha, current.master], { cwd: root }) + .status !== 0 + ) { + fail( + "The release commit is no longer on master; stop.", + "发布提交已不在 master 历史中,请停止发布。", + ); + } + if (release && !release.draft) assertAssets(release.assets.map((a) => a.name)); + return release; +} + +function verifyArchives(build: Build) { + const dist = join(build.source, "dist"); + const checksums = parseChecksums(readFileSync(join(dist, "checksums.txt"), "utf8")); + const hashes = Object.fromEntries(assets.map((name) => [name, sha256(join(dist, name))])); + for (const [name, expected] of checksums) { + if (hashes[name] !== expected) fail(`Checksum mismatch: ${name}.`, `文件校验失败:${name}。`); + } + if (JSON.stringify(hashes) !== JSON.stringify(build.hashes)) + fail("Build artifacts changed after validation.", "验证完成后构建产物发生变化。"); + if ( + git(build.source, "rev-parse", "HEAD") !== build.plan.sourceSha || + git(build.source, "status", "--porcelain", "--untracked-files=no") + ) { + fail("The release source checkout changed after validation.", "验证后发布源码副本发生变化。"); + } +} + +function smoke(build: Build) { + const p = build.plan; + const verify = mkdtempSync(join(dirname(build.source), "verify-")); + const hostOS = platform() === "darwin" ? "darwin" : platform() === "linux" ? "linux" : ""; + const hostArch = arch() === "x64" ? "amd64" : arch() === "arm64" ? "arm64" : ""; + if (!hostOS || !hostArch) + fail( + "Run release validation on Linux or macOS, amd64 or arm64.", + "请在 Linux 或 macOS 的 amd64/arm64 主机上验证发布包。", + ); + let binary = ""; + for (const name of assets.slice(1)) { + const dir = join(verify, name); + mkdirSync(dir); + const file = join(build.source, "dist", name); + if (name.endsWith(".zip")) run("unzip", ["-q", file, "-d", dir]); + else run("tar", ["-xzf", file, "-C", dir]); + const exe = join(dir, name.includes("windows") ? "one.exe" : "one"); + if ( + !existsSync(exe) || + !existsSync(join(dir, "README.md")) || + !existsSync(join(dir, "third_party/mise/LICENSE")) + ) { + fail( + `Archive ${name} is missing the binary, README, or mise license.`, + `归档 ${name} 缺少程序、README 或 mise 许可证。`, + ); + } + 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}。`, + ); + } + if (name === `one-cli_${hostOS}_${hostArch}.tar.gz`) binary = exe; + } + const config = join(verify, "config"); + const env = { + ...process.env, + CI: "1", + XDG_CONFIG_HOME: config, + XDG_DATA_HOME: join(verify, "data"), + XDG_CACHE_HOME: join(verify, "cache"), + XDG_STATE_HOME: join(verify, "state"), + }; + 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)) + fail(`CLI help did not switch to ${locale}.`, `CLI 帮助未切换到 ${locale}。`); + JSON.parse(read(binary, ["templates", "-o", "json"], { cwd: verify, env })); + } + say( + "All five archives, build metadata, versions, and bilingual CLI smoke checks passed.", + "五个平台的归档、构建信息、版本及中英文 CLI 冒烟检查均通过。", + ); +} + +function buildRelease( + root: string, + p: Plan, + notesFile: string, +): { manifest: string; build: Build } { + if (!notesFile || !existsSync(notesFile) || !readFileSync(notesFile, "utf8").trim()) { + fail( + "Provide reviewed release notes with --notes before building.", + "打包前请通过 --notes 提供已审阅的发布说明。", + ); + } + const cache = join(root, ".cache", "releases"); + mkdirSync(cache, { recursive: true }); + const dir = mkdtempSync(join(cache, `${p.tag}-`)); + const source = join(dir, "source"); + run("git", ["clone", "--no-hardlinks", "--no-checkout", root, source]); + run("git", ["remote", "set-url", "origin", git(root, "remote", "get-url", "origin")], { + cwd: source, + }); + run("git", ["checkout", "--detach", p.sourceSha], { cwd: source }); + const config = join(dir, "goreleaser.yaml"); + copyFileSync(join(root, ".goreleaser.yaml"), config); + const notes = join(dir, "notes.md"); + copyFileSync(notesFile, notes); + const env = { + ...process.env, + GOTOOLCHAIN: "local", + GOPROXY: "https://proxy.golang.org,direct", + MISE_TASK_CACHE: "off", + RELEASE_VERSION: p.version, + }; + say( + `Building ${p.tag} from ${p.sourceSha} in ${source}.`, + `在 ${source} 中从 ${p.sourceSha} 构建 ${p.tag}。`, + ); + run("mise", ["trust"], { cwd: source, env }); + for (const module of ["packages/kernel", "packages/cli"]) { + run("mise", ["exec", "--", "go", "-C", module, "mod", "download"], { + cwd: source, + env: { ...env, GOWORK: "off" }, + }); + } + run("mise", ["run", "--jobs", "1", "--force", "check"], { cwd: source, env }); + if (git(source, "status", "--porcelain", "--untracked-files=no")) + fail( + "Checks changed tracked source files; stop.", + "检查修改了受版本控制的源码文件,请停止发布。", + ); + if (!existsSync(join(source, ".git", "refs", "tags", p.tag))) { + // show-ref also handles packed refs; never replace an existing tag. + if ( + spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${p.tag}`], { cwd: source }) + .status !== 0 + ) { + run( + "git", + [ + "-c", + "user.name=One CLI Release", + "-c", + "user.email=release@1cli.dev", + "tag", + "-a", + p.tag, + p.sourceSha, + "-m", + p.tag, + ], + { cwd: source }, + ); + } + } + if (git(source, "rev-list", "-n", "1", p.tag) !== p.sourceSha) + fail("The local release tag has the wrong commit.", "本地发布 tag 指向错误提交。"); + run( + "mise", + ["exec", `goreleaser@${goreleaserVersion}`, "--", "goreleaser", "check", "--config", config], + { cwd: source, env }, + ); + run( + "mise", + [ + "exec", + `goreleaser@${goreleaserVersion}`, + "--", + "goreleaser", + "release", + "--config", + config, + "--skip=publish", + "--clean", + ], + { cwd: source, env: { ...env, GORELEASER_CURRENT_TAG: p.tag } }, + ); + const build: Build = { + schema: "one-cli/release-build/v1", + plan: p, + source, + config, + notes, + hashes: Object.fromEntries(assets.map((name) => [name, sha256(join(source, "dist", name))])), + }; + verifyArchives(build); + smoke(build); + revalidate(root, p); + const manifest = join(dir, "release.json"); + writeFileSync(manifest, JSON.stringify(build, null, 2) + "\n"); + say(`Verified build: ${manifest}`, `已验证的构建记录:${manifest}`); + return { manifest, build }; +} + +function publish(root: string, build: Build) { + verifyArchives(build); + const p = build.plan; + let release = revalidate(root, p); + if (release && !release.draft) { + say(`Already published: ${release.html_url}`, `已完成发布:${release.html_url}`); + return; + } + const target = state(root).tags.find((t) => t.name === p.tag); + if (!target) { + // Resolve state again immediately before pushing the tag. No force push. + revalidate(root, p); + run( + "git", + [ + "-c", + "credential.helper=", + "-c", + "credential.helper=!gh auth git-credential", + "push", + "origin", + `refs/tags/${p.tag}:refs/tags/${p.tag}`, + ], + { cwd: build.source }, + ); + } + const currentTag = state(root).tags.find((t) => t.name === p.tag); + if ( + currentTag?.sha !== p.sourceSha || + currentTag.object !== git(build.source, "rev-parse", p.tag) + ) { + fail( + "The pushed tag differs from the verified build; stop.", + "远端 tag 与已验证构建不一致,请停止发布。", + ); + } + release = revalidate(root, p); + if (release && !release.draft) { + say(`Already published: ${release.html_url}`, `已完成发布:${release.html_url}`); + return; + } + if (!release) + run("gh", [ + "release", + "create", + p.tag, + "--repo", + repository, + "--verify-tag", + "--draft", + "--title", + p.tag, + "--notes-file", + build.notes, + ]); + else run("gh", ["release", "edit", p.tag, "--repo", repository, "--notes-file", build.notes]); + run("gh", [ + "release", + "upload", + p.tag, + "--repo", + repository, + "--clobber", + ...assets.map((name) => join(build.source, "dist", name)), + ]); + release = revalidate(root, p); + if (!release?.draft) + fail( + "Release must remain a draft while artifacts are verified.", + "验证附件时 Release 必须保持草稿状态。", + ); + assertAssets(release.assets.map((a) => a.name)); + const download = mkdtempSync(join(dirname(build.source), "download-")); + run("gh", ["release", "download", p.tag, "--repo", repository, "--dir", download]); + for (const name of assets) { + if (sha256(join(download, name)) !== build.hashes[name]) + fail( + `Uploaded asset differs from the verified build: ${name}.`, + `上传附件与已验证构建不一致:${name}。`, + ); + } + release = revalidate(root, p); + if (!release?.draft) + fail( + "Draft release changed during verification; restart.", + "验证期间草稿状态已变化,请重新执行。", + ); + say( + `Draft ${p.tag}: all six downloaded assets match the verified build. Publishing.`, + `草稿 ${p.tag}:下载验证的六个附件均与构建一致,开始公开发布。`, + ); + run("gh", ["release", "edit", p.tag, "--repo", repository, "--draft=false", "--latest"]); + release = revalidate(root, p); + if (!release || release.draft) + fail("The release is still a draft; retry publishing.", "Release 仍为草稿,请重试发布。"); + const latest = JSON.parse(read("gh", ["api", `repos/${repository}/releases/latest`])); + if (latest.tag_name !== p.tag) + fail( + "Published release is not Latest; inspect the release state.", + "已公开版本未成为 Latest,请检查发布状态。", + ); + say( + `Published ${p.tag} from ${p.sourceSha}: ${release.html_url}`, + `已从 ${p.sourceSha} 发布 ${p.tag}:${release.html_url}`, + ); +} + +function main() { + const args = process.argv.slice(2); + const action = args.shift() ?? "plan"; + if (["--help", "-h", "help"].includes(action)) { + say( + "Usage: mise run release -- [patch|minor|major] [--notes ] [--from ]", + "用法:mise run release -- [patch|minor|major] [--notes ] [--from ]。详见 RELEASING.md。", + ); + return; + } + if (!["plan", "build", "publish"].includes(action)) + fail(`Unknown action: ${action}. Use --help.`, `未知操作:${action}。请查看 --help。`); + const bump = args[0] && !args[0].startsWith("--") ? args.shift()! : "patch"; + if (!["patch", "minor", "major"].includes(bump)) + fail(`Unsupported bump: ${bump}.`, `不支持的版本递增方式:${bump}。`); + let notes = "", + from = ""; + while (args.length) { + const flag = args.shift(); + const value = args.shift(); + if (!value || value.startsWith("--") || !["--notes", "--from"].includes(flag!)) + fail("Invalid arguments. Use --help.", "参数无效,请查看 --help。"); + if (flag === "--notes") notes = resolve(value); + else from = resolve(value); + } + const root = git(process.cwd(), "rev-parse", "--show-toplevel"); + const lock = join(root, ".cache", "releases", "release.lock"); + if (action !== "plan") { + mkdirSync(dirname(lock), { recursive: true }); + try { + writeFileSync(lock, `${process.pid}\n`, { flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + fail( + `Release lock exists: ${lock}. Check its PID before removing a stale lock.`, + `发布锁已存在:${lock}。移除残留锁前,请确认记录的进程已退出。`, + ); + } + process.once("exit", () => { + if (existsSync(lock)) unlinkSync(lock); + }); + } + if (from) { + if (action !== "publish" || notes) + fail( + "Use --from only with publish and without --notes.", + "--from 仅用于 publish,且不能同时使用 --notes。", + ); + const build: Build = JSON.parse(readFileSync(from, "utf8")); + if ( + build.schema !== "one-cli/release-build/v1" || + !stableTag.test(build.plan.tag) || + build.plan.version !== build.plan.tag.slice(1) + ) { + fail("Invalid build manifest.", "构建记录无效。"); + } + publish(root, build); + return; + } + const p = plan(root, bump as Bump); + console.log(JSON.stringify(p, null, 2)); + if (action === "plan" || p.mode === "idempotent") return; + const { build } = buildRelease(root, p, notes); + if (action === "publish") publish(root, build); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +}