diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2116a875..22ad5f2c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -37,6 +37,9 @@ jobs: - name: Check tsconfig refs run: pnpm check:tsrefs + - name: Check cascade version bumps + run: pnpm versions + test-posix: name: Test (${{ matrix.os }}) strategy: diff --git a/.internal/check-versions.ts b/.internal/check-versions.ts new file mode 100644 index 00000000..5c3c61c5 --- /dev/null +++ b/.internal/check-versions.ts @@ -0,0 +1,118 @@ +import { resolve } from "node:path"; +import process from "node:process"; +import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { x } from "@effectionx/tinyexec"; +import { main } from "effection"; +import { inc as semverInc } from "semver"; +import { buildDepGraph, getTransitiveDependents } from "./lib/dep-graph.ts"; + +const mode = process.argv[2] ?? "check"; + +if (!["check", "sync"].includes(mode)) { + console.error("Usage: node .internal/check-versions.ts [check|sync]"); + process.exit(1); +} + +const isSyncMode = mode === "sync"; + +await main(function* () { + const graph = yield* buildDepGraph(); + + // Determine which packages have a version not yet published to npm. + const pendingRelease = new Set(); + + for (const pkg of graph.packages.values()) { + const npmCheck = yield* x( + "npm", + ["view", `${pkg.name}@${pkg.version}`, "version"], + { throwOnError: false }, + ); + const result = yield* npmCheck; + + if (result.exitCode !== 0 || result.stdout.trim() === "") { + pendingRelease.add(pkg.name); + } + } + + if (pendingRelease.size === 0) { + console.log( + "All package versions are already published. Nothing to check.", + ); + return; + } + + console.log( + `Pending releases: ${[...pendingRelease] + .map((name) => { + const pkg = graph.packages.get(name)!; + return `${name}@${pkg.version}`; + }) + .join(", ")}`, + ); + + // Find all transitive dependents of the pending releases. + const requiredBumps = getTransitiveDependents(graph, pendingRelease); + + // Remove packages that are already pending release — they're fine. + for (const name of pendingRelease) { + requiredBumps.delete(name); + } + + if (requiredBumps.size === 0) { + console.log("All cascade version bumps are in order."); + return; + } + + if (isSyncMode) { + // Apply patch bumps to all packages that need them. + const bumped: Array<{ name: string; from: string; to: string }> = []; + + for (const name of requiredBumps) { + const pkg = graph.packages.get(name)!; + const newVersion = semverInc(pkg.version, "patch"); + if (!newVersion) { + console.error(`Failed to increment version for ${name}@${pkg.version}`); + process.exit(1); + } + + const packageJsonPath = resolve(pkg.workspacePath, "package.json"); + const raw = yield* readTextFile(packageJsonPath); + const json = JSON.parse(raw) as Record; + json.version = newVersion; + yield* writeTextFile( + packageJsonPath, + `${JSON.stringify(json, null, 2)}\n`, + ); + + bumped.push({ name, from: pkg.version, to: newVersion }); + } + + console.log("\nBumped cascade versions:"); + for (const { name, from, to } of bumped) { + console.log(` ${name} ${from} → ${to}`); + } + } else { + // Check mode: report missing bumps and fail. + console.error("\nMissing cascade version bumps:\n"); + + // Group by the dependency that triggered the cascade. + for (const name of requiredBumps) { + const pkg = graph.packages.get(name)!; + // Find which of its deps triggered this. + const triggers = pkg.publishedWorkspaceDeps.filter((dep) => + pendingRelease.has(dep), + ); + const triggerStr = triggers + .map((t) => { + const tp = graph.packages.get(t)!; + return `${t}@${tp.version}`; + }) + .join(", "); + + console.error(` ${name} (${pkg.version}) — depends on ${triggerStr}`); + } + + console.error("\nRun `pnpm versions:sync` to fix automatically.\n"); + process.exit(1); + } +}); diff --git a/.internal/lib/dep-graph.ts b/.internal/lib/dep-graph.ts new file mode 100644 index 00000000..4ea31c08 --- /dev/null +++ b/.internal/lib/dep-graph.ts @@ -0,0 +1,161 @@ +import { resolve } from "node:path"; +import process from "node:process"; +import { readTextFile } from "@effectionx/fs"; +import type { Operation } from "effection"; + +export interface WorkspacePackage { + /** Package name, e.g. "@effectionx/bdd". */ + name: string; + /** Current version from package.json. */ + version: string; + /** Workspace directory name, e.g. "bdd". */ + workspace: string; + /** Absolute path to workspace directory. */ + workspacePath: string; + /** True if the package is private. */ + private: boolean; + /** + * Names of workspace packages listed in `dependencies` or + * `peerDependencies` with `workspace:*` protocol. + */ + publishedWorkspaceDeps: string[]; +} + +export interface DepGraph { + /** All non-private workspace packages indexed by name. */ + packages: Map; + /** + * Reverse map: package name → names of packages that depend on it + * via published deps (`dependencies` or `peerDependencies`). + */ + dependents: Map; +} + +/** + * Build the published dependency graph for all workspace packages. + * + * Only `dependencies` and `peerDependencies` with `workspace:*` are + * considered because `devDependencies` are stripped at publish time. + */ +export function* buildDepGraph(): Operation { + const rootDir = process.cwd(); + + const workspaceYaml = yield* readTextFile( + resolve(rootDir, "pnpm-workspace.yaml"), + ); + + const workspaces: string[] = []; + for (const line of workspaceYaml.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("-")) { + const value = trimmed.replace(/^-\s*/, "").replace(/^["']|["']$/g, ""); + if (value) { + workspaces.push(value); + } + } + } + + // Read all package.json files and build WorkspacePackage entries. + const allPackages: WorkspacePackage[] = []; + + for (const workspace of workspaces) { + const workspacePath = resolve(rootDir, workspace); + const raw = yield* readTextFile(resolve(workspacePath, "package.json")); + const json = JSON.parse(raw) as Record; + + allPackages.push({ + name: json.name as string, + version: json.version as string, + workspace, + workspacePath, + private: Boolean(json.private), + publishedWorkspaceDeps: [], // filled below + }); + } + + // Index non-private packages by name. + const packages = new Map(); + for (const pkg of allPackages) { + if (!pkg.private) { + packages.set(pkg.name, pkg); + } + } + + const packageNames = new Set(packages.keys()); + + // Resolve published workspace deps and build reverse map. + const dependents = new Map(); + + for (const pkg of packages.values()) { + const raw = yield* readTextFile(resolve(pkg.workspacePath, "package.json")); + const json = JSON.parse(raw) as Record; + + const deps = collectWorkspaceDeps( + json.dependencies as Record | undefined, + packageNames, + ); + const peerDeps = collectWorkspaceDeps( + json.peerDependencies as Record | undefined, + packageNames, + ); + + const combined = [...new Set([...deps, ...peerDeps])].sort(); + pkg.publishedWorkspaceDeps = combined; + + for (const dep of combined) { + if (!dependents.has(dep)) { + dependents.set(dep, []); + } + dependents.get(dep)!.push(pkg.name); + } + } + + return { packages, dependents }; +} + +/** + * Walk the dependency graph and return all transitive dependents of the + * given root package names. + */ +export function getTransitiveDependents( + graph: DepGraph, + roots: Iterable, +): Set { + const visited = new Set(); + const queue = [...roots]; + + while (queue.length > 0) { + const current = queue.pop()!; + const deps = graph.dependents.get(current) ?? []; + for (const dep of deps) { + if (!visited.has(dep)) { + visited.add(dep); + queue.push(dep); + } + } + } + + return visited; +} + +/** Extract workspace package names that use `workspace:*` protocol. */ +function collectWorkspaceDeps( + depsObject: Record | undefined, + packageNames: Set, +): string[] { + if (!depsObject || typeof depsObject !== "object") { + return []; + } + + const result: string[] = []; + for (const [name, range] of Object.entries(depsObject)) { + if ( + packageNames.has(name) && + typeof range === "string" && + range.startsWith("workspace:") + ) { + result.push(name); + } + } + return result; +} diff --git a/.policies/version-bump.md b/.policies/version-bump.md index 1b6ef7fc..f4a3cfe5 100644 --- a/.policies/version-bump.md +++ b/.policies/version-bump.md @@ -67,11 +67,34 @@ Changed files: Violation: Source code changed but version was not bumped. ``` +## Cascade Rule + +**When a package is bumped, all packages that list it as a published dependency +(`dependencies` or `peerDependencies` with `workspace:*`) must also be bumped.** + +This ensures dependents are republished with the updated dependency range. +`devDependencies` are excluded because they are stripped at publish time. + +### Tooling + +| Command | Purpose | +|---------|---------| +| `pnpm versions` | **Check** — verifies all cascade bumps are present (runs in CI) | +| `pnpm versions:sync` | **Fix** — auto-applies patch bumps to dependents that need them | + +### Example + +If `@effectionx/test-adapter` is bumped from `0.7.3` to `0.7.4`: +- `@effectionx/bdd` depends on it via `"@effectionx/test-adapter": "workspace:*"` +- `@effectionx/bdd` must also be bumped (at minimum a patch bump) +- Run `pnpm versions:sync` to apply the cascade bumps automatically + ## Verification Checklist - [ ] If source files changed, `package.json` version was bumped - [ ] Version bump type matches the change (major/minor/patch) - [ ] Only one package version bumped per PR (unless changes span packages) +- [ ] Cascade bumps applied for all published dependents (`pnpm versions` passes) ## Common Mistakes @@ -80,6 +103,7 @@ Violation: Source code changed but version was not bumped. | Forgetting to bump version after bug fix | Add patch version bump to `package.json` | | Using patch for new features | Use minor version bump instead | | Bumping version for test-only changes | Remove unnecessary version bump | +| Forgetting to bump dependents after a dep changes | Run `pnpm versions:sync` | ## Related Policies diff --git a/package.json b/package.json index 69f5361e..f89851c9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "fmt:check": "biome format .", "sync": "node --env-file=.env .internal/sync-tsrefs.ts", "sync:fix": "node --env-file=.env .internal/sync-tsrefs.ts fix", - "check:tsrefs": "node --env-file=.env .internal/sync-tsrefs.ts check" + "check:tsrefs": "node --env-file=.env .internal/sync-tsrefs.ts check", + "versions": "node --env-file=.env .internal/check-versions.ts", + "versions:sync": "node --env-file=.env .internal/check-versions.ts sync" }, "devDependencies": { "@biomejs/biome": "^1",