Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Releasing Diffex

Diffex publishes its public workspaces in lockstep and uses GitHub Actions trusted publishing for normal npm releases.
The release tag also builds binaries, stages the GitHub release, announces the verified release, and publishes the staged GitHub release.

## Prerequisites

- Use a clean `main` branch with all intended changes merged.
- Confirm GitHub Actions can start jobs and the repository billing or spending limit is not blocking runners.
- Confirm the `@earendil-works` npm organization exists and your npm account has package publishing permission with account-level two-factor authentication enabled.
- Use npm 11.15.0 or newer when configuring trusted publishers.
- Confirm every public package has repository metadata pointing to `diffexai/diffex`.

## First npm publication

npm requires a package to exist before a trusted publisher can be configured.
Use this bootstrap process only once, before the first normal release.

1. Sign in with an npm account that can publish public packages under `@earendil-works`.

```bash
npm login
```

2. Build and validate every package without publishing anything.

```bash
npm run release:bootstrap-npm:dry
```

3. Publish the current lockstep version under the non-default `bootstrap` dist-tag.

```bash
npm run release:bootstrap-npm
```

Bootstrap publication deliberately omits provenance because it runs interactively outside the trusted CI environment.
It does not assign the `latest` dist-tag.
The command is resumable after a partial failure: it skips packages already published at the exact workspace version and refuses packages that already exist only at other versions.

4. Run every `npm trust github` command printed by the bootstrap command.
The trusted publisher values must remain:

- Repository: `diffexai/diffex`
- Workflow file: `build-binaries.yml`
- Environment: `npm-publish`
- Allowed action: `npm publish`

5. Verify each package has the expected trusted publisher.

```bash
npm trust list @earendil-works/diffex-coding-agent
```

6. Restrict traditional token publishing after trusted publishing is verified.

## Normal release

Run the appropriate release command only after local checks pass and the release workflow is operational.

```bash
npm run release:patch
npm run release:minor
npm run release:major
```

The release command updates versions and generated artifacts, runs checks and tests, creates the release commits and tag, and pushes `main` plus the tag directly.
It is not a dry-run command.
Review the target version and obtain approval for the remote push before running it.

The pushed tag triggers `.github/workflows/build-binaries.yml`.
Do not announce the release until that workflow publishes npm packages, the verified release announcement, and the GitHub release successfully.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
"release:bootstrap-npm": "npm run prepublishOnly && node scripts/publish.mjs --bootstrap",
"release:bootstrap-npm:dry": "npm run prepublishOnly && node scripts/publish.mjs --bootstrap --dry-run",
"release:local": "node scripts/local-release.mjs",
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
"install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs",
Expand Down
13 changes: 1 addition & 12 deletions packages/ai/test/fireworks-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net";
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { getModel, getModels, streamSimple } from "../src/compat.ts";
import { getModel, streamSimple } from "../src/compat.ts";
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
import type { Context, Model, Tool } from "../src/types.ts";

Expand Down Expand Up @@ -37,17 +37,6 @@ describe("Fireworks models", () => {
});
});

it("registers the Fire Pass turbo router model", () => {
const model = getModels("fireworks").find(
(candidate) => candidate.id.startsWith("accounts/fireworks/routers/") && candidate.id.endsWith("-turbo"),
);

expect(model).toBeDefined();
expect(model?.api).toBe("anthropic-messages");
expect(model?.baseUrl).toBe("https://api.fireworks.ai/inference");
expect(model?.input).toEqual(["text", "image"]);
});

it("aligns GLM 5.2 Fast with GLM 5.2's OpenAI-compatible config", () => {
const base = getModel("fireworks", "accounts/fireworks/models/glm-5p2");
const fast = getModel("fireworks", "accounts/fireworks/routers/glm-5p2-fast");
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
},
"engines": { "node": ">=22.19.0" },
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/diffexai/diffex.git",
"directory": "packages/coding-agent-server"
},
"dependencies": {
"@earendil-works/diffex-coding-agent": "^0.2.0",
"@earendil-works/diffex-protocol": "^0.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ describe("AgentSession auto-compaction queue resume", () => {

await checkCompaction(errorAssistant);

expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false, undefined, undefined);
});

it("should not trigger threshold compaction for error messages when no prior usage exists", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ describe("AgentSession compaction characterization", () => {

await sessionInternals._checkCompaction(errorAssistant);

expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false, undefined, undefined);
});

it("does not trigger threshold compaction for error messages when no prior usage exists", async () => {
Expand Down
43 changes: 43 additions & 0 deletions scripts/npm-publish-plan.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export const BOOTSTRAP_DIST_TAG = "bootstrap";

const USAGE = "Usage: node scripts/publish.mjs [--dry-run] [--bootstrap]";

export function parsePublishArgs(args) {
const unknownArgs = args.filter((arg) => arg !== "--dry-run" && arg !== "--bootstrap");
if (unknownArgs.length > 0) {
throw new Error(USAGE);
}

return {
bootstrap: args.includes("--bootstrap"),
dryRun: args.includes("--dry-run"),
};
}

export function createPublishArgs({ bootstrap }) {
return [
"publish",
"--access",
"public",
...(bootstrap ? ["--tag", BOOTSTRAP_DIST_TAG] : ["--provenance"]),
"--ignore-scripts",
];
}

export function assertBootstrapPackageStates(packageStates) {
const conflicts = packageStates.filter((pkg) => pkg.registered && !pkg.published);
if (conflicts.length === 0) {
return;
}

throw new Error(
`Bootstrap publishing is only for package registration. These packages already exist without the workspace version:\n${conflicts.map((pkg) => ` ${pkg.name}@${pkg.version}`).join("\n")}\nUse the normal release flow instead.`,
);
}

export function createTrustedPublisherCommands(packageNames) {
return packageNames.map(
(packageName) =>
`npm trust github ${packageName} --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --yes`,
);
}
64 changes: 64 additions & 0 deletions scripts/npm-publish-plan.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertBootstrapPackageStates,
BOOTSTRAP_DIST_TAG,
createPublishArgs,
createTrustedPublisherCommands,
parsePublishArgs,
} from "./npm-publish-plan.mjs";

describe("npm publish planning", () => {
it("parses normal, dry-run, and bootstrap modes", () => {
assert.deepEqual(parsePublishArgs([]), { bootstrap: false, dryRun: false });
assert.deepEqual(parsePublishArgs(["--dry-run"]), { bootstrap: false, dryRun: true });
assert.deepEqual(parsePublishArgs(["--bootstrap", "--dry-run"]), { bootstrap: true, dryRun: true });
});

it("rejects unknown arguments", () => {
assert.throws(() => parsePublishArgs(["--force"]), /Usage: node scripts\/publish\.mjs/);
});

it("uses provenance for releases and a non-default tag for bootstrap publication", () => {
assert.deepEqual(createPublishArgs({ bootstrap: false }), [
"publish",
"--access",
"public",
"--provenance",
"--ignore-scripts",
]);
assert.deepEqual(createPublishArgs({ bootstrap: true }), [
"publish",
"--access",
"public",
"--tag",
BOOTSTRAP_DIST_TAG,
"--ignore-scripts",
]);
});

it("allows new and already-completed bootstrap packages", () => {
assert.doesNotThrow(() =>
assertBootstrapPackageStates([
{ name: "@scope/new", version: "0.2.0", registered: false, published: false },
{ name: "@scope/done", version: "0.2.0", registered: true, published: true },
]),
);
});

it("rejects bootstrap publication over an established package", () => {
assert.throws(
() =>
assertBootstrapPackageStates([
{ name: "@scope/existing", version: "0.2.0", registered: true, published: false },
]),
/@scope\/existing@0\.2\.0/,
);
});

it("builds the trusted-publisher command for the release workflow", () => {
assert.deepEqual(createTrustedPublisherCommands(["@scope/package"]), [
"npm trust github @scope/package --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --yes",
]);
});
});
74 changes: 65 additions & 9 deletions scripts/publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
assertBootstrapPackageStates,
BOOTSTRAP_DIST_TAG,
createPublishArgs,
createTrustedPublisherCommands,
parsePublishArgs,
} from "./npm-publish-plan.mjs";
import { getPublicWorkspacePackages } from "./release-packages.mjs";

const packages = getPublicWorkspacePackages();

const dryRun = process.argv.includes("--dry-run");
const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run");

if (unknownArgs.length > 0) {
console.error(`Usage: node scripts/publish.mjs [--dry-run]`);
let options;
try {
options = parsePublishArgs(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const { bootstrap, dryRun } = options;

function commandForPlatform(command) {
return process.platform === "win32" ? `${command}.cmd` : command;
Expand Down Expand Up @@ -65,24 +73,57 @@ function isPublished(name, version) {
throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`);
}

function isRegistered(name) {
const result = spawnSync(commandForPlatform("npm"), ["view", name, "version", "--json"], {
encoding: "utf8",
stdio: ["inherit", "pipe", "pipe"],
});

if (result.status === 0 && result.stdout.trim()) {
return true;
}

const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) {
return false;
}

throw new Error(output ? `Failed to query ${name}\n${output}` : `Failed to query ${name}`);
}

const packageVersions = new Map(packages.map((pkg) => [pkg.name, pkg.version]));

const versions = [...new Set(packageVersions.values())];
if (versions.length !== 1) {
throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`);
}

console.log(`Publishing Diffex packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`);
const mode = [bootstrap ? "bootstrap" : null, dryRun ? "dry run" : null].filter(Boolean).join(", ");
console.log(`Publishing Diffex packages at ${versions[0]}${mode ? ` (${mode})` : ""}\n`);

if (bootstrap) {
console.log(
`Bootstrap mode registers new packages under the non-default '${BOOTSTRAP_DIST_TAG}' tag without local provenance.`,
);
console.log("It skips packages already published at this exact version and rejects established packages at other versions.\n");
}

const packageStates = packages.map((pkg) => ({
...pkg,
published: false,
published: isPublished(pkg.name, packageVersions.get(pkg.name)),
registered: false,
version: packageVersions.get(pkg.name),
}));

if (bootstrap) {
for (const pkg of packageStates) {
pkg.registered = pkg.published || isRegistered(pkg.name);
}
assertBootstrapPackageStates(packageStates);
}

for (const pkg of packageStates) {
assertBuildOutputExists(pkg.directory);
pkg.published = isPublished(pkg.name, pkg.version);

if (pkg.published) {
console.log(`${pkg.name}@${pkg.version} is already published; validating package contents only.`);
Expand All @@ -94,6 +135,13 @@ for (const pkg of packageStates) {
}

if (dryRun) {
if (bootstrap) {
console.log("After bootstrap publication, configure trusted publishing with:\n");
for (const command of createTrustedPublisherCommands(packages.map((pkg) => pkg.name))) {
console.log(` ${command}`);
}
console.log();
}
process.exit(0);
}

Expand All @@ -105,6 +153,14 @@ for (const pkg of packageStates) {
continue;
}

run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory });
run("npm", createPublishArgs({ bootstrap }), { cwd: pkg.directory });
console.log();
}

if (bootstrap) {
console.log("Bootstrap publication is complete. Configure trusted publishing with:\n");
for (const command of createTrustedPublisherCommands(packages.map((pkg) => pkg.name))) {
console.log(` ${command}`);
}
console.log("\nThen verify each relationship with npm trust list <package> before running the normal release flow.");
}
2 changes: 1 addition & 1 deletion scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function assertPackagesAreRegisteredWithNpm() {
}

if (unregisteredPackages.length > 0) {
throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nRegister them before running a release.`);
throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nFollow the first-publication process in RELEASING.md before running a release.`);
}

console.log(" All public workspace packages are registered on npm\n");
Expand Down
Loading