mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
fix(release): stage channels publishing
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, any> = {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<string, PublishablePackage>();
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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 <all|dependencies|umbrella>");
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user