From 3b22abbbd11e565218500bd0ada1328349dc7040 Mon Sep 17 00:00:00 2001 From: Tyler Slaton Date: Tue, 14 Jul 2026 23:05:50 -0700 Subject: [PATCH] fix(release): stage channels publishing --- .github/workflows/publish-release.yml | 29 ++++++-- release.config.json | 10 +-- scripts/release/lib/changes.test.ts | 57 +++++++++++++++ scripts/release/lib/changes.ts | 31 ++++++--- scripts/release/lib/config.test.ts | 16 ++--- scripts/release/lib/publish-workflow.test.ts | 31 +++++++++ scripts/release/lib/versions.test.ts | 12 +++- scripts/release/lib/versions.ts | 73 +++++++++++++++----- scripts/release/prepare-release.ts | 14 ++-- scripts/release/publish-release.ts | 53 ++++++++++---- 10 files changed, 260 insertions(+), 66 deletions(-) create mode 100644 scripts/release/lib/changes.test.ts create mode 100644 scripts/release/lib/publish-workflow.test.ts diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 57a7116967..606be8690b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -166,10 +166,6 @@ jobs: - name: Build packages run: pnpm run build - - name: Verify registry-backed Channels umbrella contract - if: ${{ steps.meta.outputs.scope == 'channels' && steps.meta.outputs.mode == 'stable' }} - run: pnpm run verify:channels-umbrella:registry - # Strip caches and pack the workspace into a single tarball before # upload. upload-artifact's path filters are post-walk: it still # descends into every node_modules and stats every file (~6.4M for @@ -272,6 +268,21 @@ jobs: echo "DRY RUN — skipping publish step. Scope: ${{ steps.meta.outputs.scope }}, mode: ${{ steps.meta.outputs.mode }}." } >> "$GITHUB_STEP_SUMMARY" + # The family must exist on npm before the umbrella is allowed to ship: + # the registry verifier installs the packed umbrella against those exact + # published dependencies. Publish the foundation and adapters first, then + # verify, then let the final publish step release the umbrella. + - name: Publish Channels dependencies to npm + if: ${{ inputs.dry-run != true && steps.meta.outputs.scope == 'channels' && steps.meta.outputs.mode == 'stable' }} + env: + NODE_AUTH_TOKEN: "" + SCOPE: ${{ steps.meta.outputs.scope }} + run: pnpm tsx scripts/release/publish-release.ts --scope "$SCOPE" --phase dependencies + + - name: Verify registry-backed Channels umbrella contract + if: ${{ inputs.dry-run != true && steps.meta.outputs.scope == 'channels' && steps.meta.outputs.mode == 'stable' }} + run: pnpm run verify:channels-umbrella:registry + - name: Publish to npm id: publish if: ${{ inputs.dry-run != true }} @@ -280,7 +291,15 @@ jobs: NOTION_API_KEY: ${{ steps.meta.outputs.mode == 'stable' && secrets.NOTION_API_KEY || '' }} PUBLISH_SCRIPT: ${{ steps.meta.outputs.mode == 'prerelease' && 'prerelease.ts' || 'publish-release.ts' }} SCOPE: ${{ steps.meta.outputs.scope }} - run: pnpm tsx "scripts/release/$PUBLISH_SCRIPT" --scope "$SCOPE" + run: | + set -euo pipefail + if [ "$PUBLISH_SCRIPT" = "prerelease.ts" ]; then + pnpm tsx "scripts/release/$PUBLISH_SCRIPT" --scope "$SCOPE" + elif [ "$SCOPE" = "channels" ]; then + pnpm tsx scripts/release/publish-release.ts --scope "$SCOPE" --phase umbrella + else + pnpm tsx scripts/release/publish-release.ts --scope "$SCOPE" + fi - name: Verify publish step emitted version if: ${{ success() && inputs.dry-run != true }} diff --git a/release.config.json b/release.config.json index 5316e5c526..b541f2ef7d 100644 --- a/release.config.json +++ b/release.config.json @@ -30,15 +30,15 @@ }, "channels": { "packages": [ - "@copilotkit/channels", - "@copilotkit/channels-core", "@copilotkit/channels-ui", - "@copilotkit/channels-discord", - "@copilotkit/channels-intelligence", + "@copilotkit/channels-core", "@copilotkit/channels-slack", "@copilotkit/channels-teams", + "@copilotkit/channels-intelligence", + "@copilotkit/channels-discord", "@copilotkit/channels-telegram", - "@copilotkit/channels-whatsapp" + "@copilotkit/channels-whatsapp", + "@copilotkit/channels" ], "versionSource": "@copilotkit/channels", "sharedVersion": true diff --git a/scripts/release/lib/changes.test.ts b/scripts/release/lib/changes.test.ts new file mode 100644 index 0000000000..7ea86ac59d --- /dev/null +++ b/scripts/release/lib/changes.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { getChangesSummary, getLastReleaseTag } from "./changes.js"; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("child_process", () => ({ + spawnSync: spawnSyncMock, +})); + +function mockGitHistory(): void { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (command !== "git") throw new Error(`unexpected command: ${command}`); + + if (args[0] === "tag") { + return { stdout: "v1.62.3\nchannels/v0.1.1\n" }; + } + + if (args[0] === "log") { + return { stdout: "abc1234 feat(channels): shared release\n" }; + } + + throw new Error(`unexpected git arguments: ${args.join(" ")}`); + }); +} + +describe("Channels release history", () => { + it("selects the Channels tag instead of the monorepo tag", () => { + mockGitHistory(); + + expect(getLastReleaseTag("channels")).toBe("channels/v0.1.1"); + expect(spawnSyncMock).toHaveBeenCalledWith( + "git", + ["tag", "--list", "channels/v*", "--sort=-v:refname"], + expect.any(Object), + ); + }); + + it("uses the Channels tag as the release-note commit boundary", () => { + mockGitHistory(); + + expect(getChangesSummary("channels")).toMatchObject({ + lastTag: "channels/v0.1.1", + commitCount: 1, + }); + expect(spawnSyncMock).toHaveBeenLastCalledWith( + "git", + [ + "log", + "channels/v0.1.1..HEAD", + "--oneline", + "--no-merges", + "--format=%H %s", + ], + expect.any(Object), + ); + }); +}); diff --git a/scripts/release/lib/changes.ts b/scripts/release/lib/changes.ts index 6097fbb24b..b4583a087e 100644 --- a/scripts/release/lib/changes.ts +++ b/scripts/release/lib/changes.ts @@ -1,16 +1,28 @@ import { spawnSync } from "child_process"; import { ROOT } from "./config.js"; +import type { ReleaseScope } from "./config.js"; -export function getLastReleaseTag(): string | null { +function getReleaseTagPattern(scope: ReleaseScope): string { + return scope === "monorepo" ? "v*" : `${scope}/v*`; +} + +function isReleaseTag(scope: ReleaseScope, tag: string): boolean { + const prefix = scope === "monorepo" ? "" : `${scope}/`; + return ( + tag.startsWith(prefix) && /^v\d+\.\d+\.\d+$/.test(tag.slice(prefix.length)) + ); +} + +export function getLastReleaseTag(scope: ReleaseScope): string | null { const result = spawnSync( "git", - ["tag", "--list", "v*", "--sort=-v:refname"], + ["tag", "--list", getReleaseTagPattern(scope), "--sort=-v:refname"], { cwd: ROOT, encoding: "utf8" }, ); const tags = result.stdout.trim().split("\n").filter(Boolean); for (const tag of tags) { - if (/^v\d+\.\d+\.\d+$/.test(tag)) { + if (isReleaseTag(scope, tag)) { return tag; } } @@ -23,8 +35,7 @@ export interface Commit { subject: string; } -export function getCommitsSinceLastRelease(): Commit[] { - const lastTag = getLastReleaseTag(); +function getCommitsSince(lastTag: string | null): Commit[] { const range = lastTag ? `${lastTag}..HEAD` : "HEAD"; const result = spawnSync( @@ -46,6 +57,10 @@ export function getCommitsSinceLastRelease(): Commit[] { }); } +export function getCommitsSinceLastRelease(scope: ReleaseScope): Commit[] { + return getCommitsSince(getLastReleaseTag(scope)); +} + export interface ChangesSummary { lastTag: string | null; commitCount: number; @@ -53,9 +68,9 @@ export interface ChangesSummary { oneline: string; } -export function getChangesSummary(): ChangesSummary { - const lastTag = getLastReleaseTag(); - const commits = getCommitsSinceLastRelease(); +export function getChangesSummary(scope: ReleaseScope): ChangesSummary { + const lastTag = getLastReleaseTag(scope); + const commits = getCommitsSince(lastTag); return { lastTag, diff --git a/scripts/release/lib/config.test.ts b/scripts/release/lib/config.test.ts index 70887cbdb6..612043a332 100644 --- a/scripts/release/lib/config.test.ts +++ b/scripts/release/lib/config.test.ts @@ -3,15 +3,15 @@ import { getScopeConfig } from "./config.js"; import { getPackagesForScope } from "./versions.js"; const CHANNELS_PACKAGES = [ - "@copilotkit/channels", - "@copilotkit/channels-core", "@copilotkit/channels-ui", - "@copilotkit/channels-discord", - "@copilotkit/channels-intelligence", + "@copilotkit/channels-core", "@copilotkit/channels-slack", "@copilotkit/channels-teams", + "@copilotkit/channels-intelligence", + "@copilotkit/channels-discord", "@copilotkit/channels-telegram", "@copilotkit/channels-whatsapp", + "@copilotkit/channels", ]; describe("Channels release scope", () => { @@ -24,10 +24,8 @@ describe("Channels release scope", () => { }); it("resolves every Channels package for a shared-version release", () => { - expect( - getPackagesForScope("channels") - .map((pkg) => pkg.name) - .sort(), - ).toEqual([...CHANNELS_PACKAGES].sort()); + expect(getPackagesForScope("channels").map((pkg) => pkg.name)).toEqual( + CHANNELS_PACKAGES, + ); }); }); diff --git a/scripts/release/lib/publish-workflow.test.ts b/scripts/release/lib/publish-workflow.test.ts new file mode 100644 index 0000000000..645685382b --- /dev/null +++ b/scripts/release/lib/publish-workflow.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const workflow = readFileSync( + resolve(ROOT, ".github/workflows/publish-release.yml"), + "utf8", +); + +describe("Channels stable publish workflow", () => { + it("publishes prerequisites, verifies the registry, then publishes the umbrella", () => { + const dependencies = workflow.indexOf( + 'scripts/release/publish-release.ts --scope "$SCOPE" --phase dependencies', + ); + const verifier = workflow.indexOf( + "Verify registry-backed Channels umbrella contract", + ); + const umbrella = workflow.indexOf( + 'scripts/release/publish-release.ts --scope "$SCOPE" --phase umbrella', + ); + + expect(dependencies).toBeGreaterThan(-1); + expect(verifier).toBeGreaterThan(dependencies); + expect(umbrella).toBeGreaterThan(verifier); + expect( + workflow.match(/Verify registry-backed Channels umbrella contract/g), + ).toHaveLength(1); + }); +}); diff --git a/scripts/release/lib/versions.test.ts b/scripts/release/lib/versions.test.ts index 42a0070188..10fa009c03 100644 --- a/scripts/release/lib/versions.test.ts +++ b/scripts/release/lib/versions.test.ts @@ -7,6 +7,7 @@ import { computeNextStableVersion, computePrereleaseVersion, bumpPackages, + getPackagesForScope, } from "./versions.js"; let tmpDir: string; @@ -21,7 +22,7 @@ vi.mock("./config.js", async () => { prereleaseTag: "canary", scopes: { monorepo: { - packages: ["@copilotkit/shared", "@copilotkit/react-core"], + packages: ["@copilotkit/react-core", "@copilotkit/shared"], versionSource: "@copilotkit/react-core", sharedVersion: true, }, @@ -35,7 +36,7 @@ vi.mock("./config.js", async () => { getScopeConfig: (scope: string) => { const scopes: Record = { monorepo: { - packages: ["@copilotkit/shared", "@copilotkit/react-core"], + packages: ["@copilotkit/react-core", "@copilotkit/shared"], versionSource: "@copilotkit/react-core", sharedVersion: true, }, @@ -227,4 +228,11 @@ describe("bumpPackages", () => { ); expect(pkg.dependencies["@copilotkit/shared"]).toBe("1.55.3"); }); + + it("publishes internal dependencies before their dependents", () => { + expect(getPackagesForScope("monorepo").map((pkg) => pkg.name)).toEqual([ + "@copilotkit/shared", + "@copilotkit/react-core", + ]); + }); }); diff --git a/scripts/release/lib/versions.ts b/scripts/release/lib/versions.ts index 1518231738..2cf9366376 100644 --- a/scripts/release/lib/versions.ts +++ b/scripts/release/lib/versions.ts @@ -1,11 +1,7 @@ import fs from "fs"; import path from "path"; -import { - loadConfig, - getScopeConfig, - ROOT, - type ReleaseScope, -} from "./config.js"; +import { loadConfig, getScopeConfig, ROOT } from "./config.js"; +import type { ReleaseScope } from "./config.js"; export type BumpLevel = "patch" | "minor" | "major"; @@ -23,6 +19,12 @@ export interface PublishablePackage { pkg: Record; } +const INTERNAL_DEPENDENCY_FIELDS = [ + "dependencies", + "optionalDependencies", + "peerDependencies", +] as const; + /** Find a package directory by its npm name. */ function findPackageDir(packageName: string): string { const packagesDir = path.join(ROOT, "packages"); @@ -47,7 +49,7 @@ export function getCurrentVersion(scope: ReleaseScope): string { export function parseSemver(version: string): SemVer { const match = version.match( - /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.\-]+))?(?:\+(.+))?$/, + /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+(.+))?$/, ); if (!match) { throw new Error(`Invalid semver: ${version}`); @@ -94,26 +96,63 @@ export function computePrereleaseVersion( /** Get all publishable packages for a given scope. */ export function getPackagesForScope(scope: ReleaseScope): PublishablePackage[] { const scopeConfig = getScopeConfig(scope); - const packageNames = new Set(scopeConfig.packages); const packagesDir = path.join(ROOT, "packages"); + const packagesByName = new Map(); - const results: PublishablePackage[] = []; for (const dir of fs.readdirSync(packagesDir)) { const pkgJsonPath = path.join(packagesDir, dir, "package.json"); if (!fs.existsSync(pkgJsonPath)) continue; const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); - if (packageNames.has(pkg.name)) { - results.push({ - name: pkg.name, - dir: path.join(packagesDir, dir), - pkgJsonPath, - pkg, - }); + packagesByName.set(pkg.name, { + name: pkg.name, + dir: path.join(packagesDir, dir), + pkgJsonPath, + pkg, + }); + } + + const scopePackages = scopeConfig.packages.map((name) => { + const pkg = packagesByName.get(name); + if (!pkg) { + throw new Error(`Package not found for scope ${scope}: ${name}`); + } + return pkg; + }); + const scopeNames = new Set(scopeConfig.packages); + const internalDependencies = new Map( + scopePackages.map((pkg) => [ + pkg.name, + new Set( + INTERNAL_DEPENDENCY_FIELDS.flatMap((field) => + Object.keys(pkg.pkg[field] ?? {}), + ).filter((name) => scopeNames.has(name)), + ), + ]), + ); + const pending = new Set(scopePackages.map((pkg) => pkg.name)); + const ordered: PublishablePackage[] = []; + + while (pending.size > 0) { + const ready = scopePackages.filter( + (pkg) => + pending.has(pkg.name) && + [...(internalDependencies.get(pkg.name) ?? [])].every( + (dependency) => !pending.has(dependency), + ), + ); + if (ready.length === 0) { + throw new Error( + `Circular package dependency in scope ${scope}: ${[...pending].join(", ")}`, + ); + } + for (const pkg of ready) { + pending.delete(pkg.name); + ordered.push(pkg); } } - return results; + return ordered; } /** Bump all packages in a scope to a new version. For sharedVersion scopes, also updates internal deps. */ diff --git a/scripts/release/prepare-release.ts b/scripts/release/prepare-release.ts index 38eea03e29..ac36224cb7 100644 --- a/scripts/release/prepare-release.ts +++ b/scripts/release/prepare-release.ts @@ -12,14 +12,12 @@ import { computeNextStableVersion, bumpPackages, getPackagesForScope, - type BumpLevel, } from "./lib/versions.js"; -import { - getChangesSummary, - type ChangesSummary, - type Commit, -} from "./lib/changes.js"; -import { ROOT, loadConfig, type ReleaseScope } from "./lib/config.js"; +import type { BumpLevel } from "./lib/versions.js"; +import { getChangesSummary } from "./lib/changes.js"; +import type { ChangesSummary, Commit } from "./lib/changes.js"; +import { ROOT, loadConfig } from "./lib/config.js"; +import type { ReleaseScope } from "./lib/config.js"; function generateRawReleaseNotes( version: string, @@ -103,7 +101,7 @@ function main() { console.log(`Bump level: ${bumpLevel}`); console.log(`Next version: ${nextVersion}`); - const summary = getChangesSummary(); + const summary = getChangesSummary(scope); console.log( `\nCommits since ${summary.lastTag || "beginning"}: ${summary.commitCount}`, ); diff --git a/scripts/release/publish-release.ts b/scripts/release/publish-release.ts index deeeba0ec6..2841104257 100644 --- a/scripts/release/publish-release.ts +++ b/scripts/release/publish-release.ts @@ -28,14 +28,12 @@ import { parseSemver, } from "./lib/versions.js"; import { readReleaseDraft } from "./lib/notion.js"; -import { - ROOT, - getScopeConfig, - loadConfig, - type ReleaseScope, -} from "./lib/config.js"; +import { ROOT, getScopeConfig, loadConfig } from "./lib/config.js"; +import type { ReleaseScope } from "./lib/config.js"; import { emitGithubOutputs } from "./lib/github-output.js"; +type PublishPhase = "all" | "dependencies" | "umbrella"; + function run(cmd: string, args: string[], opts?: { cwd?: string }) { const result = spawnSync(cmd, args, { cwd: opts?.cwd ?? ROOT, @@ -86,6 +84,10 @@ async function main() { const scope = ( scopeIdx !== -1 ? argv[scopeIdx + 1] : null ) as ReleaseScope | null; + const phaseIdx = argv.indexOf("--phase"); + const phase = (phaseIdx !== -1 ? argv[phaseIdx + 1] : "all") as + | PublishPhase + | undefined; if (!scope || !VALID_SCOPES.includes(scope)) { console.error( @@ -93,6 +95,16 @@ async function main() { ); process.exit(1); } + if (!phase || !["all", "dependencies", "umbrella"].includes(phase)) { + console.error("Usage: --phase "); + process.exit(1); + } + if (scope !== "channels" && phase !== "all") { + console.error( + `Publish phase ${phase} is only valid for the channels scope.`, + ); + process.exit(1); + } const version = getCurrentVersion(scope); const scopeConfig = getScopeConfig(scope); @@ -107,9 +119,22 @@ async function main() { ); process.exit(1); } + const packagesToPublish = + phase === "dependencies" + ? packages.filter((pkg) => pkg.name !== scopeConfig.versionSource) + : phase === "umbrella" + ? packages.filter((pkg) => pkg.name === scopeConfig.versionSource) + : packages; + if (packagesToPublish.length === 0) { + console.error( + `No packages found for publish phase ${phase} in scope ${scope}.`, + ); + process.exit(1); + } console.log(`Scope: ${scope}`); console.log(`Publishing version: ${version}`); + console.log(`Publish phase: ${phase}`); // Safety check: only allow clean semver (no prerelease suffixes like -canary.123) const v = parseSemver(version); @@ -146,7 +171,7 @@ async function main() { const notionRefPath = path.join(ROOT, "release-notes-notion.json"); const releaseNotesPath = path.join(ROOT, "release-notes.md"); - if (fs.existsSync(notionRefPath)) { + if (phase !== "dependencies" && fs.existsSync(notionRefPath)) { try { const ref = JSON.parse(fs.readFileSync(notionRefPath, "utf8")); if (ref.pageId && process.env.NOTION_API_KEY) { @@ -175,7 +200,7 @@ async function main() { // Skips packages already published at this version (idempotent retries). console.log("\nPublishing packages..."); let skipped = 0; - for (const p of packages) { + for (const p of packagesToPublish) { const pubVersion = getPublishedVersion(p.name); if (pubVersion === version) { console.log(` Skipping ${p.name}@${version} (already published)`); @@ -204,10 +229,14 @@ async function main() { console.log(`\n${skipped} package(s) skipped (already at ${version}).`); } - // Output version for downstream steps - emitGithubOutputs({ version, scope }); - - console.log(`\nRelease published: ${version} (${scope})`); + if (phase === "dependencies") { + console.log(`\nRelease dependencies published: ${version} (${scope})`); + } else { + // Output version for downstream tag/release steps only after the final + // package in the scope has been published. + emitGithubOutputs({ version, scope }); + console.log(`\nRelease published: ${version} (${scope})`); + } } main().catch((err) => {