From 848c76bcef33d9c794828311adea45c219f858d8 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:51:31 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(kernel):=20release=20=E2=80=94=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=81=9A=E5=90=88/=E5=88=86=E7=B1=BB/?= =?UTF-8?q?=E6=8F=90=E8=AE=AE=20+=20tag=20=E6=A0=A1=E9=AA=8C=EF=BC=88Issue?= =?UTF-8?q?=20#110=20=E6=89=B9=2011=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/kernel/release.ts | 300 +++++++++++++++++++++++++++++++ test/kernel/release.test.ts | 349 ++++++++++++++++++++++++++++++++++++ 2 files changed, 649 insertions(+) create mode 100644 src/kernel/release.ts create mode 100644 test/kernel/release.test.ts diff --git a/src/kernel/release.ts b/src/kernel/release.ts new file mode 100644 index 0000000..8f0fc2d --- /dev/null +++ b/src/kernel/release.ts @@ -0,0 +1,300 @@ +import { gh as ghCli } from "../util/gh.js" + +// ─── 类型 ─── + +export type ChangeCategory = "feature" | "fix" | "maintenance" | "docs" | "unclassified" +export type VersionType = "major" | "minor" | "patch" + +export interface ReleaseChange { + sha: string + title: string + category: ChangeCategory + breaking: boolean +} + +export interface Version { + major: number + minor: number + patch: number +} + +// ─── 版本文件读写(Profile §9.1,技术栈无关) ─── + +export interface VersionFileSpec { + path: string + key: string +} + +/** + * 解析版本文件规则(Profile `version file` 值): + * 形如 `package.json`(默认 $.version)或 `lib/version.json $.version`; + * json 路径必须为 `$.` 形式,否则返回 null。 + */ +export function parseVersionFileSpec(spec: string): VersionFileSpec | null { + const trimmed = spec.trim() + if (trimmed === "") return null + + const parts = trimmed.split(/\s+/) + if (parts.length === 1) { + return { path: parts[0], key: "version" } + } + + const jsonPath = parts[parts.length - 1] + const key = jsonPath.startsWith("$.") ? jsonPath.slice(2) : "" + if (key === "") return null + return { path: parts.slice(0, -1).join(" "), key } +} + +/** 从 JSON 文本按 key 读取版本字符串;非字符串或解析失败返回 null */ +export function readVersionFromJson(json: string, key: string): string | null { + try { + const value = JSON.parse(json)?.[key] + return typeof value === "string" ? value : null + } catch { + return null + } +} + +/** 更新 JSON 文本中指定 key 的版本;保留缩进风格与结尾换行 */ +export function updateVersionInJson(json: string, key: string, version: string): string { + const parsed = JSON.parse(json) as Record + parsed[key] = version + const updated = JSON.stringify(parsed, null, 2) + return json.endsWith("\n") ? `${updated}\n` : updated +} + +// ─── 版本纯函数 ─── + +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/ + +export function parseVersion(raw: string): Version | null { + const match = VERSION_PATTERN.exec(raw.trim()) + if (!match) return null + return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) } +} + +export function formatVersion(v: Version): string { + return `${v.major}.${v.minor}.${v.patch}` +} + +/** SemVer bump:major 重置 minor/patch,minor 重置 patch,patch +1 */ +export function bumpVersion(current: Version, type: VersionType): Version { + switch (type) { + case "major": + return { major: current.major + 1, minor: 0, patch: 0 } + case "minor": + return { major: current.major, minor: current.minor + 1, patch: 0 } + case "patch": + return { major: current.major, minor: current.minor, patch: current.patch + 1 } + } +} + +/** 取最高 bump 级别:major > minor > patch */ +export function highestBumpType(types: VersionType[]): VersionType { + if (types.includes("major")) return "major" + if (types.includes("minor")) return "minor" + return "patch" +} + +// ─── 变更分类 ─── + +type Classified = { category: ChangeCategory; breaking: boolean } + +const CONVENTIONAL_PATTERN = /^([a-z]+)(!)?(\([^)]*\))?:/ + +const MAINTENANCE_TYPES = new Set(["chore", "refactor", "build", "ci", "perf", "test", "merge"]) + +/** + * 从 commit message 分类(conventional commits,关联 Flow/Task type): + * - breaking:`type!:` 或 body 含 `BREAKING CHANGE` + * - feat→feature、fix→fix、docs→docs、chore/refactor/build/ci/perf/test/merge→maintenance + * - 其余 → unclassified + */ +export function classifyCommitTitle(title: string): Classified { + const firstLine = title.split("\n")[0] + const breaking = /^[a-z]+!(:|\()/.test(firstLine) || title.includes("BREAKING CHANGE") + + if (firstLine.startsWith("Merge ")) { + return { category: "maintenance", breaking } + } + + const match = CONVENTIONAL_PATTERN.exec(firstLine) + if (!match) return { category: "unclassified", breaking } + const type = match[1] + + switch (type) { + case "feat": + return { category: "feature", breaking } + case "fix": + return { category: "fix", breaking } + case "docs": + return { category: "docs", breaking } + default: + if (MAINTENANCE_TYPES.has(type)) return { category: "maintenance", breaking } + return { category: "unclassified", breaking } + } +} + +export function classifyChanges(commits: { sha: string; title: string }[]): ReleaseChange[] { + return commits.map(commit => { + const { category, breaking } = classifyCommitTitle(commit.title) + return { sha: commit.sha, title: commit.title.split("\n")[0], category, breaking } + }) +} + +// ─── 版本提议(未分类门禁) ─── + +export type ProposeVersionResult = + | { ok: true; proposed: string; type: VersionType } + | { ok: false; code: "INVALID_VERSION"; message: string } + | { ok: false; code: "UNCLASSIFIED_CHANGES"; unclassified: { sha: string; title: string }[]; message: string } + +/** + * SemVer 提议:breaking→major(含 0.x)、feature→minor、fix/maintenance/docs→patch; + * 多变更取最高级别;存在未分类变更时拒绝(版本提议前必须分类)。 + */ +export function proposeVersion(currentVersion: string, changes: ReleaseChange[]): ProposeVersionResult { + const current = parseVersion(currentVersion) + if (!current) { + return { ok: false, code: "INVALID_VERSION", message: `Invalid current version: ${currentVersion}` } + } + + const unclassified = changes + .filter(c => c.category === "unclassified") + .map(c => ({ sha: c.sha, title: c.title })) + if (unclassified.length > 0) { + return { + ok: false, + code: "UNCLASSIFIED_CHANGES", + unclassified, + message: `Unclassified changes must be classified before proposing a version (${unclassified.length} change(s))`, + } + } + + const breaking = changes.some(c => c.breaking) + const types: VersionType[] = [] + for (const c of changes) { + if (c.category === "feature") types.push("minor") + else if (c.category === "fix" || c.category === "maintenance" || c.category === "docs") types.push("patch") + } + if (breaking) types.push("major") + + const type = highestBumpType(types) + return { ok: true, proposed: formatVersion(bumpVersion(current, type)), type } +} + +// ─── tag ─── + +/** 按 tag 格式(如 v{version})构建 tag;缺省 v{version} */ +export function buildTag(version: string, tagFormat: string): string { + if (tagFormat.includes("{version}")) { + return tagFormat.replace("{version}", version) + } + return `v${version}` +} + +/** tag 是否匹配格式(替换 {version} 为语义版本正则后整体匹配) */ +export function validateTagFormat(tag: string, tagFormat: string): boolean { + const escaped = tagFormat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace("\\{version\\}", "(\\d+\\.\\d+\\.\\d+)") + return new RegExp(`^${escaped}$`).test(tag) +} + +// ─── Release Notes ─── + +const SECTION_TITLES: [ChangeCategory, string][] = [ + ["feature", "Features"], + ["fix", "Fixes"], + ["maintenance", "Maintenance"], + ["docs", "Documentation"], +] + +/** 按分类聚合生成 Release Notes markdown;breaking 变更单独列出 */ +export function buildReleaseNotes(version: string, changes: ReleaseChange[]): string { + const lines: string[] = [`## ${version}`] + const breaking = changes.filter(c => c.breaking) + if (breaking.length > 0) { + lines.push("", "### BREAKING CHANGES") + for (const c of breaking) lines.push(`- ${c.title}`) + } + for (const [category, title] of SECTION_TITLES) { + const group = changes.filter(c => c.category === category) + if (group.length === 0) continue + lines.push("", `### ${title}`) + for (const c of group) lines.push(`- ${c.title}`) + } + return lines.join("\n") +} + +// ─── gh 聚合 / tag 校验(可替换 executor,仿 kernel/records.ts) ─── + +export type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let releaseGhExecutor: GhFn | null = null + +export function setReleaseGhExecutor(fn: GhFn | null): void { + releaseGhExecutor = fn +} + +/** 解析实际 gh 执行函数:显式 executor > 模块级 mock > 真实 gh CLI */ +function ghFn(executor?: GhFn): GhFn { + return executor ?? releaseGhExecutor ?? ghCli +} + +/** 仓库上一 tag 名(tags 列表第一个);无 tag 返回 null */ +export async function latestTag(executor?: GhFn): Promise { + const { stdout } = await ghFn(executor)(`api repos/{owner}/{repo}/tags --jq '.[0].name'`) + const trimmed = stdout.trim() + if (trimmed === "" || trimmed === "null") return null + return trimmed +} + +/** 聚合上一 tag 之后 base 分支的全部 commit(sha + 首行标题) */ +export async function listCommitsSinceTag( + tag: string, + baseBranch: string, + executor?: GhFn, +): Promise<{ sha: string; title: string }[]> { + const { stdout } = await ghFn(executor)( + `api repos/{owner}/{repo}/compare/${tag}...${baseBranch} --jq '.commits[] | {sha: .sha, title: (.commit.message | split("\\n")[0])}'`, + ) + const trimmed = stdout.trim() + if (trimmed === "" || trimmed === "null") return [] + const commits = JSON.parse(trimmed) as { sha: string; title: string }[] + // jq 已截断首行,这里再截断一次(mock/调用方绕过 jq 时兜底) + return commits.map(c => ({ sha: c.sha, title: c.title.split("\n")[0] })) +} + +/** 远程已存在 tag 指向的 commit SHA;不存在返回 null */ +export async function existingTagSha(tag: string, executor?: GhFn): Promise { + const { stdout } = await ghFn(executor)(`api repos/{owner}/{repo}/tags --jq '.[] | select(.name == "${tag}") | .commit.sha'`) + const trimmed = stdout.trim() + if (trimmed === "" || trimmed === "null") return null + return trimmed +} + +export type TagImmutabilityResult = + | { ok: true; reason?: string } + | { ok: false; code: "TAG_ALREADY_EXISTS"; message: string } + +/** + * tag 不可变校验:不得删除重打。 + * 远程已存在且指向不同 SHA → 拒绝;指向同一 SHA → 幂等通过;不存在 → 可创建。 + */ +export async function validateTagImmutability( + tag: string, + expectedSha: string, + executor?: GhFn, +): Promise { + const existing = await existingTagSha(tag, executor) + if (existing === null) { + return { ok: true } + } + if (existing === expectedSha) { + return { ok: true, reason: "tag already points at the expected commit (idempotent)" } + } + return { + ok: false, + code: "TAG_ALREADY_EXISTS", + message: `Tag ${tag} already exists at ${existing}, refusing to re-point it to ${expectedSha} (tags are immutable)`, + } +} diff --git a/test/kernel/release.test.ts b/test/kernel/release.test.ts new file mode 100644 index 0000000..3c8b562 --- /dev/null +++ b/test/kernel/release.test.ts @@ -0,0 +1,349 @@ +import { describe, it, expect } from "vitest" +import { + parseVersion, + formatVersion, + bumpVersion, + highestBumpType, + classifyCommitTitle, + classifyChanges, + proposeVersion, + buildTag, + validateTagFormat, + buildReleaseNotes, + latestTag, + listCommitsSinceTag, + existingTagSha, + validateTagImmutability, + setReleaseGhExecutor, + parseVersionFileSpec, + readVersionFromJson, + updateVersionInJson, +} from "../../src/kernel/release.js" + +describe("parseVersion", () => { + it("parses a valid semver", () => { + expect(parseVersion("1.2.3")).toEqual({ major: 1, minor: 2, patch: 3 }) + }) + + it("parses 0.x versions", () => { + expect(parseVersion("0.3.2")).toEqual({ major: 0, minor: 3, patch: 2 }) + }) + + it("rejects malformed versions", () => { + for (const bad of ["1.2", "1.2.3.4", "v1.2.3", "1.2.x", "abc", "", "1.2.-3"]) { + expect(parseVersion(bad), bad).toBeNull() + } + }) +}) + +describe("formatVersion", () => { + it("formats a version as x.y.z", () => { + expect(formatVersion({ major: 1, minor: 0, patch: 0 })).toBe("1.0.0") + }) +}) + +describe("bumpVersion", () => { + it("bumps major and resets minor/patch", () => { + expect(bumpVersion({ major: 1, minor: 4, patch: 2 }, "major")).toEqual({ major: 2, minor: 0, patch: 0 }) + }) + + it("bumps major from 0.x (0.x breaking also goes major)", () => { + expect(bumpVersion({ major: 0, minor: 3, patch: 2 }, "major")).toEqual({ major: 1, minor: 0, patch: 0 }) + }) + + it("bumps minor and resets patch", () => { + expect(bumpVersion({ major: 1, minor: 4, patch: 2 }, "minor")).toEqual({ major: 1, minor: 5, patch: 0 }) + }) + + it("bumps patch", () => { + expect(bumpVersion({ major: 1, minor: 4, patch: 2 }, "patch")).toEqual({ major: 1, minor: 4, patch: 3 }) + }) +}) + +describe("highestBumpType", () => { + it("major wins over minor and patch", () => { + expect(highestBumpType(["patch", "minor", "major"])).toBe("major") + }) + + it("minor wins over patch", () => { + expect(highestBumpType(["patch", "minor"])).toBe("minor") + }) + + it("falls back to patch", () => { + expect(highestBumpType(["patch"])).toBe("patch") + expect(highestBumpType([])).toBe("patch") + }) +}) + +describe("classifyCommitTitle", () => { + it("classifies feat as feature", () => { + expect(classifyCommitTitle("feat(kernel): add release aggregation")).toEqual({ + category: "feature", + breaking: false, + }) + }) + + it("classifies fix as fix", () => { + expect(classifyCommitTitle("fix(core): resolve null deref")).toEqual({ category: "fix", breaking: false }) + }) + + it("classifies chore/refactor/build/ci/perf/test as maintenance", () => { + for (const type of ["chore", "refactor", "build", "ci", "perf", "test"]) { + expect(classifyCommitTitle(`${type}(x): something`), type).toEqual({ category: "maintenance", breaking: false }) + } + }) + + it("classifies docs as docs", () => { + expect(classifyCommitTitle("docs(readme): clarify usage")).toEqual({ category: "docs", breaking: false }) + }) + + it("marks breaking via ! after the type", () => { + expect(classifyCommitTitle("feat!: remove old flowrun")).toEqual({ category: "feature", breaking: true }) + }) + + it("marks breaking via BREAKING CHANGE in the body", () => { + const title = "feat(core): rework kernel\n\nBREAKING CHANGE: old cabinet removed" + expect(classifyCommitTitle(title)).toEqual({ category: "feature", breaking: true }) + }) + + it("classifies merge commits as maintenance", () => { + expect(classifyCommitTitle("Merge pull request #120 from devcxl/feat/x")).toEqual({ + category: "maintenance", + breaking: false, + }) + }) + + it("marks unknown commit types as unclassified", () => { + expect(classifyCommitTitle("tweak some internal thing")).toEqual({ category: "unclassified", breaking: false }) + expect(classifyCommitTitle("random: whatever")).toEqual({ category: "unclassified", breaking: false }) + }) +}) + +describe("classifyChanges", () => { + it("classifies a batch of commits", () => { + const changes = classifyChanges([ + { sha: "a", title: "feat(api): add endpoint" }, + { sha: "b", title: "fix(core): bug" }, + { sha: "c", title: "docs: readme" }, + ]) + expect(changes.map(c => c.category)).toEqual(["feature", "fix", "docs"]) + }) +}) + +describe("proposeVersion", () => { + const change = (category: "feature" | "fix" | "maintenance" | "docs" | "unclassified", breaking = false) => ({ + sha: "abc", + title: "t", + category, + breaking, + }) + + it("proposes major for breaking changes", () => { + const result = proposeVersion("1.4.2", [change("feature", true)]) + expect(result).toEqual({ ok: true, proposed: "2.0.0", type: "major" }) + }) + + it("proposes major for breaking 0.x changes (0.x breaking goes major)", () => { + const result = proposeVersion("0.3.2", [change("fix", true)]) + expect(result).toEqual({ ok: true, proposed: "1.0.0", type: "major" }) + }) + + it("proposes minor for features", () => { + const result = proposeVersion("1.4.2", [change("feature")]) + expect(result).toEqual({ ok: true, proposed: "1.5.0", type: "minor" }) + }) + + it("proposes patch for fix/maintenance/docs", () => { + for (const category of ["fix", "maintenance", "docs"] as const) { + const result = proposeVersion("1.4.2", [change(category)]) + expect(result, category).toEqual({ ok: true, proposed: "1.4.3", type: "patch" }) + } + }) + + it("takes the highest level across mixed changes", () => { + const result = proposeVersion("1.4.2", [change("fix"), change("feature"), change("docs")]) + expect(result).toEqual({ ok: true, proposed: "1.5.0", type: "minor" }) + }) + + it("rejects unknown current version", () => { + const result = proposeVersion("v1.4", [change("fix")]) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("INVALID_VERSION") + } + }) + + it("gate: rejects when any change is unclassified", () => { + const result = proposeVersion("1.4.2", [change("fix"), change("unclassified")]) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("UNCLASSIFIED_CHANGES") + } + }) +}) + +describe("buildTag / validateTagFormat", () => { + it("builds a tag with the default v{version} format", () => { + expect(buildTag("1.0.0", "v{version}")).toBe("v1.0.0") + }) + + it("builds a tag with a custom format", () => { + expect(buildTag("1.0.0", "release-{version}")).toBe("release-1.0.0") + }) + + it("validates tags against the format", () => { + expect(validateTagFormat("v1.0.0", "v{version}")).toBe(true) + expect(validateTagFormat("1.0.0", "v{version}")).toBe(false) + expect(validateTagFormat("v1.0.0", "v{version}")).toBe(true) + }) +}) + +describe("buildReleaseNotes", () => { + it("groups changes by category with a header", () => { + const notes = buildReleaseNotes("1.5.0", [ + { sha: "a", title: "feat(api): add endpoint", category: "feature", breaking: false }, + { sha: "b", title: "fix(core): bug", category: "fix", breaking: false }, + ]) + expect(notes).toContain("## 1.5.0") + expect(notes).toContain("### Features") + expect(notes).toContain("feat(api): add endpoint") + expect(notes).toContain("### Fixes") + expect(notes).toContain("fix(core): bug") + }) + + it("mentions breaking changes", () => { + const notes = buildReleaseNotes("2.0.0", [ + { sha: "a", title: "feat!: remove old flowrun", category: "feature", breaking: true }, + ]) + expect(notes).toContain("BREAKING") + }) +}) + +describe("gh aggregation and tag immutability (mock executor)", () => { + type Call = { args: string; respond: (call: string) => string } + let calls: string[] + + function withExecutor(responses: Record, fn: () => Promise) { + return async () => { + calls = [] + setReleaseGhExecutor(args => { + calls.push(args) + return Promise.resolve({ stdout: responses[args] ?? "", stderr: "" }) + }) + try { + await fn() + } finally { + setReleaseGhExecutor(null) + } + } + } + + it("latestTag returns the newest tag name", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v1.4.2" }, + async () => { + expect(await latestTag()).toBe("v1.4.2") + }, + )) + + it("latestTag returns null when there are no tags", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[0].name'": "" }, + async () => { + expect(await latestTag()).toBeNull() + }, + )) + + it("listCommitsSinceTag returns sha and first-line titles", withExecutor( + { + "api repos/{owner}/{repo}/compare/v1.4.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": + `[{"sha":"aaa","title":"feat(kernel): add release aggregation"},{"sha":"bbb","title":"fix(core): bug\\nwith a body"}]`, + }, + async () => { + const commits = await listCommitsSinceTag("v1.4.2", "main") + expect(commits).toEqual([ + { sha: "aaa", title: "feat(kernel): add release aggregation" }, + { sha: "bbb", title: "fix(core): bug" }, + ]) + }, + )) + + it("listCommitsSinceTag returns [] when the range is empty", withExecutor( + { "api repos/{owner}/{repo}/compare/v1.4.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": "[]" }, + async () => { + expect(await listCommitsSinceTag("v1.4.2", "main")).toEqual([]) + }, + )) + + it("existingTagSha returns the sha a tag points at", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.4.2\") | .commit.sha'": "deadbeef" }, + async () => { + expect(await existingTagSha("v1.4.2")).toBe("deadbeef") + }, + )) + + it("existingTagSha returns null for a missing tag", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.4.2\") | .commit.sha'": "" }, + async () => { + expect(await existingTagSha("v1.4.2")).toBeNull() + }, + )) + + it("validateTagImmutability allows creating a new tag", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "" }, + async () => { + const result = await validateTagImmutability("v1.5.0", "abc123") + expect(result).toEqual({ ok: true }) + }, + )) + + it("validateTagImmutability passes idempotently when the tag already points at the expected sha", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "abc123" }, + async () => { + const result = await validateTagImmutability("v1.5.0", "abc123") + expect(result.ok).toBe(true) + }, + )) + + it("validateTagImmutability rejects re-pointing an existing tag (tags are immutable)", withExecutor( + { "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "oldsha" }, + async () => { + const result = await validateTagImmutability("v1.5.0", "newsha") + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("TAG_ALREADY_EXISTS") + expect(result.message).toContain("immutable") + } + }, + )) +}) + +describe("version file read/write (Profile §9.1, tech-stack agnostic)", () => { + it("parses a bare version file spec with the default $.version path", () => { + expect(parseVersionFileSpec("package.json")).toEqual({ path: "package.json", key: "version" }) + }) + + it("parses a spec with an explicit json path", () => { + expect(parseVersionFileSpec("lib/version.json $.version")).toEqual({ path: "lib/version.json", key: "version" }) + expect(parseVersionFileSpec("pkg.json $.name.version")).toEqual({ path: "pkg.json", key: "name.version" }) + }) + + it("returns null for a spec without a JSON path", () => { + expect(parseVersionFileSpec("package.json metadata")).toBeNull() + expect(parseVersionFileSpec("")).toBeNull() + }) + + it("reads the version from JSON by key", () => { + expect(readVersionFromJson('{"version": "1.2.3"}', "version")).toBe("1.2.3") + expect(readVersionFromJson('{"version": 12}', "version")).toBeNull() + expect(readVersionFromJson('{"other": "1.0.0"}', "version")).toBeNull() + expect(readVersionFromJson("not json", "version")).toBeNull() + }) + + it("updates the version in JSON by key", () => { + expect(updateVersionInJson('{"version": "1.2.3"}', "version", "1.3.0")).toContain('"version": "1.3.0"') + expect(updateVersionInJson('{"version": "1.2.3"}', "version", "1.3.0")).not.toContain("1.2.3") + }) + + it("preserves a trailing newline when updating", () => { + expect(updateVersionInJson('{\n "version": "1.2.3"\n}\n', "version", "1.3.0").endsWith("\n")).toBe(true) + }) +}) From 99574557a19cdc13beb7a3d0e3f2dec41f4204a3 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:51:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(plugin):=20release-control=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=9B=9B=20op=EF=BC=88Issue=20#110=20=E6=89=B9=2011?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin/release-control.ts | 323 ++++++++++++++++++++++++++++ test/plugin/release-control.test.ts | 292 +++++++++++++++++++++++++ 2 files changed, 615 insertions(+) create mode 100644 src/plugin/release-control.ts create mode 100644 test/plugin/release-control.test.ts diff --git a/src/plugin/release-control.ts b/src/plugin/release-control.ts new file mode 100644 index 0000000..edc9f9d --- /dev/null +++ b/src/plugin/release-control.ts @@ -0,0 +1,323 @@ +import { exec } from "node:child_process" +import { promisify } from "node:util" +import { readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tool } from "@opencode-ai/plugin/tool" +import { gh as ghCli } from "../util/gh.js" +import { escapeShellArg } from "../util/shell.js" +import { readProjectProfile } from "../kernel/profile.js" +import type { ProjectProfile } from "../kernel/profile.js" +import { + classifyChanges, + buildTag, + buildReleaseNotes, + parseVersion, + parseVersionFileSpec, + proposeVersion, + readVersionFromJson, + updateVersionInJson, + latestTag, + listCommitsSinceTag, + validateTagImmutability, +} from "../kernel/release.js" +import type { GhFn, ReleaseChange } from "../kernel/release.js" + +// ─── 可替换 executor(用于测试) ─── + +export type GitFn = (args: string, cwd: string) => Promise<{ stdout: string; stderr: string }> + +let releaseGhExecutor: GhFn | null = null +let releaseGitExecutor: GitFn | null = null + +export function setReleaseControlGhExecutor(fn: GhFn | null): void { + releaseGhExecutor = fn +} + +export function setReleaseControlGitExecutor(fn: GitFn | null): void { + releaseGitExecutor = fn +} + +const execAsync = promisify(exec) + +async function runGh(args: string): Promise<{ stdout: string; stderr: string }> { + if (releaseGhExecutor) return releaseGhExecutor(args) + return ghCli(args) +} + +async function runGit(cwd: string, args: string): Promise<{ stdout: string; stderr: string }> { + if (releaseGitExecutor) return releaseGitExecutor(args, cwd) + return execAsync(`git ${args}`, { cwd }) +} + +// ─── 内部辅助 ─── + +export type ReleaseControlOp = "propose-version" | "open-release-pr" | "merge-release-pr" | "monitor" + +export interface ReleaseControlDeps { + projectDir: string +} + +function releaseBranch(version: string): string { + return `release/v${version}` +} + +/** 聚合上一 tag 后 main 的变更并分类;无 tag 时返回空变更集 */ +async function collectChanges(profile: ProjectProfile): Promise<{ tag: string | null; changes: ReleaseChange[] }> { + const tag = await latestTag(runGh) + if (!tag) return { tag: null, changes: [] } + const commits = await listCommitsSinceTag(tag, "main", runGh) + return { tag, changes: classifyChanges(commits) } +} + +async function readCurrentVersion(profile: ProjectProfile, projectDir: string): Promise { + const spec = parseVersionFileSpec(profile.versionFile ?? "") + if (!spec) return null + try { + const raw = await readFile(join(projectDir, spec.path), "utf8") + return readVersionFromJson(raw, spec.key) + } catch { + return null + } +} + +async function writeVersionFile(profile: ProjectProfile, projectDir: string, version: string): Promise { + const spec = parseVersionFileSpec(profile.versionFile ?? "") + if (!spec) return "Error: Profile version file 配置无效(期望 `path[ $.key]`,如 `package.json $.version`)" + try { + const path = join(projectDir, spec.path) + const raw = await readFile(path, "utf8") + await writeFile(path, updateVersionInJson(raw, spec.key, version)) + return null + } catch (err) { + return `Error: 无法更新版本文件:${String(err)}` + } +} + +// ─── 工具 ─── + +/** + * release_control — 版本提议 / Release PR / 合并打 tag / release workflow 监控。 + * 技术栈无关:版本读写规则与 tag 格式全部来自 Profile(§9.1),不硬编码 npm。 + */ +export function createReleaseControlTool(deps: ReleaseControlDeps) { + return tool({ + description: `Control the release lifecycle (R9, tech-stack agnostic). + +Ops: +- propose-version: aggregate all changes on main since the last tag, classify them + (feature/fix/maintenance/docs + breaking), and propose a SemVer bump. + Unclassified changes block the proposal until classified. Returns the proposed + version and its tag. +- open-release-pr: create the release branch, bump the version file (per Profile + version write rule), generate Release Notes, push, and open a Release PR. +- merge-release-pr: after CI passes and human approval, merge the Release PR, + verify the merge SHA, and push the tag (tags are immutable, never re-pointed). +- monitor: poll the project GitHub Actions release workflow run until success; + a failed run is rerun once.`, + args: { + op: tool.schema.enum(["propose-version", "open-release-pr", "merge-release-pr", "monitor"]) + .describe("Release control operation"), + proposed_version: tool.schema.string().describe("Proposed semantic version (x.y.z), required for open-release-pr / merge-release-pr"), + release_notes: tool.schema.string().describe("Optional override for the Release Notes body"), + }, + async execute(args: Record): Promise { + const op = args.op as ReleaseControlOp + const profile = await readProjectProfile(deps.projectDir) + + switch (op) { + case "propose-version": + return proposeVersionOp(profile, deps.projectDir) + case "open-release-pr": + return openReleasePrOp(profile, args, deps.projectDir) + case "merge-release-pr": + return mergeReleasePrOp(profile, args) + case "monitor": + return monitorOp(profile) + default: + return `Error: unknown operation "${op}"` + } + }, + }) +} + +async function proposeVersionOp(profile: ProjectProfile, projectDir: string): Promise { + if (!profile.versionFile) { + return "Error: Profile 缺少 version file 配置(先运行 /setup 确认 Release Profile)" + } + + const current = await readCurrentVersion(profile, projectDir) + if (current === null) { + return `Error: 无法从版本文件(${profile.versionFile})读取当前版本` + } + + const { tag, changes } = await collectChanges(profile) + if (!tag) { + return "Error: 仓库尚无 tag,无法聚合上一 tag 后的变更;请先人工创建基线 tag" + } + + const unclassified = changes.filter(c => c.category === "unclassified") + if (unclassified.length > 0) { + const list = unclassified.map(c => `${c.sha.slice(0, 7)} ${c.title}`).join("\n") + return `Error: ${unclassified.length} 个未分类变更,版本提议前必须分类(propose-version 不产出版本号):\n${list}` + } + + const result = proposeVersion(current, changes) + if (!result.ok) { + return `Error: ${result.message}` + } + + const tagFormat = profile.tagFormat ?? "v{version}" + const nextTag = buildTag(result.proposed, tagFormat) + const summary = changes + .filter(c => c.category !== "unclassified") + .reduce>((acc, c) => { + acc[c.category] = (acc[c.category] ?? 0) + 1 + return acc + }, {}) + return [ + `Version proposal: ${current} → ${result.proposed} (${result.type} bump)`, + `Tag: ${nextTag}`, + `Changes since ${tag}: ${changes.length} (${Object.entries(summary).map(([k, v]) => `${k}: ${v}`).join(", ")})`, + "", + buildReleaseNotes(result.proposed, changes), + ].join("\n") +} + +async function openReleasePrOp(profile: ProjectProfile, args: Record, projectDir: string): Promise { + const version = String(args.proposed_version ?? "") + const parsed = parseVersion(version) + if (!parsed) { + return "Error: proposed_version 必填且必须为 x.y.z(先调用 propose-version 获取提议版本)" + } + if (!profile.versionFile) { + return "Error: Profile 缺少 version file 配置(先运行 /setup 确认 Release Profile)" + } + + const tagFormat = profile.tagFormat ?? "v{version}" + const tag = buildTag(version, tagFormat) + const branch = releaseBranch(version) + + const mainSha = (await runGh(`api repos/{owner}/{repo}/commits/main --jq .sha`)).stdout.trim() + const immutability = await validateTagImmutability(tag, mainSha, runGh) + if (!immutability.ok) { + return `Error: ${immutability.message}` + } + + try { + await runGit(projectDir, "fetch origin") + await runGit(projectDir, `checkout -b ${branch} origin/main`) + } catch (err) { + return `Error: 创建 release branch 失败:${String(err)}` + } + + const writeError = await writeVersionFile(profile, projectDir, version) + if (writeError) return writeError + + try { + await runGit(projectDir, `add ${parseVersionFileSpec(profile.versionFile)?.path}`) + await runGit(projectDir, `commit -m "release: ${tag}"`) + } catch (err) { + return `Error: 提交版本更新失败:${String(err)}` + } + + let notes = String(args.release_notes ?? "") + if (notes === "") { + const { changes } = await collectChanges(profile) + notes = buildReleaseNotes(version, changes) + } + + try { + await runGit(projectDir, `push -u origin ${branch}`) + } catch (err) { + return `Error: push release branch 失败:${String(err)}` + } + + try { + const { stdout } = await runGh( + `pr create --base main --head ${branch} --title 'release: ${tag}' --body '${escapeShellArg(notes)}' --json number --jq .number`, + ) + return `Release PR created: #${stdout.trim()} (branch ${branch}, tag ${tag})` + } catch (err) { + return `Error: 创建 Release PR 失败:${String(err)}` + } +} + +async function mergeReleasePrOp(profile: ProjectProfile, args: Record): Promise { + const version = String(args.proposed_version ?? "") + if (!parseVersion(version)) { + return "Error: proposed_version 必填且必须为 x.y.z" + } + + const branch = releaseBranch(version) + const prNumber = (await runGh(`pr list --head ${branch} --json number --jq '.[0].number'`)).stdout.trim() + if (!prNumber || prNumber === "null") { + return "Error: 未找到对应 Release PR(branch: " + branch + ")" + } + + try { + await runGh(`pr checks ${prNumber}`) + } catch { + return `Error: PR #${prNumber} 的 CI checks 未通过,无法合并` + } + + try { + await runGh(`pr merge ${prNumber} --squash --delete-branch`) + } catch (err) { + return `Error: 合并 Release PR 失败:${String(err)}` + } + + const mergeSha = (await runGh(`pr view ${prNumber} --json mergeCommit --jq '.mergeCommit.oid'`)).stdout.trim() + if (!mergeSha || mergeSha === "null") { + return "Error: 无法读取 merge commit SHA" + } + + const tagFormat = profile.tagFormat ?? "v{version}" + const tag = buildTag(version, tagFormat) + const immutability = await validateTagImmutability(tag, mergeSha, runGh) + if (!immutability.ok) { + return `Error: ${immutability.message}` + } + + try { + await runGh(`api repos/{owner}/{repo}/git/refs -f ref=refs/tags/${tag} -f sha=${mergeSha}`) + } catch (err) { + return `Error: 打 tag 失败(可能已存在):${String(err)}` + } + + return `Merged #${prNumber} and tagged ${tag} @ ${mergeSha.slice(0, 7)}` +} + +async function monitorOp(profile: ProjectProfile): Promise { + const workflow = profile.releaseWorkflowPath + if (!workflow) { + return "Error: Profile 缺少 release workflow 配置(release-ready 未满足,先运行 /setup)" + } + + const { stdout } = await runGh( + `run list --workflow ${workflow} --limit 1 --json databaseId,status,conclusion,headBranch --jq '.[0]'`, + ) + const trimmed = stdout.trim() + if (!trimmed || trimmed === "null") { + return "No release workflow runs found yet." + } + + const run = JSON.parse(trimmed) as { databaseId: number; status: string; conclusion: string | null; headBranch: string } + if (run.status !== "completed") { + return `Release workflow run #${run.databaseId} is ${run.status} (branch ${run.headBranch}) — not finished, check again later` + } + + if (run.conclusion === "success") { + return `Release workflow run #${run.databaseId} succeeded — release complete` + } + + if (run.conclusion === "failure") { + try { + await runGh(`run rerun ${run.databaseId}`) + return `Release workflow run #${run.databaseId} failed — rerun triggered, check again later` + } catch (err) { + return `Release workflow run #${run.databaseId} failed and rerun failed: ${String(err)}` + } + } + + return `Release workflow run #${run.databaseId} ${run.conclusion} — needs human intervention (Corrective Flow + new version)` +} diff --git a/test/plugin/release-control.test.ts b/test/plugin/release-control.test.ts new file mode 100644 index 0000000..3063036 --- /dev/null +++ b/test/plugin/release-control.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, afterEach } from "vitest" +import { mkdtemp, writeFile, rm, readFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createReleaseControlTool, setReleaseControlGhExecutor, setReleaseControlGitExecutor } from "../../src/plugin/release-control.js" + +const PROFILE = `## Project Profile + +- version bump rule: \`breaking→major, feature→minor, fix→patch\` +- version file: \`package.json\` +- tag format: \`v{version}\` +- release workflow: \`.github/workflows/release.yml\` +` + +type GhResult = { stdout: string; stderr: string } + +async function withProject(agentMd: string | null, pkg: Record, fn: (dir: string) => Promise) { + const dir = await mkdtemp(join(tmpdir(), "cabbage-release-control-")) + try { + if (agentMd !== null) { + await writeFile(join(dir, "AGENTS.md"), agentMd) + } + await writeFile(join(dir, "package.json"), JSON.stringify(pkg, null, 2)) + await fn(dir) + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +/** 前缀匹配的 gh mock:精确 key 或前缀(key 以 * 结尾) */ +function mockGh(responses: Record GhResult)>, calls: string[] = []) { + setReleaseControlGhExecutor(args => { + calls.push(args) + for (const [key, value] of Object.entries(responses)) { + const matchKey = key.endsWith("*") ? key.slice(0, -1) : key + if (key.endsWith("*") ? args.startsWith(matchKey) : args === matchKey) { + if (typeof value === "function") return Promise.resolve(value()) + return Promise.resolve({ stdout: value, stderr: "" }) + } + } + return Promise.resolve({ stdout: "", stderr: "" }) + }) +} + +afterEach(() => { + setReleaseControlGhExecutor(null) + setReleaseControlGitExecutor(null) +}) + +describe("release_control tool", () => { + const mainHeadSha = "mainheadsha" + const gitCalls: string[] = [] + + function setupGitMock() { + setReleaseControlGitExecutor((args, cwd) => { + gitCalls.push(`${cwd}|${args}`) + return Promise.resolve({ stdout: "", stderr: "" }) + }) + } + + async function call( + dir: string, + op: string, + extra: Record = {}, + ) { + const tool = createReleaseControlTool({ projectDir: dir }) + return tool.execute({ op, ...extra }, {} as any) + } + + it("rejects an unknown op", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const out = await call(dir, "bogus") + expect(String(out)).toContain("Error") + }) + }) + + describe("propose-version", () => { + it("aggregates changes, classifies, and proposes a patch bump", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v1.4.2", + "api repos/{owner}/{repo}/compare/v1.4.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": + `[{"sha":"aaa","title":"fix(core): bug"},{"sha":"bbb","title":"docs: readme"}]`, + }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("1.4.2 → 1.4.3") + expect(out).toContain("Tag: v1.4.3") + expect(out).toContain("Changes since v1.4.2: 2") + }) + }) + + it("proposes a minor bump for features", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v1.4.2", + "api repos/{owner}/{repo}/compare/v1.4.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": + `[{"sha":"aaa","title":"feat(kernel): add release aggregation"}]`, + }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("1.4.2 → 1.5.0") + }) + }) + + it("proposes a major bump for 0.x breaking changes", async () => { + await withProject(PROFILE, { version: "0.3.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v0.3.2", + "api repos/{owner}/{repo}/compare/v0.3.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": + `[{"sha":"aaa","title":"feat!: remove old flowrun"}]`, + }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("0.3.2 → 1.0.0") + }) + }) + + it("gate: asks the user to classify unclassified changes", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v1.4.2", + "api repos/{owner}/{repo}/compare/v1.4.2...main --jq '.commits[] | {sha: .sha, title: (.commit.message | split(\"\\n\")[0])}'": + `[{"sha":"aaa","title":"fix(core): bug"},{"sha":"bbb","title":"random commit"}]`, + }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("Error") + expect(out).toContain("未分类") + expect(out).toContain("random commit") + }) + }) + + it("requires the Profile version file config", async () => { + await withProject(null, { version: "1.4.2" }, async dir => { + mockGh({ "api repos/{owner}/{repo}/tags --jq '.[0].name'": "v1.4.2" }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("Error") + }) + }) + + it("fails when the repo has no tags yet", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ "api repos/{owner}/{repo}/tags --jq '.[0].name'": "" }) + const out = String(await call(dir, "propose-version")) + expect(out).toContain("Error") + expect(out).toContain("tag") + }) + }) + }) + + describe("open-release-pr", () => { + it("requires proposed_version", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const out = String(await call(dir, "open-release-pr")) + expect(out).toContain("Error") + expect(out).toContain("proposed_version") + }) + }) + + it("creates the release branch, bumps the version file, and opens a PR", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/commits/main --jq .sha": mainHeadSha, + "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "", + "pr create --base main --head release/v1.5.0 --title 'release: v1.5.0' --body '*": "42", + }) + setupGitMock() + gitCalls.length = 0 + + const out = String(await call(dir, "open-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("#42") + expect(out).toContain("release/v1.5.0") + + // 版本文件已更新(技术栈无关:读取 package.json 的 version 字段) + const updated = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) + expect(updated.version).toBe("1.5.0") + + expect(gitCalls.some(c => c.endsWith("|fetch origin"))).toBe(true) + expect(gitCalls.some(c => c.includes("checkout -b release/v1.5.0"))).toBe(true) + expect(gitCalls.some(c => c.includes("push -u origin release/v1.5.0"))).toBe(true) + }) + }) + + it("rejects re-pointing an existing tag", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "api repos/{owner}/{repo}/commits/main --jq .sha": mainHeadSha, + "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "othersha", + }) + const out = String(await call(dir, "open-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("Error") + expect(out).toContain("immutable") + }) + }) + + it("rejects an invalid proposed version", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const out = String(await call(dir, "open-release-pr", { proposed_version: "v1.5.0" })) + expect(out).toContain("Error") + }) + }) + }) + + describe("merge-release-pr", () => { + it("merges the release PR, verifies the merge SHA, and pushes the tag", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const ghCalls: string[] = [] + mockGh({ + "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", + "pr checks 12": "", + "pr merge 12 --squash --delete-branch": "", + "pr view 12 --json mergeCommit --jq '.mergeCommit.oid'": "deadbeef", + "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "", + "api repos/{owner}/{repo}/git/refs -f ref=refs/tags/v1.5.0 -f sha=deadbeef": "", + }, ghCalls) + + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("Merged #12") + expect(out).toContain("tagged v1.5.0") + expect(out).toContain("deadbee") + expect(ghCalls.some(c => c.startsWith("pr checks"))).toBe(true) + expect(ghCalls.some(c => c.includes("git/refs"))).toBe(true) + }) + }) + + it("refuses when the release PR does not exist", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "" }) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("Error") + }) + }) + + it("refuses when CI checks have not passed", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", + "pr checks 12": () => { + throw new Error("checks failed") + }, + }) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("Error") + expect(out).toContain("CI") + }) + }) + }) + + describe("monitor", () => { + it("requires the release workflow config", async () => { + await withProject("## Project Profile\n\n- version file: `package.json`\n", { version: "1.4.2" }, async dir => { + const out = String(await call(dir, "monitor")) + expect(out).toContain("Error") + expect(out).toContain("workflow") + }) + }) + + it("reports success when the latest workflow run succeeded", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "run list --workflow .github/workflows/release.yml --limit 1 --json databaseId,status,conclusion,headBranch --jq '.[0]'": + `{"databaseId": 77, "status": "completed", "conclusion": "success", "headBranch": "main"}`, + }) + const out = String(await call(dir, "monitor")) + expect(out).toContain("succeeded") + }) + }) + + it("reports pending while the run is not completed", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "run list --workflow .github/workflows/release.yml --limit 1 --json databaseId,status,conclusion,headBranch --jq '.[0]'": + `{"databaseId": 77, "status": "in_progress", "conclusion": null, "headBranch": "release/v1.5.0"}`, + }) + const out = String(await call(dir, "monitor")) + expect(out).toContain("in_progress") + }) + }) + + it("reruns a failed run once", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const ghCalls: string[] = [] + mockGh({ + "run list --workflow .github/workflows/release.yml --limit 1 --json databaseId,status,conclusion,headBranch --jq '.[0]'": + `{"databaseId": 77, "status": "completed", "conclusion": "failure", "headBranch": "release/v1.5.0"}`, + "run rerun 77": "", + }, ghCalls) + const out = String(await call(dir, "monitor")) + expect(out).toContain("failed") + expect(out).toContain("rerun") + expect(ghCalls.some(c => c.startsWith("run rerun 77"))).toBe(true) + }) + }) + }) +}) From 70cb9ad094ecfd656cf10e39febed9dfc5ce094e Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:06:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(plugin):=20release=5Fcontrol=20?= =?UTF-8?q?=E2=80=94=20caller=20=E9=97=A8=E7=A6=81=20+=20=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E6=89=B9=E5=87=86=20+=20statusCheckRollup=20CI=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin/release-control.ts | 41 ++++++++++++++++++++++++++--- test/plugin/release-control.test.ts | 41 ++++++++++++++++++++++------- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/plugin/release-control.ts b/src/plugin/release-control.ts index edc9f9d..dce537c 100644 --- a/src/plugin/release-control.ts +++ b/src/plugin/release-control.ts @@ -7,6 +7,8 @@ import { gh as ghCli } from "../util/gh.js" import { escapeShellArg } from "../util/shell.js" import { readProjectProfile } from "../kernel/profile.js" import type { ProjectProfile } from "../kernel/profile.js" +import { requireCaller } from "../kernel/caller.js" +import type { CallerSessionClient } from "../kernel/caller.js" import { classifyChanges, buildTag, @@ -55,6 +57,7 @@ export type ReleaseControlOp = "propose-version" | "open-release-pr" | "merge-re export interface ReleaseControlDeps { projectDir: string + sessionClient?: CallerSessionClient } function releaseBranch(version: string): string { @@ -119,9 +122,22 @@ Ops: .describe("Release control operation"), proposed_version: tool.schema.string().describe("Proposed semantic version (x.y.z), required for open-release-pr / merge-release-pr"), release_notes: tool.schema.string().describe("Optional override for the Release Notes body"), + user_confirmed: tool.schema.boolean().describe("Human approval — required for merge-release-pr (release is a manual flow)"), }, - async execute(args: Record): Promise { + async execute(args: Record, ctx: any): Promise { const op = args.op as ReleaseControlOp + + // caller 门禁:release 为人工流程,仅 primary 可调用(§2.2/§2.3) + if (deps.sessionClient && ctx?.sessionID) { + const denied = await requireCaller( + { agent: ctx.agent, sessionID: ctx.sessionID }, + ["primary"], + op, + deps.sessionClient, + ) + if (denied) return `Error: ${denied}` + } + const profile = await readProjectProfile(deps.projectDir) switch (op) { @@ -248,16 +264,35 @@ async function mergeReleasePrOp(profile: ProjectProfile, args: Record c === "SUCCESS" || c === "NEUTRAL" || c === "SKIPPED")) { + return `Error: PR #${prNumber} 的 CI checks 尚未全部完成(conclusions: ${conclusions.join(", ")}),请稍后重试` } try { diff --git a/test/plugin/release-control.test.ts b/test/plugin/release-control.test.ts index 3063036..9f23604 100644 --- a/test/plugin/release-control.test.ts +++ b/test/plugin/release-control.test.ts @@ -64,7 +64,7 @@ describe("release_control tool", () => { extra: Record = {}, ) { const tool = createReleaseControlTool({ projectDir: dir }) - return tool.execute({ op, ...extra }, {} as any) + return tool.execute({ op, ...extra } as any, {} as any) } it("rejects an unknown op", async () => { @@ -204,43 +204,64 @@ describe("release_control tool", () => { const ghCalls: string[] = [] mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", - "pr checks 12": "", + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + '["SUCCESS"]', "pr merge 12 --squash --delete-branch": "", "pr view 12 --json mergeCommit --jq '.mergeCommit.oid'": "deadbeef", "api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "", "api repos/{owner}/{repo}/git/refs -f ref=refs/tags/v1.5.0 -f sha=deadbeef": "", }, ghCalls) - const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) expect(out).toContain("Merged #12") expect(out).toContain("tagged v1.5.0") expect(out).toContain("deadbee") - expect(ghCalls.some(c => c.startsWith("pr checks"))).toBe(true) + expect(ghCalls.some(c => c.includes("statusCheckRollup"))).toBe(true) expect(ghCalls.some(c => c.includes("git/refs"))).toBe(true) }) }) + it("refuses without human approval (user_confirmed)", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + expect(out).toContain("Error") + expect(out).toContain("user_confirmed") + }) + }) + it("refuses when the release PR does not exist", async () => { await withProject(PROFILE, { version: "1.4.2" }, async dir => { mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "" }) - const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) expect(out).toContain("Error") }) }) - it("refuses when CI checks have not passed", async () => { + it("refuses when CI checks have failed", async () => { await withProject(PROFILE, { version: "1.4.2" }, async dir => { mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", - "pr checks 12": () => { - throw new Error("checks failed") - }, + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + '["FAILURE"]', }) - const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" })) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) expect(out).toContain("Error") expect(out).toContain("CI") }) }) + + it("refuses when no CI checks are reported", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + "[]", + }) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) + expect(out).toContain("Error") + expect(out).toContain("没有任何 CI checks") + }) + }) }) describe("monitor", () => { From 5cd0dd593c311d727403675ab8bd38d2c268d8c4 Mon Sep 17 00:00:00 2001 From: devcxl <64475363+devcxl@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:11:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(plugin):=20release=5Fcontrol=20?= =?UTF-8?q?=E2=80=94=20CI=20in-progress=20=E9=97=A8=E7=A6=81=20+=20caller?= =?UTF-8?q?=20fail-closed=20+=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin/release-control.ts | 58 +++++++++++++++++------------ test/plugin/release-control.test.ts | 49 +++++++++++++++++++++--- 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/src/plugin/release-control.ts b/src/plugin/release-control.ts index dce537c..bcee757 100644 --- a/src/plugin/release-control.ts +++ b/src/plugin/release-control.ts @@ -57,7 +57,7 @@ export type ReleaseControlOp = "propose-version" | "open-release-pr" | "merge-re export interface ReleaseControlDeps { projectDir: string - sessionClient?: CallerSessionClient + sessionClient: CallerSessionClient } function releaseBranch(version: string): string { @@ -125,10 +125,10 @@ Ops: user_confirmed: tool.schema.boolean().describe("Human approval — required for merge-release-pr (release is a manual flow)"), }, async execute(args: Record, ctx: any): Promise { - const op = args.op as ReleaseControlOp + try { + const op = args.op as ReleaseControlOp - // caller 门禁:release 为人工流程,仅 primary 可调用(§2.2/§2.3) - if (deps.sessionClient && ctx?.sessionID) { + // caller 门禁:release 为人工流程,仅 primary 可调用(§2.2/§2.3),fail-closed const denied = await requireCaller( { agent: ctx.agent, sessionID: ctx.sessionID }, ["primary"], @@ -136,21 +136,23 @@ Ops: deps.sessionClient, ) if (denied) return `Error: ${denied}` - } - const profile = await readProjectProfile(deps.projectDir) - - switch (op) { - case "propose-version": - return proposeVersionOp(profile, deps.projectDir) - case "open-release-pr": - return openReleasePrOp(profile, args, deps.projectDir) - case "merge-release-pr": - return mergeReleasePrOp(profile, args) - case "monitor": - return monitorOp(profile) - default: - return `Error: unknown operation "${op}"` + const profile = await readProjectProfile(deps.projectDir) + + switch (op) { + case "propose-version": + return proposeVersionOp(profile, deps.projectDir) + case "open-release-pr": + return openReleasePrOp(profile, args, deps.projectDir) + case "merge-release-pr": + return mergeReleasePrOp(profile, args) + case "monitor": + return monitorOp(profile) + default: + return `Error: unknown operation "${op}"` + } + } catch (err) { + return `Error: INTERNAL_ERROR — ${String(err)}` } }, }) @@ -275,9 +277,9 @@ async function mergeReleasePrOp(profile: ProjectProfile, args: Record c === "SUCCESS" || c === "NEUTRAL" || c === "SKIPPED")) { - return `Error: PR #${prNumber} 的 CI checks 尚未全部完成(conclusions: ${conclusions.join(", ")}),请稍后重试` + return `Error: PR #${prNumber} 的 CI checks 存在未知结论(${conclusions.join(", ")}),无法合并` } try { @@ -331,12 +336,17 @@ async function monitorOp(profile: ProjectProfile): Promise { const { stdout } = await runGh( `run list --workflow ${workflow} --limit 1 --json databaseId,status,conclusion,headBranch --jq '.[0]'`, ) - const trimmed = stdout.trim() - if (!trimmed || trimmed === "null") { + const runJson = stdout.trim() + if (!runJson || runJson === "null") { return "No release workflow runs found yet." } - const run = JSON.parse(trimmed) as { databaseId: number; status: string; conclusion: string | null; headBranch: string } + let run: { databaseId: number; status: string; conclusion: string | null; headBranch: string } + try { + run = JSON.parse(runJson) as { databaseId: number; status: string; conclusion: string | null; headBranch: string } + } catch { + return "Error: 无法解析 release workflow run 状态(gh 输出异常)" + } if (run.status !== "completed") { return `Release workflow run #${run.databaseId} is ${run.status} (branch ${run.headBranch}) — not finished, check again later` } diff --git a/test/plugin/release-control.test.ts b/test/plugin/release-control.test.ts index 9f23604..d34c065 100644 --- a/test/plugin/release-control.test.ts +++ b/test/plugin/release-control.test.ts @@ -58,13 +58,31 @@ describe("release_control tool", () => { }) } + /** 构造 session client:primary 无 parentID;developer 有 parentID */ + function sessionClient(caller: "primary" | "developer") { + const parentID = caller === "developer" ? "parent-session" : null + return { + session: { + async get({ sessionID }: { sessionID: string }) { + return { data: { parentID } } + }, + }, + } + } + async function call( dir: string, op: string, extra: Record = {}, + opts: { caller?: "primary" | "developer" } = {}, ) { - const tool = createReleaseControlTool({ projectDir: dir }) - return tool.execute({ op, ...extra } as any, {} as any) + const caller = opts.caller ?? "primary" + const tool = createReleaseControlTool({ + projectDir: dir, + sessionClient: sessionClient(caller), + }) + const agent = caller === "developer" ? "developer" : "dev-lifecycle" + return tool.execute({ op, ...extra } as any, { agent, sessionID: "sess_x" } as any) } it("rejects an unknown op", async () => { @@ -74,6 +92,14 @@ describe("release_control tool", () => { }) }) + it("rejects a non-primary caller with CALLER_NOT_AUTHORIZED", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + const out = await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }, { caller: "developer" }) + expect(String(out)).toContain("Error") + expect(String(out)).toContain("CALLER_NOT_AUTHORIZED") + }) + }) + describe("propose-version", () => { it("aggregates changes, classifies, and proposes a patch bump", async () => { await withProject(PROFILE, { version: "1.4.2" }, async dir => { @@ -204,7 +230,7 @@ describe("release_control tool", () => { const ghCalls: string[] = [] mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", - "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | if .conclusion == null then \"IN_PROGRESS\" else .conclusion end] | unique'": '["SUCCESS"]', "pr merge 12 --squash --delete-branch": "", "pr view 12 --json mergeCommit --jq '.mergeCommit.oid'": "deadbeef", @@ -241,7 +267,7 @@ describe("release_control tool", () => { await withProject(PROFILE, { version: "1.4.2" }, async dir => { mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", - "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | if .conclusion == null then \"IN_PROGRESS\" else .conclusion end] | unique'": '["FAILURE"]', }) const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) @@ -250,11 +276,24 @@ describe("release_control tool", () => { }) }) + it("refuses when CI checks are still in progress", async () => { + await withProject(PROFILE, { version: "1.4.2" }, async dir => { + mockGh({ + "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | if .conclusion == null then \"IN_PROGRESS\" else .conclusion end] | unique'": + '["IN_PROGRESS", "SUCCESS"]', + }) + const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true })) + expect(out).toContain("Error") + expect(out).toContain("尚未全部完成") + }) + }) + it("refuses when no CI checks are reported", async () => { await withProject(PROFILE, { version: "1.4.2" }, async dir => { mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12", - "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'": + "pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | if .conclusion == null then \"IN_PROGRESS\" else .conclusion end] | unique'": "[]", }) const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }))