diff --git a/.codex/flows/peezy-codex-flows-fork/exec/release-fork.ts b/.codex/flows/peezy-codex-flows-fork/exec/release-fork.ts new file mode 100644 index 0000000..07ff33d --- /dev/null +++ b/.codex/flows/peezy-codex-flows-fork/exec/release-fork.ts @@ -0,0 +1,395 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type FlowContext = { + flow: { + config?: Record; + event: { + id: string; + payload?: Record; + }; + }; +}; + +type CommandResult = { + code: number; + stdout: string; + stderr: string; +}; + +const context = JSON.parse(await Bun.stdin.text()) as FlowContext; +const config = context.flow.config ?? {}; +const payload = context.flow.event.payload ?? {}; + +function finish(value: Record): never { + process.stdout.write(`FLOW_RESULT ${JSON.stringify(value)}\n`); + process.exit(0); +} + +try { + const sourcePackage = stringValue(payload.packageName); + const sourceVersion = stringValue(payload.version); + const packageName = stringConfig("package_name", "@peezy.tech/codex-flows"); + const codexPackageName = stringConfig("codex_package_name", "@peezy.tech/codex"); + + if (!sourcePackage || !sourceVersion) { + finish({ status: "failed", message: "downstream.release requires packageName and version." }); + } + if (sourcePackage !== packageName && sourcePackage !== codexPackageName) { + finish({ status: "skipped", message: `Ignoring downstream release for ${sourcePackage}.` }); + } + + const repoRoot = path.resolve(envConfig(stringConfig("codex_flows_repo_env", "")) || stringConfig("codex_flows_repo", process.cwd())); + const sourceBranch = stringConfig("source_branch", "main"); + const forkBranch = stringConfig("fork_branch", "fork"); + const worktreeDir = path.resolve(repoRoot, stringConfig("worktree_dir", ".codex/flow-artifacts/codex-flows-fork-worktree")); + const artifactDir = path.resolve(repoRoot, stringConfig("artifact_dir", ".codex/flow-artifacts/codex-flows-fork-release")); + const fetchEnabled = enabled("fetch", true); + const commitEnabled = enabled("commit", true); + const pushEnabled = enabled("push", false); + const publishEnabled = enabled("publish", false); + const linkLocalPackage = enabled("link_local_package", false); + + await requireCleanRepo(repoRoot); + if (fetchEnabled) { + await runChecked("fetch source branch", ["git", "fetch", "origin", sourceBranch, "--prune"], repoRoot); + } + + const baseVersion = sourcePackage === packageName + ? sourceVersion + : await readPackageVersion(path.join(repoRoot, "packages/codex-client/package.json")); + const codexVersion = sourcePackage === codexPackageName + ? sourceVersion + : envConfig(stringConfig("codex_version_env", "")) || await npmPackageVersion(codexPackageName); + const forkVersion = forkPackageVersion(baseVersion, codexVersion); + const baseSha = (await runChecked("resolve source branch", ["git", "rev-parse", "--verify", `${sourceBranch}^{commit}`], repoRoot)).stdout.trim(); + + await prepareWorktree(repoRoot, worktreeDir, forkBranch, sourceBranch); + await applyForkOverlay({ + worktreeDir, + packageName, + codexPackageName, + codexVersion, + forkVersion, + }); + + await runChecked("install fork package dependency", ["bun", "install"], worktreeDir); + await runChecked("fork release check", ["bun", "run", "--filter", packageName, "release:check"], worktreeDir); + + await rm(artifactDir, { recursive: true, force: true }); + await mkdir(artifactDir, { recursive: true }); + const pack = await runChecked( + "pack fork release", + ["npm", "pack", "--pack-destination", artifactDir], + path.join(worktreeDir, "packages/codex-client"), + ); + const tarball = pack.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + const tarballPath = tarball ? path.join(artifactDir, tarball) : undefined; + + if (linkLocalPackage) { + await runChecked("link fork release package", ["bun", "pm", "link"], path.join(worktreeDir, "packages/codex-client")); + } + + const status = await runChecked("read fork diff", ["git", "status", "--porcelain"], worktreeDir); + let commitSha = ""; + if (commitEnabled && status.stdout.trim()) { + await runChecked("stage fork release changes", [ + "git", + "add", + "--", + "bun.lock", + "packages/codex-client/package.json", + "packages/codex-client/src/mode.ts", + "packages/codex-client/src/app-server/stdio-transport.ts", + "packages/codex-client/test/stdio-transport.test.ts", + ], worktreeDir); + await runChecked("commit fork release", [ + "git", + "commit", + "-m", + `release: codex-flows fork ${forkVersion}`, + ], worktreeDir); + commitSha = (await runChecked("read fork commit", ["git", "rev-parse", "HEAD"], worktreeDir)).stdout.trim(); + } else { + commitSha = (await runChecked("read fork head", ["git", "rev-parse", "HEAD"], worktreeDir)).stdout.trim(); + } + + let pushed = false; + if (pushEnabled) { + await runChecked("push fork branch", ["git", "push", "origin", `HEAD:refs/heads/${forkBranch}`, "--force-with-lease"], worktreeDir); + pushed = true; + } + + let published = false; + if (publishEnabled && tarballPath) { + await runChecked("publish fork package", [ + "npm", + "publish", + tarballPath, + "--access", + "public", + "--tag", + stringConfig("fork_dist_tag", "fork"), + ], worktreeDir); + published = true; + } + + finish({ + status: "changed", + message: `Prepared ${packageName} fork ${forkVersion} from ${sourcePackage}@${sourceVersion}.`, + artifacts: { + sourcePackage, + sourceVersion, + packageName, + baseVersion, + codexPackageName, + codexVersion, + forkVersion, + sourceBranch, + forkBranch, + baseSha, + commitSha, + worktreeDir, + tarballPath, + linked: linkLocalPackage, + pushed, + published, + candidateRefs: [{ + kind: "branch", + repo: "peezy-tech/codex-flows", + ref: `refs/heads/${forkBranch}`, + sha: commitSha, + pushed, + }], + }, + }); +} catch (error) { + finish({ + status: "failed", + message: error instanceof Error ? error.message : String(error), + }); +} + +async function requireCleanRepo(repoRoot: string): Promise { + const status = await runChecked("read repository status", ["git", "status", "--porcelain"], repoRoot); + const relevant = status.stdout + .split(/\r?\n/) + .filter((line) => line.trim()) + .filter((line) => !line.includes(".codex/flow-artifacts/")); + if (relevant.length > 0) { + finish({ + status: "blocked", + message: "codex-flows checkout has local changes before fork release preparation.", + artifacts: { status: relevant.join("\n") }, + }); + } +} + +async function prepareWorktree( + repoRoot: string, + worktreeDir: string, + forkBranch: string, + sourceBranch: string, +): Promise { + if (existsSync(worktreeDir)) { + await run("remove old fork worktree", ["git", "worktree", "remove", "--force", worktreeDir], repoRoot); + await rm(worktreeDir, { recursive: true, force: true }); + } + await run("prune worktrees", ["git", "worktree", "prune"], repoRoot); + await runChecked("create fork worktree", ["git", "worktree", "add", "--force", "-B", forkBranch, worktreeDir, sourceBranch], repoRoot); +} + +async function applyForkOverlay(input: { + worktreeDir: string; + packageName: string; + codexPackageName: string; + codexVersion: string; + forkVersion: string; +}): Promise { + const packageJsonPath = path.join(input.worktreeDir, "packages/codex-client/package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + packageJson.version = input.forkVersion; + packageJson.dependencies = sortRecord({ + ...(recordValue(packageJson.dependencies)), + [input.codexPackageName]: input.codexVersion, + }); + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, "\t")}\n`, "utf8"); + + const modePath = path.join(input.worktreeDir, "packages/codex-client/src/mode.ts"); + let modeText = await readFile(modePath, "utf8"); + if (!modeText.includes("CODEX_FLOWS_FORK_DEFAULT_CODE_MODE")) { + modeText = modeText.replace( + `export const DEFAULT_CODE_MODE_CODEX_PACKAGE = "${input.codexPackageName}";\n`, + `export const DEFAULT_CODE_MODE_CODEX_PACKAGE = "${input.codexPackageName}";\nexport const CODEX_FLOWS_FORK_DEFAULT_CODE_MODE = true;\n`, + ); + } + modeText = modeText.replace( + "return booleanEnv(env.CODEX_FLOWS_ENABLE_CODE_MODE) || codexFlowsMode(env) === CODEX_FLOWS_CODE_MODE;", + [ + "if (booleanEnv(env.CODEX_FLOWS_DISABLE_CODE_MODE)) {", + "\t\treturn false;", + "\t}", + "\treturn CODEX_FLOWS_FORK_DEFAULT_CODE_MODE || booleanEnv(env.CODEX_FLOWS_ENABLE_CODE_MODE) || codexFlowsMode(env) === CODEX_FLOWS_CODE_MODE;", + ].join("\n\t"), + ); + await writeFile(modePath, modeText, "utf8"); + + const transportPath = path.join(input.worktreeDir, "packages/codex-client/src/app-server/stdio-transport.ts"); + let transportText = await readFile(transportPath, "utf8"); + transportText = transportText.replace( + "return { command: DEFAULT_CODEX_COMMAND, args };", + [ + "return {", + "\t\tcommand: env.CODEX_APP_SERVER_BUNX_COMMAND?.trim() || \"bunx\",", + "\t\targs: [DEFAULT_CODE_MODE_CODEX_PACKAGE, ...args],", + "\t};", + ].join("\n"), + ); + await writeFile(transportPath, transportText, "utf8"); + + const testPath = path.join(input.worktreeDir, "packages/codex-client/test/stdio-transport.test.ts"); + let testText = await readFile(testPath, "utf8"); + testText = testText.replace( + [ + "expect(resolveCodexStdioCommand({}, {})).toEqual({", + "\t\tcommand: \"codex\",", + "\t\targs: [\"app-server\", \"--listen\", \"stdio://\", \"--enable\", \"apps\", \"--enable\", \"hooks\"],", + "\t});", + ].join("\n\t"), + [ + "expect(resolveCodexStdioCommand({}, {})).toEqual({", + "\t\tcommand: \"bunx\",", + "\t\targs: [", + "\t\t\tDEFAULT_CODEX_NPM_PACKAGE,", + "\t\t\t\"app-server\",", + "\t\t\t\"--listen\",", + "\t\t\t\"stdio://\",", + "\t\t\t\"--enable\",", + "\t\t\t\"apps\",", + "\t\t\t\"--enable\",", + "\t\t\t\"hooks\",", + "\t\t],", + "\t});", + ].join("\n\t"), + ); + testText = testText.replace( + [ + "expect(resolveCodexStdioCommand({}, { CODEX_FLOWS_ENABLE_CODE_MODE: \"1\" })).toEqual({", + "\t\tcommand: \"codex\",", + "\t\targs: [\"app-server\", \"--listen\", \"stdio://\", \"--enable\", \"apps\", \"--enable\", \"hooks\"],", + "\t});", + ].join("\n\t"), + [ + "expect(resolveCodexStdioCommand({}, { CODEX_FLOWS_ENABLE_CODE_MODE: \"1\" })).toEqual({", + "\t\tcommand: \"bunx\",", + "\t\targs: [", + "\t\t\tDEFAULT_CODEX_NPM_PACKAGE,", + "\t\t\t\"app-server\",", + "\t\t\t\"--listen\",", + "\t\t\t\"stdio://\",", + "\t\t\t\"--enable\",", + "\t\t\t\"apps\",", + "\t\t\t\"--enable\",", + "\t\t\t\"hooks\",", + "\t\t],", + "\t});", + ].join("\n\t"), + ); + await writeFile(testPath, testText, "utf8"); +} + +function forkPackageVersion(baseVersion: string, codexVersion: string): string { + const prefix = sanitizePrerelease(stringConfig("fork_version_prefix", "peezy")); + const codex = sanitizePrerelease(codexVersion); + return baseVersion.includes("-") + ? `${baseVersion}.${prefix}.${codex}` + : `${baseVersion}-${prefix}.${codex}`; +} + +function sanitizePrerelease(value: string): string { + return value + .replace(/^v/, "") + .replace(/[^0-9A-Za-z]+/g, ".") + .split(".") + .filter(Boolean) + .join(".") || "0"; +} + +async function readPackageVersion(packageJsonPath: string): Promise { + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string }; + if (!packageJson.version) { + throw new Error(`Could not read package version from ${packageJsonPath}`); + } + return packageJson.version; +} + +async function npmPackageVersion(packageName: string): Promise { + const result = await runChecked("read latest Codex fork package version", ["npm", "view", packageName, "version", "--json"], process.cwd()); + return JSON.parse(result.stdout) as string; +} + +async function runChecked(label: string, command: string[], cwd: string): Promise { + const result = await run(label, command, cwd); + if (result.code !== 0) { + throw new Error(`${label} failed with exit ${result.code}:\n${result.stderr || result.stdout}`); + } + return result; +} + +async function run(label: string, command: string[], cwd: string): Promise { + process.stderr.write(`+ ${label}: ${command.join(" ")}\n`); + const proc = Bun.spawn(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (stdout) process.stderr.write(stdout); + if (stderr) process.stderr.write(stderr); + return { code, stdout, stderr }; +} + +function enabled(name: string, fallback: boolean): boolean { + const envName = `CODEX_FLOW_${name.toUpperCase()}`; + const envValue = process.env[envName]; + if (envValue !== undefined) { + return booleanValue(envValue); + } + const value = config[name]; + if (typeof value === "boolean") return value; + if (typeof value === "string") return booleanValue(value); + return fallback; +} + +function stringConfig(name: string, fallback: string): string { + const value = config[name]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +function envConfig(name: string): string | undefined { + return name ? process.env[name]?.trim() || undefined : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function recordValue(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function sortRecord(value: Record): Record { + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))); +} + +function booleanValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} diff --git a/.codex/flows/peezy-codex-flows-fork/flow.toml b/.codex/flows/peezy-codex-flows-fork/flow.toml new file mode 100644 index 0000000..a0d61f9 --- /dev/null +++ b/.codex/flows/peezy-codex-flows-fork/flow.toml @@ -0,0 +1,33 @@ +name = "peezy-codex-flows-fork" +version = 1 +description = "Build a local fork release of codex-flows when Peezy Codex or codex-flows releases." + +[config] +package_name = "@peezy.tech/codex-flows" +codex_package_name = "@peezy.tech/codex" +codex_version_env = "PEEZY_CODEX_VERSION" +codex_flows_repo_env = "PEEZY_CODEX_FLOWS_REPO" +codex_flows_repo = "/home/peezy/meta-workspace/codex-flows" +source_branch = "main" +fork_branch = "fork" +fork_dist_tag = "fork" +fork_version_prefix = "peezy" +worktree_dir = ".codex/flow-artifacts/codex-flows-fork-worktree" +artifact_dir = ".codex/flow-artifacts/codex-flows-fork-release" +commit = true +push = false +publish = false +link_local_package = false + +[guidance] +skills = ["jojo-development-flow", "bun-flow-author"] + +[[steps]] +name = "release-fork" +runner = "bun" +script = "exec/release-fork.ts" +timeout_ms = 1200000 + +[steps.trigger] +type = "downstream.release" +schema = "schemas/downstream-release.schema.json" diff --git a/.codex/flows/peezy-codex-flows-fork/schemas/downstream-release.schema.json b/.codex/flows/peezy-codex-flows-fork/schemas/downstream-release.schema.json new file mode 100644 index 0000000..26f10fd --- /dev/null +++ b/.codex/flows/peezy-codex-flows-fork/schemas/downstream-release.schema.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required": ["packageName", "version"], + "properties": { + "packageName": { + "type": "string", + "enum": ["@peezy.tech/codex", "@peezy.tech/codex-flows"] + }, + "version": { "type": "string" }, + "tag": { "type": "string" }, + "repo": { + "type": "string", + "enum": ["peezy-tech/codex", "peezy-tech/codex-flows"] + }, + "provider": { "type": "string" }, + "sourceId": { "type": "string" }, + "entryId": { "type": "string" }, + "publishedAt": { "type": "string" }, + "url": { "type": "string" } + } +} diff --git a/.codex/pack-lock.json b/.codex/pack-lock.json index 1b02c32..6722dc1 100644 --- a/.codex/pack-lock.json +++ b/.codex/pack-lock.json @@ -7,12 +7,25 @@ "source": { "input": "../codex-flows", "type": "local", - "commit": "c3905c73feff72add4f88ba8ec5c11cb1921c386" + "commit": "58c91cfa12706213a072c6c67ae3910b6b95f120" }, "sourcePath": "flows/openai-codex-bindings", "destinationPath": ".codex/flows/openai-codex-bindings", "contentHash": "sha256:6bfd8118be031c145813dcacbf47f6939b2460aeeb0d8959075e6018297d0403", - "installedAt": "2026-05-18T19:14:31.421Z" + "installedAt": "2026-05-18T19:29:51.103Z" + }, + { + "name": "peezy-codex-flows-fork", + "kind": "flow", + "source": { + "input": "../codex-flows", + "type": "local", + "commit": "58c91cfa12706213a072c6c67ae3910b6b95f120" + }, + "sourcePath": "flows/peezy-codex-flows-fork", + "destinationPath": ".codex/flows/peezy-codex-flows-fork", + "contentHash": "sha256:149c21269fe4de463f9bdf8e8086f8aae9601c8f319d62c08749c8f65db4a465", + "installedAt": "2026-05-18T19:29:51.103Z" }, { "name": "peezy-codex-fork", @@ -20,12 +33,12 @@ "source": { "input": "../codex-flows", "type": "local", - "commit": "c3905c73feff72add4f88ba8ec5c11cb1921c386" + "commit": "58c91cfa12706213a072c6c67ae3910b6b95f120" }, "sourcePath": "flows/peezy-codex-fork", "destinationPath": ".codex/flows/peezy-codex-fork", "contentHash": "sha256:613f0733ebea22b191ed6e706ad09e05a2dc97aad16cdbe14db01065a1b06582", - "installedAt": "2026-05-18T19:14:31.421Z" + "installedAt": "2026-05-18T19:29:51.103Z" } ] } diff --git a/apps/patch/feed-sources.json b/apps/patch/feed-sources.json index fead476..dc3bc26 100644 --- a/apps/patch/feed-sources.json +++ b/apps/patch/feed-sources.json @@ -88,6 +88,54 @@ } }, "pollIntervalSeconds": 300 + }, + { + "id": "npm-peezy-codex-releases", + "provider": "npm", + "url": "https://registry.npmjs.org/@peezy.tech%2Fcodex", + "event": "release", + "repo": { + "owner": "@peezy.tech", + "name": "codex", + "fullName": "@peezy.tech/codex", + "webUrl": "https://www.npmjs.com/package/@peezy.tech/codex" + }, + "target": { + "mode": "workspace_flow", + "eventType": "downstream.release", + "workspaceUrlEnv": "PATCH_WORKSPACE_BACKEND_URL", + "workspaceSecretEnv": "PATCH_WORKSPACE_BACKEND_SECRET", + "payload": { + "provider": "npm", + "packageName": "@peezy.tech/codex", + "repo": "peezy-tech/codex" + } + }, + "pollIntervalSeconds": 300 + }, + { + "id": "npm-peezy-codex-flows-releases", + "provider": "npm", + "url": "https://registry.npmjs.org/@peezy.tech%2Fcodex-flows", + "event": "release", + "repo": { + "owner": "@peezy.tech", + "name": "codex-flows", + "fullName": "@peezy.tech/codex-flows", + "webUrl": "https://www.npmjs.com/package/@peezy.tech/codex-flows" + }, + "target": { + "mode": "workspace_flow", + "eventType": "downstream.release", + "workspaceUrlEnv": "PATCH_WORKSPACE_BACKEND_URL", + "workspaceSecretEnv": "PATCH_WORKSPACE_BACKEND_SECRET", + "payload": { + "provider": "npm", + "packageName": "@peezy.tech/codex-flows", + "repo": "peezy-tech/codex-flows" + } + }, + "pollIntervalSeconds": 300 } ] } diff --git a/apps/patch/src/cli.ts b/apps/patch/src/cli.ts index 2b53c80..6f5afd3 100644 --- a/apps/patch/src/cli.ts +++ b/apps/patch/src/cli.ts @@ -7,6 +7,7 @@ import { discoverFlows, matchingSteps, type FlowEvent as RuntimeFlowEvent } from import { dispatchWorkspaceEventDetailed, maintenanceAttemptForWorkspaceDispatch, + patchDownstreamReleaseEvent, patchUpstreamBranchUpdateEvent, patchUpstreamReleaseEvent, replayWorkspaceEventDetailed, @@ -55,6 +56,7 @@ Usage: patch.moi run harness [--event FILE] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--json] patch.moi run codex-release --tag TAG [--repo openai/codex] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--allow-local] [--json] patch.moi run codex-main [--sha SHA] [--repo openai/codex] [--ref refs/heads/main] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--allow-local] [--json] + patch.moi run downstream-release --package PACKAGE --version VERSION [--repo OWNER/NAME] [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--allow-local] [--json] patch.moi run event --file FILE [--workspace-root DIR] [--data-dir DIR] [--dry-run] [--record-only] [--json] patch.moi patch doctor [--repo DIR] [--main BRANCH] [--upstream BRANCH] [--json] patch.moi patch list [--repo DIR] [--prefix patch/] [--json] @@ -234,7 +236,7 @@ async function handleAttempts(context: CliContext): Promise { async function handleRun(positionals: string[], context: CliContext): Promise { const target = positionals[0]; if (!target) { - throw new UsageError("run requires harness, codex-release, or event"); + throw new UsageError("run requires harness, codex-release, codex-main, downstream-release, or event"); } if (target === "harness") { const eventFile = flagValue(context.parsed, "event") ?? @@ -267,6 +269,25 @@ async function handleRun(positionals: string[], context: CliContext): Promise entry.id); } +export function parseNpmPackageEntries(text: string): FeedEntry[] { + const parsed = JSON.parse(text) as { + name?: string; + time?: Record; + "dist-tags"?: Record; + }; + const packageName = parsed.name ?? "unknown-package"; + const time = parsed.time ?? {}; + return Object.entries(time) + .filter(([version]) => version !== "created" && version !== "modified") + .map(([version, publishedAt]) => ({ + id: `npm:${packageName}:${version}`, + title: version, + url: `https://www.npmjs.com/package/${encodeURIComponent(packageName)}/v/${encodeURIComponent(version)}`, + author: "npm", + publishedAt, + raw: JSON.stringify({ + packageName, + version, + distTags: distTagsForVersion(parsed["dist-tags"] ?? {}, version), + }), + })) + .sort((left, right) => new Date(right.publishedAt).getTime() - new Date(left.publishedAt).getTime()); +} + +function distTagsForVersion(distTags: Record, version: string): string[] { + return Object.entries(distTags) + .filter(([, tagVersion]) => tagVersion === version) + .map(([tag]) => tag) + .sort(); +} + function shaFromEntry(entry: FeedEntry): string | undefined { const value = entry.url ?? entry.id; return value.match(/[0-9a-f]{40}/i)?.[0]; @@ -174,13 +206,14 @@ export async function pollFeedSource(input: { fetchImpl?: FetchLike; }): Promise<{ signals: FeedSignal[]; jobs: number; flowDispatches: number; primed: boolean }> { const response = await (input.fetchImpl ?? fetch)(input.source.url, { - headers: { accept: "application/atom+xml, application/rss+xml, application/xml, text/xml;q=0.9" }, + headers: { accept: "application/json, application/atom+xml, application/rss+xml, application/xml, text/xml;q=0.9" }, }); if (!response.ok) { throw new Error(`Feed ${input.source.id} returned ${response.status}`); } - const entries = parseFeedEntries(await response.text()); + const body = await response.text(); + const entries = input.source.provider === "npm" ? parseNpmPackageEntries(body) : parseFeedEntries(body); const newestId = entries[0]?.id; const previous = input.state[input.source.id]; const primed = !previous?.lastSeenId; diff --git a/apps/patch/src/flow.ts b/apps/patch/src/flow.ts index 522701c..afb8c88 100644 --- a/apps/patch/src/flow.ts +++ b/apps/patch/src/flow.ts @@ -54,6 +54,7 @@ function tagFromSignal(signal: FeedSignal): string | undefined { } function flowPayloadFromSignal(signal: FeedSignal): Record { + const tag = tagFromSignal(signal); return { provider: signal.provider, event: signal.event, @@ -68,7 +69,8 @@ function flowPayloadFromSignal(signal: FeedSignal): Record { repoName: signal.repo.name, ref: signal.ref, sha: signal.sha, - tag: tagFromSignal(signal), + tag, + ...(signal.provider === "npm" && tag ? { packageName: signal.repo.fullName, version: tag } : {}), raw: signal.raw, }; } @@ -130,6 +132,26 @@ export function patchUpstreamBranchUpdateEvent(input: { }; } +export function patchDownstreamReleaseEvent(input: { + packageName: string; + version: string; + repo?: string; + receivedAt?: string; +}): FlowEvent> { + return { + id: `${serviceSource}:downstream.release:${input.packageName}:${input.version}`, + type: "downstream.release", + source: serviceSource, + receivedAt: input.receivedAt ?? new Date().toISOString(), + payload: { + packageName: input.packageName, + version: input.version, + tag: input.version, + ...(input.repo ? { repo: input.repo } : {}), + }, + }; +} + export async function dispatchFlowEvent( event: FlowEvent, target: Partial = {}, diff --git a/apps/patch/src/types.ts b/apps/patch/src/types.ts index b2e071d..fb9b88f 100644 --- a/apps/patch/src/types.ts +++ b/apps/patch/src/types.ts @@ -1,4 +1,4 @@ -export type FeedProvider = "codeberg" | "github" | "jojo"; +export type FeedProvider = "codeberg" | "github" | "jojo" | "npm"; export type FeedEventName = "push" | "release"; diff --git a/apps/patch/test/cli.test.ts b/apps/patch/test/cli.test.ts index 4d11b0a..f0db329 100644 --- a/apps/patch/test/cli.test.ts +++ b/apps/patch/test/cli.test.ts @@ -116,6 +116,38 @@ describe("patch.moi CLI", () => { }); }); + test("dry-runs downstream release matching for codex-flows fork releases", async () => { + const dryRun = await invoke([ + "run", + "downstream-release", + "--package", + "@peezy.tech/codex-flows", + "--version", + "0.4.0", + "--repo", + "peezy-tech/codex-flows", + "--workspace-root", + workspaceRoot, + "--dry-run", + "--json", + ]); + + expect(dryRun.code).toBe(0); + expect(JSON.parse(dryRun.stdout)).toMatchObject({ + event: { + type: "downstream.release", + payload: { + packageName: "@peezy.tech/codex-flows", + version: "0.4.0", + repo: "peezy-tech/codex-flows", + }, + }, + matches: [ + { flow: "peezy-codex-flows-fork", step: "release-fork", runner: "bun" }, + ], + }); + }); + test("syncs a maintenance attempt from workspace run state", async () => { const dataDir = await mkdtemp(join(tmpdir(), "patch-cli-")); const store = new EventStore(dataDir); diff --git a/apps/patch/test/feed.test.ts b/apps/patch/test/feed.test.ts index 1006308..7da0586 100644 --- a/apps/patch/test/feed.test.ts +++ b/apps/patch/test/feed.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, test } from "bun:test"; -import { loadSources, parseFeedEntries, pollFeedsOnce, signalFromEntry } from "../src/feed"; -import { dispatchWorkspaceEvent, patchUpstreamReleaseEvent } from "../src/flow"; +import { loadSources, parseFeedEntries, parseNpmPackageEntries, pollFeedsOnce, signalFromEntry } from "../src/feed"; +import { dispatchWorkspaceEvent, patchDownstreamReleaseEvent, patchUpstreamReleaseEvent } from "../src/flow"; import type { FeedSourceConfig } from "../src/types"; const atom = ` @@ -35,6 +35,19 @@ const rss = ` `; +const npmPackage = JSON.stringify({ + name: "@peezy.tech/codex-flows", + "dist-tags": { + latest: "0.4.0", + }, + time: { + created: "2026-05-10T00:00:00.000Z", + modified: "2026-05-17T00:00:00.000Z", + "0.3.6": "2026-05-15T00:00:00.000Z", + "0.4.0": "2026-05-17T00:00:00.000Z", + }, +}); + const source: FeedSourceConfig = { id: "github-openai-codex-main", provider: "github", @@ -69,6 +82,15 @@ describe("feed watcher", () => { }); }); + test("parses npm package releases newest first", () => { + expect(parseNpmPackageEntries(npmPackage).map((entry) => entry.title)).toEqual(["0.4.0", "0.3.6"]); + expect(parseNpmPackageEntries(npmPackage)[0]).toMatchObject({ + id: "npm:@peezy.tech/codex-flows:0.4.0", + author: "npm", + publishedAt: "2026-05-17T00:00:00.000Z", + }); + }); + test("normalizes commit feed entries into push signals", () => { const signal = signalFromEntry(source, parseFeedEntries(atom)[0]); expect(signal).toMatchObject({ @@ -88,6 +110,8 @@ describe("feed watcher", () => { "codeberg-forgejo-releases", "github-openai-codex-main", "github-openai-codex-releases", + "npm-peezy-codex-releases", + "npm-peezy-codex-flows-releases", ]); expect(sources.find((item) => item.id === "github-openai-codex-main")?.target).toMatchObject({ mode: "workspace_flow", @@ -274,6 +298,67 @@ describe("feed watcher", () => { }); }); + test("later npm release polls dispatch downstream release events", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "patch-feed-")); + const sourcesPath = join(dataDir, "sources.json"); + const releaseSource: FeedSourceConfig = { + id: "npm-peezy-codex-flows-releases", + provider: "npm", + url: "https://registry.npmjs.org/@peezy.tech%2Fcodex-flows", + event: "release", + repo: { + owner: "@peezy.tech", + name: "codex-flows", + fullName: "@peezy.tech/codex-flows", + webUrl: "https://www.npmjs.com/package/@peezy.tech/codex-flows", + }, + target: { + mode: "workspace_flow", + eventType: "downstream.release", + workspaceUrlEnv: "WORKSPACE_URL", + payload: { + packageName: "@peezy.tech/codex-flows", + repo: "peezy-tech/codex-flows", + }, + }, + }; + await writeFile(sourcesPath, JSON.stringify({ sources: [releaseSource] }), "utf8"); + await writeFile(join(dataDir, "feed-state.json"), JSON.stringify({ + "npm-peezy-codex-flows-releases": { + lastSeenId: "npm:@peezy.tech/codex-flows:0.3.6", + lastCheckedAt: "2026-05-15T00:00:00.000Z", + }, + }), "utf8"); + + let dispatchedBody = ""; + await pollFeedsOnce({ + dataDir, + sourcesPath, + discord: { enabled: false, notifyEvents: new Set(["release"]) }, + flowDispatch: { + env: { + WORKSPACE_URL: "https://workspace.example/events", + }, + fetchImpl: async (_url, init) => { + dispatchedBody = String(init.body); + const eventId = JSON.parse(String(init.body ?? "{}")).id; + return Response.json({ status: "accepted", eventId, runIds: [], matched: 1 }, { status: 202 }); + }, + }, + }, async () => { + return new Response(npmPackage, { status: 200 }); + }); + + const flowEventText = await readFile(join(dataDir, "flow-events.jsonl"), "utf8"); + const flowEvent = JSON.parse(flowEventText.trim()) as Record; + expect(flowEvent.type).toBe("downstream.release"); + expect(flowEvent.payload.packageName).toBe("@peezy.tech/codex-flows"); + expect(flowEvent.payload.version).toBe("0.4.0"); + expect(flowEvent.payload.tag).toBe("0.4.0"); + expect(flowEvent.payload.repo).toBe("peezy-tech/codex-flows"); + expect(JSON.parse(dispatchedBody).id).toBe(flowEvent.id); + }); + test("workspace dispatch uses default workspace backend env names", async () => { let dispatchedUrl = ""; let dispatchedSignature = ""; @@ -519,6 +604,26 @@ describe("feed watcher", () => { }, }); }); + + test("Patch downstream release helper creates deterministic product events", () => { + expect(patchDownstreamReleaseEvent({ + packageName: "@peezy.tech/codex-flows", + version: "0.4.0", + repo: "peezy-tech/codex-flows", + receivedAt: "2026-05-17T00:00:00.000Z", + })).toEqual({ + id: "patch:downstream.release:@peezy.tech/codex-flows:0.4.0", + type: "downstream.release", + source: "patch", + receivedAt: "2026-05-17T00:00:00.000Z", + payload: { + packageName: "@peezy.tech/codex-flows", + version: "0.4.0", + tag: "0.4.0", + repo: "peezy-tech/codex-flows", + }, + }); + }); }); function headerValue(headers: HeadersInit | undefined, name: string): string { diff --git a/docs/pages/guides/maintain-a-fork.md b/docs/pages/guides/maintain-a-fork.md index 5c8efe5..2d8c8a4 100644 --- a/docs/pages/guides/maintain-a-fork.md +++ b/docs/pages/guides/maintain-a-fork.md @@ -100,6 +100,20 @@ The output should show the flow steps that will receive the event. For the current Codex release package, the expected fanout is the bindings update flow and the Codex fork release-cycle flow. +When a Peezy downstream package release needs to refresh the codex-flows fork +release candidate, dry-run the downstream release event: + +```bash +bun run patch.moi -- run downstream-release \ + --package @peezy.tech/codex \ + --version 0.130.0 \ + --repo peezy-tech/codex \ + --dry-run +``` + +That event should match the `peezy-codex-flows-fork/release-fork` Bun step. The +same flow also accepts `@peezy.tech/codex-flows` releases. + Dry-run the upstream main branch update path separately: ```bash diff --git a/docs/pages/reference/cli.md b/docs/pages/reference/cli.md index dda751e..3dc9788 100644 --- a/docs/pages/reference/cli.md +++ b/docs/pages/reference/cli.md @@ -98,6 +98,16 @@ bun run patch.moi -- run codex-main \ --dry-run ``` +Verify a downstream Peezy package release without executing fork packaging: + +```bash +bun run patch.moi -- run downstream-release \ + --package @peezy.tech/codex-flows \ + --version 0.4.0 \ + --repo peezy-tech/codex-flows \ + --dry-run +``` + Dispatching the Codex release task requires an explicit execution surface. Use Actions/local mode when no workspace backend is running: @@ -124,6 +134,7 @@ Read patch.moi-owned state: ```bash bun run patch.moi -- status bun run patch.moi -- events --type upstream.release +bun run patch.moi -- events --type downstream.release bun run patch.moi -- dispatches --status failed bun run patch.moi -- attempts --status needs_intervention ``` diff --git a/docs/pages/reference/feed-sources.md b/docs/pages/reference/feed-sources.md index adc7872..16ecc14 100644 --- a/docs/pages/reference/feed-sources.md +++ b/docs/pages/reference/feed-sources.md @@ -14,7 +14,7 @@ tags as the maintained project source of truth. ```ts type FeedSourceConfig = { id: string; - provider: "github"; + provider: "codeberg" | "github" | "jojo" | "npm"; url: string; event: "push" | "release"; repo: { @@ -51,7 +51,39 @@ adapter. The flow payload includes provider, event, source id, entry id, title, URL, author, published time, repository fields, ref, SHA, tag, and raw feed metadata. Values from `target.payload` are merged last. -For release maintenance, use a stable event type such as `upstream.release`. -For upstream main movement, use `upstream.branch_update`. Include only routing -hints in `payload`; avoid copying branch topology into the feed source when it -can be read from the repository. +For upstream release maintenance, use a stable event type such as +`upstream.release`. For upstream main movement, use `upstream.branch_update`. +For downstream package releases, use `downstream.release` with `packageName` and +`version` in the payload. + +Include only routing hints in `payload`; avoid copying branch topology into the +feed source when it can be read from the repository. + +## npm release sources + +npm package sources read the registry package document and treat each published +version as a release entry. patch.moi uses this for the local downstream release +broadcasts from `@peezy.tech/codex` and `@peezy.tech/codex-flows`: + +```json +{ + "id": "npm-peezy-codex-flows-releases", + "provider": "npm", + "url": "https://registry.npmjs.org/@peezy.tech%2Fcodex-flows", + "event": "release", + "repo": { + "owner": "@peezy.tech", + "name": "codex-flows", + "fullName": "@peezy.tech/codex-flows", + "webUrl": "https://www.npmjs.com/package/@peezy.tech/codex-flows" + }, + "target": { + "mode": "workspace_flow", + "eventType": "downstream.release", + "payload": { + "packageName": "@peezy.tech/codex-flows", + "repo": "peezy-tech/codex-flows" + } + } +} +``` diff --git a/docs/pages/reference/packages.md b/docs/pages/reference/packages.md index 90f9e92..235e218 100644 --- a/docs/pages/reference/packages.md +++ b/docs/pages/reference/packages.md @@ -62,7 +62,9 @@ codex-flows pack doctor --json `openai-codex-bindings` matches `upstream.release` events for `openai/codex`. `peezy-codex-fork` matches both `upstream.release` and -`upstream.branch_update` events. They are installed capabilities, not patch.moi +`upstream.branch_update` events. `peezy-codex-flows-fork` matches +`downstream.release` events for `@peezy.tech/codex` and +`@peezy.tech/codex-flows`. They are installed capabilities, not patch.moi product state. patch.moi still records feed-owned flow events, workspace dispatches, and maintenance attempts under `DATA_DIR`. diff --git a/docs/pages/tutorials/dispatch-codex-release-flow.md b/docs/pages/tutorials/dispatch-codex-release-flow.md index bfed6de..d12a9eb 100644 --- a/docs/pages/tutorials/dispatch-codex-release-flow.md +++ b/docs/pages/tutorials/dispatch-codex-release-flow.md @@ -50,12 +50,13 @@ The Codex release maintenance capabilities are installed from the neighboring codex-flows pack add ../codex-flows \ --include openai-codex-bindings \ --include peezy-codex-fork \ + --include peezy-codex-flows-fork \ --apply codex-flows pack doctor --json ``` -The current local install pins `openai-codex-bindings` and `peezy-codex-fork` -in `.codex/pack-lock.json`. The codex-flows runtime discovers installed +The current local install pins `openai-codex-bindings`, `peezy-codex-fork`, and +`peezy-codex-flows-fork` in `.codex/pack-lock.json`. The codex-flows runtime discovers installed `.codex/flows/*` before source-owned `flows/*`, so the installed Codex capabilities are visible to patch.moi while the harness remains a source-owned repo flow.