Route downstream releases to codex-flows fork
All checks were successful
check / check (push) Successful in 36s

This commit is contained in:
matamune 2026-05-18 19:31:24 +00:00
parent c14470a8f4
commit 3313b54a10
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
16 changed files with 802 additions and 19 deletions

View file

@ -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<string, unknown>;
event: {
id: string;
payload?: Record<string, unknown>;
};
};
};
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<string, unknown>): 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<void> {
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<void> {
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<void> {
const packageJsonPath = path.join(input.worktreeDir, "packages/codex-client/package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record<string, unknown>;
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<string> {
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<string> {
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<CommandResult> {
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<CommandResult> {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function sortRecord(value: Record<string, unknown>): Record<string, unknown> {
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";
}

View file

@ -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"

View file

@ -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" }
}
}

View file

@ -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"
}
]
}

View file

@ -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
}
]
}

View file

@ -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<number> {
async function handleRun(positionals: string[], context: CliContext): Promise<number> {
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<nu
}
return await runEvent(event, context);
}
if (target === "downstream-release") {
const packageName = flagValue(context.parsed, "package") ?? flagValue(context.parsed, "package-name");
const version = flagValue(context.parsed, "version") ?? flagValue(context.parsed, "tag");
if (!packageName) {
throw new UsageError("run downstream-release requires --package");
}
if (!version) {
throw new UsageError("run downstream-release requires --version");
}
const event = patchDownstreamReleaseEvent({
packageName,
version,
repo: flagValue(context.parsed, "repo"),
});
if (!flagBool(context.parsed, "dry-run") && !flagBool(context.parsed, "record-only")) {
assertCodexDispatchAllowed(context, "downstream-release");
}
return await runEvent(event, context);
}
if (target === "event") {
const eventFile = flagValue(context.parsed, "file");
if (!eventFile) {

View file

@ -96,6 +96,38 @@ export function parseFeedEntries(xml: string): FeedEntry[] {
}).filter((entry) => entry.id);
}
export function parseNpmPackageEntries(text: string): FeedEntry[] {
const parsed = JSON.parse(text) as {
name?: string;
time?: Record<string, string>;
"dist-tags"?: Record<string, string>;
};
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<string, string>, 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;

View file

@ -54,6 +54,7 @@ function tagFromSignal(signal: FeedSignal): string | undefined {
}
function flowPayloadFromSignal(signal: FeedSignal): Record<string, unknown> {
const tag = tagFromSignal(signal);
return {
provider: signal.provider,
event: signal.event,
@ -68,7 +69,8 @@ function flowPayloadFromSignal(signal: FeedSignal): Record<string, unknown> {
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<Record<string, unknown>> {
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<FeedWorkspaceFlowTarget> = {},

View file

@ -1,4 +1,4 @@
export type FeedProvider = "codeberg" | "github" | "jojo";
export type FeedProvider = "codeberg" | "github" | "jojo" | "npm";
export type FeedEventName = "push" | "release";

View file

@ -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);

View file

@ -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 = `<?xml version="1.0"?>
@ -35,6 +35,19 @@ const rss = `<?xml version="1.0"?>
</item>
</channel></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<string, any>;
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 {

View file

@ -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

View file

@ -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
```

View file

@ -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"
}
}
}
```

View file

@ -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`.

View file

@ -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.