diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 7b8d2edb3b..e142872ce8 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -3,6 +3,14 @@ name: release / pre on: workflow_dispatch: inputs: + scope: + description: "What to release" + required: true + type: choice + options: + - monorepo + - cli + - angular suffix: description: "Version suffix (e.g. 'fix-user-issue'). Leave blank for timestamp." required: false @@ -55,9 +63,9 @@ jobs: - name: Publish prerelease run: | - ARGS="" + ARGS="--scope ${{ inputs.scope }}" if [ -n "${{ inputs.suffix }}" ]; then - ARGS="--suffix ${{ inputs.suffix }}" + ARGS="$ARGS --suffix ${{ inputs.suffix }}" fi if [ "${{ inputs.dry_run }}" == "true" ]; then ARGS="$ARGS --dry-run" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 57c26cac65..c7e6943fd4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -20,10 +20,19 @@ jobs: # Only run when a release PR is merged (not just closed) if: > github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/publish/v') + startsWith(github.event.pull_request.head.ref, 'release/publish/') runs-on: ubuntu-latest timeout-minutes: 20 steps: + - name: Extract scope from branch + id: meta + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + # Branch format: release/publish//v + SCOPE=$(echo "$BRANCH" | sed 's|release/publish/\([^/]*\)/v.*|\1|') + echo "scope=$SCOPE" >> $GITHUB_OUTPUT + echo "Detected scope: $SCOPE" + - name: Checkout Repo uses: actions/checkout@v4 with: @@ -54,7 +63,7 @@ jobs: - name: Publish to npm id: publish - run: pnpm tsx scripts/release/publish-release.ts + run: pnpm tsx scripts/release/publish-release.ts --scope ${{ steps.meta.outputs.scope }} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -67,22 +76,33 @@ jobs: - name: Create and push git tag run: | - TAG="v${{ steps.publish.outputs.version }}" - git tag -a "$TAG" -m "Release ${{ steps.publish.outputs.version }}" + SCOPE="${{ steps.meta.outputs.scope }}" + VERSION="${{ steps.publish.outputs.version }}" + if [ "$SCOPE" == "monorepo" ]; then + TAG="v${VERSION}" + else + TAG="${SCOPE}/v${VERSION}" + fi + git tag -a "$TAG" -m "Release ${SCOPE} ${VERSION}" git push origin "$TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + id: tag - name: Create GitHub Release uses: actions/github-script@v7 env: - RELEASE_TAG: v${{ steps.publish.outputs.version }} - RELEASE_NAME: v${{ steps.publish.outputs.version }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + RELEASE_SCOPE: ${{ steps.meta.outputs.scope }} + RELEASE_VERSION: ${{ steps.publish.outputs.version }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require("fs"); const { owner, repo } = context.repo; const tag = process.env.RELEASE_TAG; - const name = process.env.RELEASE_NAME; + const scope = process.env.RELEASE_SCOPE; + const version = process.env.RELEASE_VERSION; + const name = scope === "monorepo" ? `v${version}` : `${scope}/v${version}`; let body = ""; try { @@ -112,6 +132,6 @@ jobs: run: | echo "## Release Published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "**Scope:** ${{ steps.meta.outputs.scope }}" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ steps.publish.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**Tag:** v${{ steps.publish.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**npm:** https://www.npmjs.com/package/@copilotkit/react-core/v/${{ steps.publish.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 3b26f97daf..74b12bca99 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -3,6 +3,14 @@ name: release / create-pr on: workflow_dispatch: inputs: + scope: + description: "What to release" + required: true + type: choice + options: + - monorepo + - cli + - angular bump: description: "Version bump level" required: true @@ -44,10 +52,10 @@ jobs: owner, repo, state: "open", - head_prefix: `${owner}:release/publish/v`, + head_prefix: `${owner}:release/publish/`, }); - const releasePRs = prs.filter(pr => pr.head.ref.startsWith("release/publish/v")); + const releasePRs = prs.filter(pr => pr.head.ref.startsWith("release/publish/")); if (releasePRs.length > 0) { const existing = releasePRs.map(pr => ` - #${pr.number}: ${pr.title} (${pr.html_url})`).join("\n"); core.setFailed( @@ -78,9 +86,9 @@ jobs: id: prepare run: | if [ "${{ inputs.dry_run }}" == "true" ]; then - pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} --dry-run + pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} --scope ${{ inputs.scope }} --dry-run else - pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} + pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} --scope ${{ inputs.scope }} fi - name: Generate AI release notes @@ -98,21 +106,21 @@ jobs: uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} - branch: release/publish/v${{ steps.prepare.outputs.version }} + branch: release/publish/${{ inputs.scope }}/v${{ steps.prepare.outputs.version }} delete-branch: true - commit-message: "chore: release v${{ steps.prepare.outputs.version }}" - title: "chore: release v${{ steps.prepare.outputs.version }}" + commit-message: "chore: release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }}" + title: "chore: release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }}" body: | - ## Release v${{ steps.prepare.outputs.version }} + ## Release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }} - **Bump:** `${{ inputs.bump }}` + **Scope:** `${{ inputs.scope }}` | **Bump:** `${{ inputs.bump }}` --- ### How this release process works - 1. **This PR was created automatically** by the "Create Release PR" workflow. - It bumped all `@copilotkit/*` package versions to `${{ steps.prepare.outputs.version }}` + 1. **This PR was created automatically** by the "release / create-pr" workflow. + It bumped the `${{ inputs.scope }}` packages to `${{ steps.prepare.outputs.version }}` and generated AI-enhanced release notes. 2. **CI runs on this PR** — the full test suite (unit tests, lint, type checks, build) @@ -121,10 +129,10 @@ jobs: 3. **Review the release notes** in `release-notes.md` in this PR. If a Notion draft was created, you can edit the release notes there before merging. - 4. **When this PR is merged**, the `publish-release` workflow automatically: + 4. **When this PR is merged**, the `release / publish` workflow automatically: - Builds all packages - - Publishes all `@copilotkit/*` packages to npm at version `${{ steps.prepare.outputs.version }}` - - Creates git tag `v${{ steps.prepare.outputs.version }}` + - Publishes the `${{ inputs.scope }}` packages to npm at version `${{ steps.prepare.outputs.version }}` + - Creates git tag `${{ inputs.scope }}/v${{ steps.prepare.outputs.version }}` - Creates a GitHub Release with the final release notes ### Before merging diff --git a/package.json b/package.json index 0b5a42848e..1ebe7f7742 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "docs": "pnpm -C examples/v2/docs dev", "demo:angular": "nx run-many -t dev --projects=examples/v2/angular/demo,examples/v2/angular/demo-server", "demo:react": "pnpm -C examples/v2/react/demo dev", - "release:prepare:dry": "tsx scripts/release/prepare-release.ts --dry-run", - "release:prerelease:dry": "tsx scripts/release/prerelease.ts --dry-run", + "release:prepare:dry": "tsx scripts/release/prepare-release.ts --scope monorepo --bump patch --dry-run", + "release:prerelease:dry": "tsx scripts/release/prerelease.ts --scope monorepo --dry-run", "prepare": "lefthook install", "graph": "nx graph", "publint": "nx run-many -t publint --projects=packages/**", diff --git a/release.config.json b/release.config.json index cfb95e02cd..30288ce819 100644 --- a/release.config.json +++ b/release.config.json @@ -1,18 +1,33 @@ { "prereleaseTag": "canary", - "versionedTogether": [ - "@copilotkit/a2ui-renderer", - "@copilotkit/core", - "@copilotkit/react-core", - "@copilotkit/react-textarea", - "@copilotkit/react-ui", - "@copilotkit/runtime", - "@copilotkit/runtime-client-gql", - "@copilotkit/sdk-js", - "@copilotkit/shared", - "@copilotkit/sqlite-runner", - "@copilotkit/voice", - "@copilotkit/web-inspector" - ], - "versionedIndependently": ["@copilotkitnext/angular", "copilotkit"] + "scopes": { + "monorepo": { + "packages": [ + "@copilotkit/a2ui-renderer", + "@copilotkit/core", + "@copilotkit/react-core", + "@copilotkit/react-textarea", + "@copilotkit/react-ui", + "@copilotkit/runtime", + "@copilotkit/runtime-client-gql", + "@copilotkit/sdk-js", + "@copilotkit/shared", + "@copilotkit/sqlite-runner", + "@copilotkit/voice", + "@copilotkit/web-inspector" + ], + "versionSource": "@copilotkit/react-core", + "sharedVersion": true + }, + "cli": { + "packages": ["copilotkit"], + "versionSource": "copilotkit", + "sharedVersion": false + }, + "angular": { + "packages": ["@copilotkitnext/angular"], + "versionSource": "@copilotkitnext/angular", + "sharedVersion": false + } + } } diff --git a/scripts/release/lib/config.ts b/scripts/release/lib/config.ts index 5c4d1afb7a..65bda869f2 100644 --- a/scripts/release/lib/config.ts +++ b/scripts/release/lib/config.ts @@ -7,13 +7,31 @@ export const ROOT = path.resolve( "../../..", ); +export type ReleaseScope = "monorepo" | "cli" | "angular"; + +export interface ScopeConfig { + packages: string[]; + versionSource: string; + sharedVersion: boolean; +} + export interface ReleaseConfig { prereleaseTag: string; - versionedTogether: string[]; - versionedIndependently: string[]; + scopes: Record; } export function loadConfig(): ReleaseConfig { const configPath = path.join(ROOT, "release.config.json"); return JSON.parse(fs.readFileSync(configPath, "utf8")); } + +export function getScopeConfig(scope: ReleaseScope): ScopeConfig { + const config = loadConfig(); + const scopeConfig = config.scopes[scope]; + if (!scopeConfig) { + throw new Error( + `Unknown scope: ${scope}. Valid scopes: ${Object.keys(config.scopes).join(", ")}`, + ); + } + return scopeConfig; +} diff --git a/scripts/release/lib/versions.test.ts b/scripts/release/lib/versions.test.ts index 033210f70e..69bab44d27 100644 --- a/scripts/release/lib/versions.test.ts +++ b/scripts/release/lib/versions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { parseSemver, computeNextStableVersion, @@ -10,9 +10,36 @@ vi.mock("./config.js", () => ({ ROOT: "/mock", loadConfig: () => ({ prereleaseTag: "canary", - versionedTogether: [], - versionedIndependently: [], + scopes: { + monorepo: { + packages: [], + versionSource: "@copilotkit/react-core", + sharedVersion: true, + }, + cli: { packages: [], versionSource: "copilotkit", sharedVersion: false }, + angular: { + packages: [], + versionSource: "@copilotkitnext/angular", + sharedVersion: false, + }, + }, }), + getScopeConfig: (scope: string) => { + const scopes: Record = { + monorepo: { + packages: [], + versionSource: "@copilotkit/react-core", + sharedVersion: true, + }, + cli: { packages: [], versionSource: "copilotkit", sharedVersion: false }, + angular: { + packages: [], + versionSource: "@copilotkitnext/angular", + sharedVersion: false, + }, + }; + return scopes[scope]; + }, })); describe("parseSemver", () => { diff --git a/scripts/release/lib/versions.ts b/scripts/release/lib/versions.ts index e56adf5bd6..90a1a241ff 100644 --- a/scripts/release/lib/versions.ts +++ b/scripts/release/lib/versions.ts @@ -1,6 +1,11 @@ import fs from "fs"; import path from "path"; -import { loadConfig, ROOT } from "./config.js"; +import { + loadConfig, + getScopeConfig, + ROOT, + type ReleaseScope, +} from "./config.js"; export type BumpLevel = "patch" | "minor" | "major"; @@ -16,13 +21,27 @@ export interface PublishablePackage { dir: string; pkgJsonPath: string; pkg: Record; - isVersionedTogether: boolean; - isVersionedIndependently: boolean; } -export function getCurrentVersion(): string { - const pkgPath = path.join(ROOT, "packages/react-core/package.json"); - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); +/** Find a package directory by its npm name. */ +function findPackageDir(packageName: string): string { + const packagesDir = path.join(ROOT, "packages"); + 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 (pkg.name === packageName) return path.join(packagesDir, dir); + } + throw new Error(`Package not found: ${packageName}`); +} + +/** Get the current version for a scope (reads from the scope's versionSource package). */ +export function getCurrentVersion(scope: ReleaseScope): string { + const scopeConfig = getScopeConfig(scope); + const dir = findPackageDir(scopeConfig.versionSource); + const pkg = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf8"), + ); return pkg.version; } @@ -72,12 +91,10 @@ export function computePrereleaseVersion( return `${v.major}.${v.minor}.${v.patch}-${tag}.${id}`; } -export function getPublishablePackages(): PublishablePackage[] { - const config = loadConfig(); - const allPackageNames = new Set([ - ...config.versionedTogether, - ...config.versionedIndependently, - ]); +/** 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 results: PublishablePackage[] = []; @@ -86,16 +103,12 @@ export function getPublishablePackages(): PublishablePackage[] { if (!fs.existsSync(pkgJsonPath)) continue; const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); - if (allPackageNames.has(pkg.name)) { + if (packageNames.has(pkg.name)) { results.push({ name: pkg.name, dir: path.join(packagesDir, dir), pkgJsonPath, pkg, - isVersionedTogether: config.versionedTogether.includes(pkg.name), - isVersionedIndependently: config.versionedIndependently.includes( - pkg.name, - ), }); } } @@ -103,14 +116,14 @@ export function getPublishablePackages(): PublishablePackage[] { return results; } -export function bumpVersionedTogetherPackages( +/** Bump all packages in a scope to a new version. For sharedVersion scopes, also updates internal deps. */ +export function bumpPackages( + scope: ReleaseScope, newVersion: string, ): { name: string; oldVersion: string; newVersion: string }[] { - const packages = getPublishablePackages().filter( - (p) => p.isVersionedTogether, - ); - const config = loadConfig(); - const togetherNames = new Set(config.versionedTogether); + const scopeConfig = getScopeConfig(scope); + const packages = getPackagesForScope(scope); + const scopeNames = new Set(scopeConfig.packages); const updated: { name: string; oldVersion: string; newVersion: string }[] = []; @@ -119,15 +132,18 @@ export function bumpVersionedTogetherPackages( const oldVersion = pkg.version; pkg.version = newVersion; - for (const depField of [ - "dependencies", - "peerDependencies", - "devDependencies", - ] as const) { - if (!pkg[depField]) continue; - for (const depName of Object.keys(pkg[depField])) { - if (togetherNames.has(depName)) { - pkg[depField][depName] = newVersion; + // For shared-version scopes, update internal dependency references too + if (scopeConfig.sharedVersion) { + for (const depField of [ + "dependencies", + "peerDependencies", + "devDependencies", + ] as const) { + if (!pkg[depField]) continue; + for (const depName of Object.keys(pkg[depField])) { + if (scopeNames.has(depName)) { + pkg[depField][depName] = newVersion; + } } } } diff --git a/scripts/release/prepare-release.ts b/scripts/release/prepare-release.ts index 93997418d8..b3b0bfd336 100644 --- a/scripts/release/prepare-release.ts +++ b/scripts/release/prepare-release.ts @@ -2,7 +2,7 @@ * Prepare a release: bump versions, generate raw release notes. * Runs inside the "create release PR" workflow. * - * Usage: tsx scripts/release/prepare-release.ts --bump [--dry-run] + * Usage: tsx scripts/release/prepare-release.ts --bump --scope [--dry-run] */ import fs from "fs"; @@ -10,8 +10,8 @@ import path from "path"; import { getCurrentVersion, computeNextStableVersion, - bumpVersionedTogetherPackages, - getPublishablePackages, + bumpPackages, + getPackagesForScope, type BumpLevel, } from "./lib/versions.js"; import { @@ -19,14 +19,16 @@ import { type ChangesSummary, type Commit, } from "./lib/changes.js"; -import { ROOT } from "./lib/config.js"; +import { ROOT, type ReleaseScope } from "./lib/config.js"; function generateRawReleaseNotes( version: string, + scope: ReleaseScope, summary: ChangesSummary, ): string { const lines: string[] = []; - lines.push(`## v${version}`, ""); + const label = scope === "monorepo" ? "" : ` (${scope})`; + lines.push(`## v${version}${label}`, ""); if (summary.commits.length === 0) { lines.push("No changes since last release."); @@ -65,6 +67,8 @@ function generateRawReleaseNotes( return lines.join("\n"); } +const VALID_SCOPES = ["monorepo", "cli", "angular"]; + function main() { const argv = process.argv.slice(2); const dryRun = argv.includes("--dry-run"); @@ -72,14 +76,28 @@ function main() { const bumpLevel = ( bumpIdx !== -1 ? argv[bumpIdx + 1] : null ) as BumpLevel | null; + const scopeIdx = argv.indexOf("--scope"); + const scope = ( + scopeIdx !== -1 ? argv[scopeIdx + 1] : null + ) as ReleaseScope | null; if (!bumpLevel || !["patch", "minor", "major"].includes(bumpLevel)) { - console.error("Usage: prepare-release.ts --bump "); + console.error( + "Usage: prepare-release.ts --bump --scope ", + ); process.exit(1); } - const currentVersion = getCurrentVersion(); + if (!scope || !VALID_SCOPES.includes(scope)) { + console.error( + `Invalid scope: ${scope}. Valid scopes: ${VALID_SCOPES.join(", ")}`, + ); + process.exit(1); + } + + const currentVersion = getCurrentVersion(scope); const nextVersion = computeNextStableVersion(currentVersion, bumpLevel); + console.log(`Scope: ${scope}`); console.log(`Current version: ${currentVersion}`); console.log(`Bump level: ${bumpLevel}`); console.log(`Next version: ${nextVersion}`); @@ -91,19 +109,17 @@ function main() { if (dryRun) { console.log("\n[DRY RUN] Would bump these packages:"); - for (const p of getPublishablePackages().filter( - (p) => p.isVersionedTogether, - )) { + for (const p of getPackagesForScope(scope)) { console.log(` ${p.name}: ${p.pkg.version} -> ${nextVersion}`); } console.log("\n[DRY RUN] Exiting."); return; } - const updated = bumpVersionedTogetherPackages(nextVersion); + const updated = bumpPackages(scope, nextVersion); console.log(`\nBumped ${updated.length} packages to ${nextVersion}`); - const rawNotes = generateRawReleaseNotes(nextVersion, summary); + const rawNotes = generateRawReleaseNotes(nextVersion, scope, summary); const releaseNotesPath = path.join(ROOT, "release-notes.md"); fs.writeFileSync(releaseNotesPath, rawNotes); console.log("Raw release notes written to release-notes.md"); @@ -111,10 +127,10 @@ function main() { const outputPath = process.env.GITHUB_OUTPUT; if (outputPath) { fs.appendFileSync(outputPath, `version=${nextVersion}\n`); - fs.appendFileSync(outputPath, `tag=v${nextVersion}\n`); + fs.appendFileSync(outputPath, `scope=${scope}\n`); } - console.log(`\nRelease prepared: v${nextVersion}`); + console.log(`\nRelease prepared: v${nextVersion} (${scope})`); } main(); diff --git a/scripts/release/prerelease.ts b/scripts/release/prerelease.ts index c5a35b5516..af81119f0d 100644 --- a/scripts/release/prerelease.ts +++ b/scripts/release/prerelease.ts @@ -1,23 +1,23 @@ /** * Publish a prerelease (no git commits, no PRs). * - * Takes the current version on main and appends a canary suffix. + * Takes the current version for the given scope and appends a canary suffix. * If --suffix is provided (e.g. "fix-user-issue"), the version becomes * 1.55.2-canary.fix-user-issue. Otherwise it uses a unix timestamp. * * Always publishes with the "canary" dist-tag. * - * Usage: tsx scripts/release/prerelease.ts [--suffix