diff --git a/bun.lock b/bun.lock index f41e3f9..9d31221 100644 --- a/bun.lock +++ b/bun.lock @@ -138,7 +138,7 @@ }, "packages/dev-workflow": { "name": "@ageflow/dev-workflow", - "version": "0.0.13", + "version": "0.0.15", "dependencies": { "@ageflow/core": "^0.6.0", "@ageflow/executor": "^0.7.0", diff --git a/packages/dev-workflow/__tests__/pipelines.test.ts b/packages/dev-workflow/__tests__/pipelines.test.ts index c099e79..d556579 100644 --- a/packages/dev-workflow/__tests__/pipelines.test.ts +++ b/packages/dev-workflow/__tests__/pipelines.test.ts @@ -1,11 +1,17 @@ // Smoke tests for the pipeline factories — confirms that `feature`, `bugfix`, -// and `docs` build valid DAGs at definition time with the role prompts -// loaded from disk. +// `docs`, and `release` build valid DAGs at definition time. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createBugfixPipeline } from "../pipelines/bugfix.js"; import { createDocsPipeline } from "../pipelines/docs.js"; import { createFeaturePipeline } from "../pipelines/feature.js"; +import { + PUBLISH_ORDER, + bumpFn, + createReleasePipeline, + publishFn, + semverBump, +} from "../pipelines/release.js"; import type { WorkflowInput } from "../shared/types.js"; const FAKE_INPUT: WorkflowInput = { @@ -241,3 +247,152 @@ describe("docs pipeline", () => { expect(publish.dependsOn).toContain("review"); }); }); + +describe("release pipeline", () => { + it("builds a workflow named release-pipeline with 4 tasks", () => { + const wf = createReleasePipeline(FAKE_INPUT); + expect(wf.name).toBe("release-pipeline"); + const keys = Object.keys(wf.tasks).sort(); + expect(keys).toEqual(["bump", "changelog", "cleanup", "publish"]); + }); + + it("all 4 tasks are defineFunction (fn), not agent", () => { + const wf = createReleasePipeline(FAKE_INPUT); + for (const key of ["bump", "changelog", "publish", "cleanup"] as const) { + const task = wf.tasks[key] as { agent?: unknown; fn?: unknown }; + expect(task.fn).toBeDefined(); + expect(task.agent).toBeUndefined(); + } + }); + + it("bump has no dependsOn", () => { + const wf = createReleasePipeline(FAKE_INPUT); + const bump = wf.tasks.bump as { dependsOn?: readonly string[] }; + expect(bump.dependsOn).toBeUndefined(); + }); + + it("changelog dependsOn bump", () => { + const wf = createReleasePipeline(FAKE_INPUT); + const changelog = wf.tasks.changelog as { dependsOn?: readonly string[] }; + expect(changelog.dependsOn).toContain("bump"); + }); + + it("publish dependsOn changelog and bump", () => { + const wf = createReleasePipeline(FAKE_INPUT); + const publish = wf.tasks.publish as { dependsOn?: readonly string[] }; + expect(publish.dependsOn).toContain("changelog"); + expect(publish.dependsOn).toContain("bump"); + }); + + it("cleanup dependsOn publish and bump", () => { + const wf = createReleasePipeline(FAKE_INPUT); + const cleanup = wf.tasks.cleanup as { dependsOn?: readonly string[] }; + expect(cleanup.dependsOn).toContain("publish"); + expect(cleanup.dependsOn).toContain("bump"); + }); +}); + +describe("bumpFn.execute — P1-1 guard", () => { + it("throws when affectedPackages is empty", async () => { + await expect( + bumpFn.execute({ + issueNumber: 1, + labels: ["patch"], + issueBody: "no package references here", + worktreePath: "/tmp/fake-wt", + affectedPackages: [], + }), + ).rejects.toThrow("affectedPackages is empty"); + }); + + it("does not throw when affectedPackages has at least one entry", async () => { + // The package dir won't exist on disk, so bumps will be empty but no throw. + const result = await bumpFn.execute({ + issueNumber: 1, + labels: ["patch"], + issueBody: "@ageflow/core", + worktreePath: "/tmp/fake-wt-nonexistent", + affectedPackages: ["@ageflow/core"], + }); + // No throw — bumps empty because dir doesn't exist, bumpKind defaults patch. + expect(result.bumpKind).toBe("patch"); + expect(result.bumps).toEqual([]); + }); +}); + +describe("publishFn.execute — P1-3 throw on failure", () => { + it("throws when any npm publish fails (skipped.length > 0)", async () => { + // Mock execa to reject for @ageflow/core, succeed for nothing else. + vi.mock("execa", () => ({ + execa: vi + .fn() + .mockRejectedValue(new Error("E403 Forbidden — auth required")), + })); + + await expect( + publishFn.execute({ + bumps: [{ package: "@ageflow/core", before: "0.6.0", after: "0.6.1" }], + worktreePath: "/tmp/fake-wt-nonexistent", + plan: false, + }), + ).rejects.toThrow("publish failed for"); + + vi.restoreAllMocks(); + }); + + it("does not throw in plan:true mode (dry-run — no real publish)", async () => { + // plan:true path never calls execa, so no failures. + const result = await publishFn.execute({ + bumps: [{ package: "@ageflow/core", before: "0.6.0", after: "0.6.1" }], + worktreePath: "/tmp/fake-wt-nonexistent", + plan: true, + }); + expect(result.published).toContain("@ageflow/core"); + expect(result.skipped).toHaveLength(0); + }); +}); + +describe("PUBLISH_ORDER — P1-2 runner-anthropic included", () => { + it("contains @ageflow/runner-anthropic", () => { + expect(PUBLISH_ORDER).toContain("@ageflow/runner-anthropic"); + }); + + it("@ageflow/runner-anthropic appears after @ageflow/runner-api", () => { + const apiIdx = PUBLISH_ORDER.indexOf("@ageflow/runner-api"); + const anthropicIdx = PUBLISH_ORDER.indexOf("@ageflow/runner-anthropic"); + expect(apiIdx).toBeGreaterThanOrEqual(0); + expect(anthropicIdx).toBeGreaterThan(apiIdx); + }); + + it("@ageflow/runner-anthropic appears before @ageflow/testing", () => { + const anthropicIdx = PUBLISH_ORDER.indexOf("@ageflow/runner-anthropic"); + const testingIdx = PUBLISH_ORDER.indexOf("@ageflow/testing"); + expect(anthropicIdx).toBeLessThan(testingIdx); + }); +}); + +describe("semverBump", () => { + it("patch: increments patch, leaves major/minor", () => { + expect(semverBump("1.2.3", "patch")).toBe("1.2.4"); + expect(semverBump("0.0.0", "patch")).toBe("0.0.1"); + expect(semverBump("1.0.0", "patch")).toBe("1.0.1"); + }); + + it("minor: increments minor, resets patch", () => { + expect(semverBump("1.2.3", "minor")).toBe("1.3.0"); + expect(semverBump("0.5.9", "minor")).toBe("0.6.0"); + expect(semverBump("2.0.0", "minor")).toBe("2.1.0"); + }); + + it("major: increments major, resets minor + patch", () => { + expect(semverBump("1.2.3", "major")).toBe("2.0.0"); + expect(semverBump("0.9.9", "major")).toBe("1.0.0"); + expect(semverBump("3.4.5", "major")).toBe("4.0.0"); + }); + + it("throws on invalid semver string", () => { + expect(() => semverBump("not-a-version", "patch")).toThrow( + "invalid semver", + ); + }); +}); diff --git a/packages/dev-workflow/package.json b/packages/dev-workflow/package.json index ab3e128..8b5b823 100644 --- a/packages/dev-workflow/package.json +++ b/packages/dev-workflow/package.json @@ -1,6 +1,6 @@ { "name": "@ageflow/dev-workflow", - "version": "0.0.13", + "version": "0.0.15", "private": true, "type": "module", "scripts": { diff --git a/packages/dev-workflow/pipelines/release.ts b/packages/dev-workflow/pipelines/release.ts index 3dc2f8a..a6aab51 100644 --- a/packages/dev-workflow/pipelines/release.ts +++ b/packages/dev-workflow/pipelines/release.ts @@ -1,59 +1,387 @@ -// Release pipeline — CHANGELOG → BUMP → PUBLISH → ANNOUNCE. +// Release pipeline — BUMP → CHANGELOG → PUBLISH → CLEANUP. // -// Used for issues labelled `release`. Covers semver bumps, npm publish, -// and GitHub release creation for the @ageflow/* packages. +// All nodes are defineFunction — release mechanics are deterministic. +// An LLM-driven npm publish is a footgun (wrong package order → partial release). // -// Sub-PR 1: skeleton stub. All tasks are defineFunction no-ops returning {}. -// Real role-based agents (tech-writer, devops, ship) land in sub-PR 2. +// PR E: all 4 nodes are real defineFunction implementations. +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { defineFunction, defineWorkflowFactory } from "@ageflow/core"; +import { execa } from "execa"; import { z } from "zod"; import type { WorkflowInput } from "../shared/types.js"; -const noopFn = defineFunction({ - name: "noop", - input: z.object({}).passthrough(), - output: z.object({}), - execute: async () => ({}), +// Publish order — consumer packages last. Must match +// .claude/commands/ageflow-orchestrator.md#package-dependency-order. +const PUBLISH_ORDER = [ + "@ageflow/core", + "@ageflow/executor", + "@ageflow/runner-claude", + "@ageflow/runner-codex", + "@ageflow/runner-api", + "@ageflow/runner-anthropic", + "@ageflow/testing", + "@ageflow/server", + "@ageflow/mcp-server", + "@ageflow/learning", + "@ageflow/learning-sqlite", + "@ageflow/cli", +] as const; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function findPackageDir( + repoRoot: string, + pkgName: string, +): Promise { + const roots = [ + join(repoRoot, "packages"), + join(repoRoot, "packages/runners"), + ]; + for (const root of roots) { + let entries: string[] = []; + try { + entries = await readdir(root); + } catch { + continue; + } + for (const entry of entries) { + const pkgJsonPath = join(root, entry, "package.json"); + try { + const content = await readFile(pkgJsonPath, "utf8"); + const parsed = JSON.parse(content); + if (parsed.name === pkgName) return join(root, entry); + } catch { + // skip unreadable entries + } + } + } + return null; +} + +function semverBump( + current: string, + kind: "patch" | "minor" | "major", +): string { + const match = current.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) throw new Error(`invalid semver: ${current}`); + const [, majStr, minStr, patStr] = match; + let maj = Number(majStr); + let min = Number(minStr); + let pat = Number(patStr); + if (kind === "major") { + maj += 1; + min = 0; + pat = 0; + } else if (kind === "minor") { + min += 1; + pat = 0; + } else { + pat += 1; + } + return `${maj}.${min}.${pat}`; +} + +// ── Task functions ──────────────────────────────────────────────────────────── + +// BUMP — reads labels to determine semver kind, then rewrites package.json +// versions for each affected package. +const bumpFn = defineFunction({ + name: "bump", + input: z.object({ + issueNumber: z.number().int().positive(), + labels: z.array(z.string()), + issueBody: z.string(), + worktreePath: z.string(), + // Caller specifies which packages to bump. Empty = no-op; operator fills. + affectedPackages: z.array(z.string()), + }), + output: z.object({ + bumpKind: z.enum(["patch", "minor", "major"]), + bumps: z.array( + z.object({ + package: z.string(), + before: z.string(), + after: z.string(), + }), + ), + }), + execute: async (input) => { + if (input.affectedPackages.length === 0) { + throw new Error( + "affectedPackages is empty — no packages to bump. " + + "The release issue body must mention at least one @ageflow/.", + ); + } + + // Determine bump kind from labels + const labelLower = input.labels.map((l) => l.toLowerCase()); + let bumpKind: "patch" | "minor" | "major" = "patch"; + if (labelLower.includes("breaking") || labelLower.includes("major")) { + bumpKind = "major"; + } else if (labelLower.includes("feature") || labelLower.includes("minor")) { + bumpKind = "minor"; + } + + const bumps: { package: string; before: string; after: string }[] = []; + for (const pkg of input.affectedPackages) { + const pkgDir = await findPackageDir(input.worktreePath, pkg); + if (!pkgDir) continue; + + const pkgJsonPath = join(pkgDir, "package.json"); + const pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf8")); + const before = pkgJson.version as string; + const after = semverBump(before, bumpKind); + pkgJson.version = after; + await writeFile(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`); + + bumps.push({ package: pkg, before, after }); + } + + return { bumpKind, bumps }; + }, +}); + +// CHANGELOG — appends a dated section to CHANGELOG.md at repo root. +const changelogFn = defineFunction({ + name: "changelog", + input: z.object({ + bumpKind: z.enum(["patch", "minor", "major"]), + bumps: z.array( + z.object({ + package: z.string(), + before: z.string(), + after: z.string(), + }), + ), + worktreePath: z.string(), + }), + output: z.object({ + changelogPath: z.string(), + entryLines: z.number().int().nonnegative(), + }), + execute: async (input) => { + const changelogPath = join(input.worktreePath, "CHANGELOG.md"); + const today = new Date().toISOString().slice(0, 10); + const lines = [ + `## ${today} — ${input.bumpKind} release`, + "", + ...input.bumps.map((b) => `- \`${b.package}\` ${b.before} → ${b.after}`), + "", + ]; + + let existing = ""; + try { + existing = await readFile(changelogPath, "utf8"); + } catch { + // File does not exist yet — that's fine. + } + + const header = existing.startsWith("# ") ? "" : "# Changelog\n\n"; + await writeFile( + changelogPath, + `${header}${lines.join("\n")}${existing ? `\n${existing.replace(/^# Changelog\s*\n/, "")}` : ""}`, + ); + + return { changelogPath, entryLines: lines.length }; + }, +}); + +// PUBLISH — runs `npm publish` in PUBLISH_ORDER for each bumped package. +// plan: true prints commands without executing — safe dry-run mode. +const publishFn = defineFunction({ + name: "publish", + input: z.object({ + bumps: z.array( + z.object({ + package: z.string(), + before: z.string(), + after: z.string(), + }), + ), + worktreePath: z.string(), + // Safety: if true, only prints commands (no actual publish). + plan: z.boolean(), + }), + output: z.object({ + published: z.array(z.string()), + skipped: z.array(z.object({ package: z.string(), reason: z.string() })), + }), + execute: async (input) => { + const published: string[] = []; + const skipped: { package: string; reason: string }[] = []; + + const bumpedNames = new Set(input.bumps.map((b) => b.package)); + + for (const pkgName of PUBLISH_ORDER) { + if (!bumpedNames.has(pkgName)) continue; + + if (input.plan) { + // In plan mode, skip actual directory lookup — it's a dry-run. + console.log( + "[publish] would run: npm publish --access public (cwd: packages/...)", + ); + published.push(pkgName); + continue; + } + + const pkgDir = await findPackageDir(input.worktreePath, pkgName); + if (!pkgDir) { + skipped.push({ package: pkgName, reason: "package.json not found" }); + continue; + } + + try { + await execa("npm", ["publish", "--access", "public"], { cwd: pkgDir }); + published.push(pkgName); + } catch (err) { + skipped.push({ + package: pkgName, + reason: (err as Error).message.slice(0, 200), + }); + } + } + + if (!input.plan && skipped.length > 0) { + const details = skipped + .map((s) => `${s.package}: ${s.reason}`) + .join("; "); + throw new Error( + `publish failed for ${skipped.length} package(s): ${details}`, + ); + } + + return { published, skipped }; + }, +}); + +// CLEANUP — tags the release commit in the worktree. No auto-push — operator +// pushes the tag manually after confirming the publish succeeded. +const cleanupFn = defineFunction({ + name: "cleanup", + input: z.object({ + bumpKind: z.enum(["patch", "minor", "major"]), + bumps: z.array(z.object({ package: z.string(), after: z.string() })), + worktreePath: z.string(), + }), + output: z.object({ + tag: z.string(), + pushed: z.boolean(), + }), + execute: async (input) => { + const today = new Date().toISOString().slice(0, 10); + const tag = `release-${today}-${input.bumpKind}`; + const message = input.bumps + .map((b) => `${b.package}@${b.after}`) + .join("\n"); + + try { + await execa("git", ["tag", "-a", tag, "-m", message], { + cwd: input.worktreePath, + }); + } catch (err) { + console.warn(`[cleanup] tag failed: ${(err as Error).message}`); + } + + // Don't push the tag — operator does that manually after confirming publish. + return { tag, pushed: false }; + }, }); +// ── Pipeline factory ────────────────────────────────────────────────────────── + export const createReleasePipeline = defineWorkflowFactory( (input: WorkflowInput) => ({ name: "release-pipeline", tasks: { - // CHANGELOG — summarise commits since last tag into CHANGELOG.md. - // Sub-PR 2: replace with tech-writer agent reading git log. - changelog: { - fn: noopFn, - input: () => ({ issueNumber: input.issue.number }), + // BUMP — determine semver kind from labels; rewrite package.json versions. + bump: { + fn: bumpFn, + input: () => { + // Parse affected packages from the issue body. + // Convention: the release issue body mentions @ageflow/ names + // (e.g. in a fenced list or inline). De-duplicate with Set. + const pkgMatches = input.issue.body.match(/@ageflow\/[a-z-]+/g) ?? []; + const affectedPackages = [...new Set(pkgMatches)]; + return { + issueNumber: input.issue.number, + labels: [...input.issue.labels], + issueBody: input.issue.body, + worktreePath: input.worktreePath, + affectedPackages, + }; + }, }, - // BUMP — update package.json versions across affected packages. - // Sub-PR 2: replace with engineer agent following semver rules. - bump: { - fn: noopFn, - dependsOn: ["changelog"] as const, - input: () => ({ worktreePath: input.worktreePath }), + // CHANGELOG — append a dated section to CHANGELOG.md at repo root. + changelog: { + fn: changelogFn, + dependsOn: ["bump"] as const, + input: (ctx: { + bump: { + output: { + bumpKind: "patch" | "minor" | "major"; + bumps: readonly { + package: string; + before: string; + after: string; + }[]; + }; + }; + }) => ({ + bumpKind: ctx.bump.output.bumpKind, + bumps: [...ctx.bump.output.bumps], + worktreePath: input.worktreePath, + }), }, - // PUBLISH — bun publish for each changed package. - // Sub-PR 2: replace with devops agent + dry-run guard. + // PUBLISH — npm publish in PUBLISH_ORDER. dryRun gates plan:true mode. + // dependsOn both changelog (ordering) and bump (for the bumps list). publish: { - fn: noopFn, - dependsOn: ["bump"] as const, - input: () => ({ worktreePath: input.worktreePath }), + fn: publishFn, + dependsOn: ["changelog", "bump"] as const, + input: (ctx: { + bump: { + output: { + bumps: readonly { + package: string; + before: string; + after: string; + }[]; + }; + }; + }) => ({ + bumps: [...ctx.bump.output.bumps], + worktreePath: input.worktreePath, + plan: input.dryRun ?? false, + }), }, - // ANNOUNCE — create GitHub release + tag. - // Sub-PR 2: replace with ship agent calling gh release create. - announce: { - fn: noopFn, - dependsOn: ["publish"] as const, - input: () => ({ - issueNumber: input.issue.number, + // CLEANUP — git tag the release commit. Operator pushes the tag manually. + // dependsOn both publish (ordering) and bump (for bumpKind + bumps). + cleanup: { + fn: cleanupFn, + dependsOn: ["publish", "bump"] as const, + input: (ctx: { + bump: { + output: { + bumpKind: "patch" | "minor" | "major"; + bumps: readonly { package: string; after: string }[]; + }; + }; + }) => ({ + bumpKind: ctx.bump.output.bumpKind, + bumps: ctx.bump.output.bumps.map((b) => ({ + package: b.package, + after: b.after, + })), worktreePath: input.worktreePath, }), }, }, }), ); + +// Export helpers and task functions for use in tests. +export { semverBump, findPackageDir, bumpFn, publishFn, PUBLISH_ORDER };