From 2ecb1368907400806628b84da65217b35bd09c18 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sun, 30 Aug 2026 00:31:31 -0400 Subject: [PATCH] build(release): add npm bootstrap flow Add a resumable first-publication path that keeps initial packages off the latest tag, preserves the normal registration gate, and documents trusted publisher setup. Refresh stale release-test expectations and complete package repository metadata so the full release suite passes. --- RELEASING.md | 72 ++++++++++++++++++ package.json | 2 + packages/ai/test/fireworks-models.test.ts | 13 +--- packages/coding-agent-server/package.json | 5 ++ ...gent-session-auto-compaction-queue.test.ts | 2 +- .../suite/agent-session-compaction.test.ts | 2 +- scripts/npm-publish-plan.mjs | 43 +++++++++++ scripts/npm-publish-plan.test.mjs | 64 ++++++++++++++++ scripts/publish.mjs | 74 ++++++++++++++++--- scripts/release.mjs | 2 +- 10 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 RELEASING.md create mode 100644 scripts/npm-publish-plan.mjs create mode 100644 scripts/npm-publish-plan.test.mjs diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..5d4af6ee7 --- /dev/null +++ b/RELEASING.md @@ -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. diff --git a/package.json b/package.json index a9552c6ab..ad965854b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index b37991b5b..1a604ba9e 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -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"; @@ -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"); diff --git a/packages/coding-agent-server/package.json b/packages/coding-agent-server/package.json index a0dbf1e33..0b1d38c66 100644 --- a/packages/coding-agent-server/package.json +++ b/packages/coding-agent-server/package.json @@ -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", diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index c7c47d3d6..d376c50bf 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 2ca6f1494..b9e1714f3 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -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 () => { diff --git a/scripts/npm-publish-plan.mjs b/scripts/npm-publish-plan.mjs new file mode 100644 index 000000000..9948fdc2a --- /dev/null +++ b/scripts/npm-publish-plan.mjs @@ -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`, + ); +} diff --git a/scripts/npm-publish-plan.test.mjs b/scripts/npm-publish-plan.test.mjs new file mode 100644 index 000000000..299c8b2a9 --- /dev/null +++ b/scripts/npm-publish-plan.test.mjs @@ -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", + ]); + }); +}); diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 48ddd6996..92f276245 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -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; @@ -65,6 +73,24 @@ 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())]; @@ -72,17 +98,32 @@ 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.`); @@ -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); } @@ -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 before running the normal release flow."); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index f51b727ec..460d55dea 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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");