diff --git a/.changeset/plenty-hounds-shop.md b/.changeset/plenty-hounds-shop.md new file mode 100644 index 000000000..006f1d653 --- /dev/null +++ b/.changeset/plenty-hounds-shop.md @@ -0,0 +1,17 @@ +--- +"@changesets/apply-release-plan": minor +"@changesets/assemble-release-plan": minor +"@changesets/catalogs": minor +"@changesets/cli": minor +"@changesets/config": minor +"@changesets/get-dependents-graph": minor +"@changesets/get-release-plan": minor +"@changesets/git": minor +"@changesets/types": minor +--- + +Add support for dependency catalogs. + +Ranges declared through the `catalog:` protocol are now resolved wherever Changesets looks at a dependency range, so a package depending on another one in the workspace through a catalog is released exactly as it would be with the range written out in full. Releasing a package a catalog points at updates the catalog entry itself, keeping its range style, while the packages referencing it keep saying `catalog:`. Both the default catalog and named catalogs are supported, in pnpm (`pnpm-workspace.yaml`), Yarn (`.yarnrc.yml`) and Bun (`package.json`) workspaces. + +Editing a dependency range in a package's own `package.json` marks that package as changed. A catalog belongs to no package in particular, so `changeset add` and `changeset status` treat an updated catalog entry as a change to every package referencing it. Set the new `detectCatalogChanges` config option to `false` to opt out. diff --git a/docs/config-file-options.md b/docs/config-file-options.md index 74ad15f5f..987f5eb1c 100644 --- a/docs/config-file-options.md +++ b/docs/config-file-options.md @@ -185,6 +185,16 @@ Default value: `false` Determines whether Changesets should only bump dependency ranges that use workspace protocol of packages that are part of the workspace. +## `detectCatalogChanges` (optional boolean) + +Default value: `true` + +Determines whether updating the version range of a catalog entry (pnpm's `pnpm-workspace.yaml`, Yarn's `.yarnrc.yml` or Bun's `package.json`) counts as a change to every package that references it through the `catalog:` protocol, for the purposes of `changeset add` and `changeset status`. + +Editing a dependency range in a package's own `package.json` marks that package as changed, because the file lives inside the package. A catalog lives at the root of the workspace, so without this option an updated entry would go unnoticed. Set this to `false` if you'd rather catalog updates never ask for a changeset. + +This option has no effect on packages inside your workspace that are referenced through a catalog. Those are always resolved and released as if the range had been written out in full. + ## `snapshot` (object or undefined) Default value: `undefined` diff --git a/packages/apply-release-plan/package.json b/packages/apply-release-plan/package.json index 3d26de3db..b82d8a954 100644 --- a/packages/apply-release-plan/package.json +++ b/packages/apply-release-plan/package.json @@ -17,6 +17,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@changesets/catalogs": "workspace:^", "@changesets/config": "workspace:^", "@changesets/format": "^0.1.1", "@changesets/git": "workspace:^", diff --git a/packages/apply-release-plan/src/catalog.test.ts b/packages/apply-release-plan/src/catalog.test.ts new file mode 100644 index 000000000..9dee12435 --- /dev/null +++ b/packages/apply-release-plan/src/catalog.test.ts @@ -0,0 +1,170 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { withCatalogs } from "@changesets/catalogs"; +import { defaultConfig } from "@changesets/config"; +import { testdir } from "@changesets/test-utils"; +import type { Config, ReleasePlan } from "@changesets/types"; +import { getPackages } from "@manypkg/get-packages"; +import { describe, expect, it } from "vitest"; +import { applyReleasePlan } from "./index.ts"; + +const workspaceYaml = `packages: + - packages/* + +catalog: + # the one everything shares + pkg-a: ^1.0.0 + react: ^19.0.0 + +catalogs: + internal: + pkg-a: 1.0.0 +`; + +const releasePlan: ReleasePlan = { + changesets: [ + { + id: "quick-lions-devour", + summary: "Hey, let's have fun with testing!", + releases: [{ name: "pkg-a", type: "major" }], + }, + ], + releases: [ + { + name: "pkg-a", + type: "major", + oldVersion: "1.0.0", + newVersion: "2.0.0", + changesets: ["quick-lions-devour"], + }, + { + name: "pkg-b", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: [], + }, + ], + preState: undefined, +}; + +async function setup({ + dependencyRange = "catalog:", + plan = releasePlan, + ...overrides +}: { + dependencyRange?: string; + plan?: ReleasePlan; + config?: Partial; +} = {}) { + const cwd = await testdir({ + "package.json": JSON.stringify({ private: true, name: "root" }), + "pnpm-workspace.yaml": workspaceYaml, + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + version: "1.0.0", + }), + "packages/pkg-b/package.json": JSON.stringify( + { + name: "pkg-b", + version: "1.0.0", + dependencies: { "pkg-a": dependencyRange, react: "catalog:" }, + }, + null, + 2, + ), + }); + + const changedFiles = await applyReleasePlan( + plan, + await withCatalogs(await getPackages(cwd)), + { ...defaultConfig, changelog: false, ...overrides.config }, + ); + + return { + cwd, + changedFiles, + catalogFile: () => + fs.readFile(path.join(cwd, "pnpm-workspace.yaml"), "utf8"), + pkgB: async () => + JSON.parse( + await fs.readFile( + path.join(cwd, "packages/pkg-b/package.json"), + "utf8", + ), + ), + }; +} + +describe("catalog entries", () => { + it("writes the new range to the catalog and leaves the rest of the file alone", async () => { + const { catalogFile } = await setup(); + + await expect(catalogFile()).resolves.toMatchInlineSnapshot(` + "packages: + - packages/* + + catalog: + # the one everything shares + pkg-a: ^2.0.0 + react: ^19.0.0 + + catalogs: + internal: + pkg-a: 1.0.0 + " + `); + }); + + it("keeps the dependents pointing at the catalog", async () => { + const { pkgB } = await setup(); + + await expect(pkgB()).resolves.toMatchObject({ + dependencies: { "pkg-a": "catalog:", react: "catalog:" }, + }); + }); + + it("reports the catalog file as touched", async () => { + const { cwd, changedFiles } = await setup(); + + expect(changedFiles).toContain(path.join(cwd, "pnpm-workspace.yaml")); + }); + + it("only updates the catalog a dependent actually references", async () => { + const { catalogFile } = await setup({ + dependencyRange: "catalog:internal", + }); + + const contents = await catalogFile(); + expect(contents).toContain("pkg-a: ^1.0.0"); + expect(contents).toContain("pkg-a: 2.0.0"); + }); + + it("leaves the catalog alone with bumpVersionsWithWorkspaceProtocolOnly", async () => { + const { catalogFile, changedFiles, cwd } = await setup({ + config: { bumpVersionsWithWorkspaceProtocolOnly: true }, + }); + + await expect(catalogFile()).resolves.toBe(workspaceYaml); + expect(changedFiles).not.toContain(path.join(cwd, "pnpm-workspace.yaml")); + }); + + it("refreshes the entry for a release that stays inside the range", async () => { + const { catalogFile } = await setup({ + plan: { + ...releasePlan, + releases: [ + { + name: "pkg-a", + type: "patch", + oldVersion: "1.0.0", + newVersion: "1.0.1", + changesets: ["quick-lions-devour"], + }, + ], + }, + }); + + await expect(catalogFile()).resolves.toContain("pkg-a: ^1.0.1"); + }); +}); diff --git a/packages/apply-release-plan/src/get-changelog-entry.ts b/packages/apply-release-plan/src/get-changelog-entry.ts index 5e9a35419..71a70715c 100644 --- a/packages/apply-release-plan/src/get-changelog-entry.ts +++ b/packages/apply-release-plan/src/get-changelog-entry.ts @@ -1,4 +1,6 @@ +import { resolveCatalogRange } from "@changesets/catalogs"; import type { + Catalogs, ChangelogFunctions, ModCompWithPackage, NewChangesetWithCommit, @@ -23,9 +25,11 @@ export async function getChangelogEntry( { updateInternalDependencies, onlyUpdatePeerDependentsWhenOutOfRange, + catalogs, }: { updateInternalDependencies: "patch" | "minor"; onlyUpdatePeerDependentsWhenOutOfRange: boolean; + catalogs: Catalogs | undefined; }, ) { if (release.type === "none") return null; @@ -55,10 +59,21 @@ export async function getChangelogEntry( const peerDependencyVersionRange = release.packageJson.peerDependencies?.[rel.name]; - const versionRange = dependencyVersionRange || peerDependencyVersionRange; - const usesWorkspaceRange = versionRange?.startsWith("workspace:"); + const declaredRange = dependencyVersionRange || peerDependencyVersionRange; + + if (!declaredRange) { + return false; + } + + const versionRange = resolveCatalogRange(declaredRange, rel.name, catalogs); + + if (versionRange == null) { + return false; + } + + const usesWorkspaceRange = versionRange.startsWith("workspace:"); + return ( - versionRange && (usesWorkspaceRange || validRange(versionRange) != null) && shouldUpdateDependencyBasedOnConfig( cwd, diff --git a/packages/apply-release-plan/src/index.ts b/packages/apply-release-plan/src/index.ts index 5b3b6aa43..db5903d63 100644 --- a/packages/apply-release-plan/src/index.ts +++ b/packages/apply-release-plan/src/index.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { updateCatalogEntries } from "@changesets/catalogs"; import { defaultConfig } from "@changesets/config"; import { defaultDetectOrder, @@ -10,6 +11,7 @@ import { import * as git from "@changesets/git"; import { shouldSkipPackage } from "@changesets/should-skip-package"; import type { + Catalogs, ChangelogFunctions, ComprehensiveRelease, Packages, @@ -22,6 +24,7 @@ import { resolve } from "import-meta-resolve"; import { editJson, type EditJsonOperation } from "./edit-json.ts"; import { getChangelogEntry } from "./get-changelog-entry.ts"; import { + getCatalogEntryUpdates, getDependencyVersionEdits, type DependencyUpdateOptions, } from "./version-package.ts"; @@ -73,6 +76,29 @@ async function updatePackageJson(dir: string, edits: EditJsonOperation[]) { return pkgJsonPath; } +async function updateCatalog( + packages: Packages, + updates: ReturnType, +) { + const source = packages.catalogs?.source; + + if (updates.length === 0 || !source) { + return; + } + + const catalogPath = path.resolve(packages.rootDir, source.filePath); + const raw = await fs.readFile(catalogPath, "utf8"); + const updated = updateCatalogEntries(raw, source.format, updates); + + if (updated === raw) { + return; + } + + await fs.writeFile(catalogPath, updated); + + return catalogPath; +} + export async function applyReleasePlan( releasePlan: ReleasePlan, packages: Packages, @@ -109,6 +135,7 @@ export async function applyReleasePlan( config, cwd, contextDir, + packages.catalogs, ); if (releasePlan.preState?.mode === "exit" && snapshot == null) { @@ -177,6 +204,14 @@ export async function applyReleasePlan( } } + const catalogPath = await updateCatalog( + packages, + getCatalogEntryUpdates(packages, versionsToUpdate, dependencyUpdateOptions), + ); + if (catalogPath) { + touchedFiles.push(catalogPath); + } + if (filesToFormat.length > 0) { const formatter = await getFormatter(config.format, cwd); await formatter(filesToFormat); @@ -241,6 +276,7 @@ async function getNewChangelogEntry( config: Config, cwd: string, contextDir: string, + catalogs: Catalogs | undefined, ) { if (!config.changelog) { return Promise.resolve( @@ -307,6 +343,7 @@ async function getNewChangelogEntry( onlyUpdatePeerDependentsWhenOutOfRange: config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH .onlyUpdatePeerDependentsWhenOutOfRange, + catalogs, }, ); diff --git a/packages/apply-release-plan/src/version-package.ts b/packages/apply-release-plan/src/version-package.ts index f405fd048..54ff44add 100644 --- a/packages/apply-release-plan/src/version-package.ts +++ b/packages/apply-release-plan/src/version-package.ts @@ -1,4 +1,11 @@ -import type { ComprehensiveRelease, PackageJSON } from "@changesets/types"; +import { parseCatalogProtocol } from "@changesets/catalogs"; +import type { + CatalogEntryUpdate, + ComprehensiveRelease, + DependencyType, + PackageJSON, + Packages, +} from "@changesets/types"; import Range from "semver/classes/range.js"; import semverPrerelease from "semver/functions/prerelease.js"; import validRange from "semver/ranges/valid.js"; @@ -49,6 +56,8 @@ export function getDependencyVersionEdits( !depCurrentVersion || depCurrentVersion.startsWith("file:") || depCurrentVersion.startsWith("link:") || + // catalog ranges live in the catalog, they get updated there instead + parseCatalogProtocol(depCurrentVersion) != null || !shouldUpdateDependencyBasedOnConfig( cwd, release, @@ -122,3 +131,120 @@ function getVersionRangeType( if (versionRange.charAt(0) === ">") return ">"; return ""; } + +export function getCatalogEntryUpdates( + packages: Packages, + versionsToUpdate: VersionToUpdate[], + { + cwd, + updateInternalDependencies, + onlyUpdatePeerDependentsWhenOutOfRange, + bumpVersionsWithWorkspaceProtocolOnly, + snapshot, + }: DependencyUpdateOptions, +): CatalogEntryUpdate[] { + const catalogs = packages.catalogs; + // `catalog:` is not the workspace protocol, so it's out of scope for this option + if (!catalogs?.entries.size || bumpVersionsWithWorkspaceProtocolOnly) { + return []; + } + + const references = getCatalogReferences(packages); + const updates: CatalogEntryUpdate[] = []; + + for (const release of versionsToUpdate) { + if (release.newVersion == null) { + continue; + } + + for (const [catalogName, catalog] of catalogs.entries) { + const currentRange = catalog.get(release.name); + + if (currentRange == null || validRange(currentRange) == null) { + continue; + } + + // Entries no package in the workspace references are left alone + const depTypes = references.get(catalogName)?.get(release.name); + + if (!depTypes?.size) { + continue; + } + + const shouldUpdate = [...depTypes].some((depType) => + shouldUpdateDependencyBasedOnConfig( + cwd, + release, + { depVersionRange: currentRange, depType }, + { + minReleaseType: updateInternalDependencies, + onlyUpdatePeerDependentsWhenOutOfRange, + }, + ), + ); + + if (!shouldUpdate) { + continue; + } + + // See the equivalent check in `getDependencyVersionEdits` for the reasoning + if ( + new Range(currentRange).range === "" && + semverPrerelease(release.newVersion) == null + ) { + continue; + } + + updates.push({ + catalogName, + dependencyName: release.name, + value: snapshot + ? release.newVersion + : `${getVersionRangeType(currentRange)}${release.newVersion}`, + }); + } + } + + return updates; +} + +/** catalog name -> dependency name -> the dependency types it's referenced through */ +function getCatalogReferences(packages: Packages) { + const references = new Map>>(); + + const allPackages = packages.rootPackage + ? [...packages.packages, packages.rootPackage] + : packages.packages; + + for (const pkg of allPackages) { + for (const depType of DEPENDENCY_TYPES) { + for (const [depName, range] of Object.entries( + pkg.packageJson[depType] ?? {}, + )) { + const catalogName = parseCatalogProtocol(range); + + if (catalogName == null) { + continue; + } + + let catalog = references.get(catalogName); + + if (!catalog) { + catalog = new Map(); + references.set(catalogName, catalog); + } + + let depTypes = catalog.get(depName); + + if (!depTypes) { + depTypes = new Set(); + catalog.set(depName, depTypes); + } + + depTypes.add(depType); + } + } + } + + return references; +} diff --git a/packages/assemble-release-plan/package.json b/packages/assemble-release-plan/package.json index 607443189..147580f30 100644 --- a/packages/assemble-release-plan/package.json +++ b/packages/assemble-release-plan/package.json @@ -17,6 +17,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@changesets/catalogs": "workspace:^", "@changesets/errors": "workspace:^", "@changesets/get-dependents-graph": "workspace:^", "@changesets/should-skip-package": "workspace:^", diff --git a/packages/assemble-release-plan/src/catalog.test.ts b/packages/assemble-release-plan/src/catalog.test.ts new file mode 100644 index 000000000..8fe272f6b --- /dev/null +++ b/packages/assemble-release-plan/src/catalog.test.ts @@ -0,0 +1,107 @@ +import { defaultConfig } from "@changesets/config"; +import type { Catalogs } from "@changesets/types"; +import { describe, expect, it } from "vitest"; +import { assembleReleasePlan } from "./index.ts"; +import { FakeFullState } from "./test-utils.ts"; + +const catalogs = ( + entries: Record>, +): Catalogs => ({ + entries: new Map( + Object.entries(entries).map(([name, catalog]) => [ + name, + new Map(Object.entries(catalog)), + ]), + ), + source: undefined, +}); + +describe("dependencies referenced through a catalog", () => { + it("bumps a dependent when the release leaves the catalog range", () => { + const setup = new FakeFullState(); + setup.addPackage("pkg-b", "1.0.0"); + setup.updateDependency("pkg-b", "pkg-a", "catalog:"); + setup.packages.catalogs = catalogs({ default: { "pkg-a": "^1.0.0" } }); + + setup.addChangeset({ + id: "major-bump", + releases: [{ name: "pkg-a", type: "major" }], + }); + + const { releases } = assembleReleasePlan( + setup.changesets, + setup.packages, + defaultConfig, + undefined, + ); + + expect(releases).toEqual([ + expect.objectContaining({ name: "pkg-a", newVersion: "2.0.0" }), + expect.objectContaining({ name: "pkg-b", newVersion: "1.0.1" }), + ]); + }); + + it("does not bump a dependent while the catalog range still matches", () => { + const setup = new FakeFullState(); + setup.addPackage("pkg-b", "1.0.0"); + setup.updateDependency("pkg-b", "pkg-a", "catalog:"); + setup.packages.catalogs = catalogs({ default: { "pkg-a": "^1.0.0" } }); + + const { releases } = assembleReleasePlan( + setup.changesets, + setup.packages, + defaultConfig, + undefined, + ); + + expect(releases).toEqual([ + expect.objectContaining({ name: "pkg-a", newVersion: "1.0.1" }), + ]); + }); + + it("resolves references to a named catalog", () => { + const setup = new FakeFullState(); + setup.addPackage("pkg-b", "1.0.0"); + setup.updateDependency("pkg-b", "pkg-a", "catalog:internal"); + setup.packages.catalogs = catalogs({ + default: { "pkg-a": "^1.0.0" }, + internal: { "pkg-a": "1.0.0" }, + }); + + const { releases } = assembleReleasePlan( + setup.changesets, + setup.packages, + defaultConfig, + undefined, + ); + + // the `internal` catalog pins 1.0.0, so the patch release takes pkg-b with it + expect(releases).toEqual([ + expect.objectContaining({ name: "pkg-a", newVersion: "1.0.1" }), + expect.objectContaining({ name: "pkg-b", newVersion: "1.0.1" }), + ]); + }); + + it("ignores a reference the catalogs don't define", () => { + const setup = new FakeFullState(); + setup.addPackage("pkg-b", "1.0.0"); + setup.updateDependency("pkg-b", "pkg-a", "catalog:"); + setup.packages.catalogs = catalogs({ default: { react: "^19.0.0" } }); + + setup.addChangeset({ + id: "major-bump", + releases: [{ name: "pkg-a", type: "major" }], + }); + + const { releases } = assembleReleasePlan( + setup.changesets, + setup.packages, + defaultConfig, + undefined, + ); + + expect(releases).toEqual([ + expect.objectContaining({ name: "pkg-a", newVersion: "2.0.0" }), + ]); + }); +}); diff --git a/packages/assemble-release-plan/src/determine-dependents.ts b/packages/assemble-release-plan/src/determine-dependents.ts index 206758b63..85100ca27 100644 --- a/packages/assemble-release-plan/src/determine-dependents.ts +++ b/packages/assemble-release-plan/src/determine-dependents.ts @@ -1,6 +1,8 @@ import path from "node:path"; +import { resolveCatalogRange } from "@changesets/catalogs"; import { shouldSkipPackage } from "@changesets/should-skip-package"; import type { + Catalogs, Config, DependencyType, PackageJSON, @@ -29,6 +31,7 @@ export function determineDependents({ releases, packagesByName, rootDir, + catalogs, dependencyGraph, preInfo, config, @@ -36,6 +39,7 @@ export function determineDependents({ releases: Map; packagesByName: Map; rootDir: string; + catalogs: Catalogs | undefined; dependencyGraph: Map; preInfo: PreInfo | undefined; config: Config; @@ -82,6 +86,7 @@ export function determineDependents({ dependentPackage.packageJson, nextRelease, dependencyPackage, + catalogs, ); for (const { depType, versionRange } of dependencyVersionRanges) { @@ -175,6 +180,7 @@ function getDependencyVersionRanges( dependentPkgJSON: PackageJSON, dependencyRelease: InternalRelease, dependencyPackage: Package, + catalogs: Catalogs | undefined, ): { depType: DependencyType; versionRange: string; @@ -193,6 +199,18 @@ function getDependencyVersionRanges( let versionRange = dependentPkgJSON[type]?.[dependencyRelease.name]; if (!versionRange) continue; + const resolvedRange = resolveCatalogRange( + versionRange, + dependencyRelease.name, + catalogs, + ); + + if (resolvedRange == null) { + continue; + } + + versionRange = resolvedRange; + if (versionRange.startsWith("workspace:")) { versionRange = versionRange.replace(/^workspace:/, ""); switch (versionRange) { diff --git a/packages/assemble-release-plan/src/index.ts b/packages/assemble-release-plan/src/index.ts index f3df0d8ff..3a80bf079 100644 --- a/packages/assemble-release-plan/src/index.ts +++ b/packages/assemble-release-plan/src/index.ts @@ -165,6 +165,7 @@ export function assembleReleasePlan( releases, packagesByName, rootDir: packages.rootDir, + catalogs: packages.catalogs, dependencyGraph, preInfo, config, diff --git a/packages/catalogs/README.md b/packages/catalogs/README.md new file mode 100644 index 000000000..ad780e1ef --- /dev/null +++ b/packages/catalogs/README.md @@ -0,0 +1,18 @@ +# @changesets/catalogs + +[![Open on npmx.dev](https://npmx.dev/api/registry/badge/version/@changesets/catalogs?name=true)](https://npmx.dev/package/@changesets/catalogs) +[![View changelog](https://npmx.dev/api/registry/badge/version/@changesets/cli?color=229fe4&value=View+changelog&label=+)](./CHANGELOG.md) + +Reads and updates the dependency catalogs of a workspace - `catalog` / `catalogs` +in `pnpm-workspace.yaml` (pnpm), `.yarnrc.yml` (Yarn) or `package.json` (Bun). + +```ts +import { readCatalogs, resolveCatalogRange } from "@changesets/catalogs"; + +const catalogs = await readCatalogs(cwd); + +// "^19.0.0" +resolveCatalogRange("catalog:", "react", catalogs); +``` + +Mostly published for use in [changesets](https://npmx.dev/@changesets/cli) diff --git a/packages/catalogs/package.json b/packages/catalogs/package.json new file mode 100644 index 000000000..bb76a6d26 --- /dev/null +++ b/packages/catalogs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@changesets/catalogs", + "version": "1.0.0-next.0", + "description": "Reads and updates the dependency catalogs of pnpm, Yarn and Bun workspaces", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/changesets/changesets.git", + "directory": "packages/catalogs" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "dependencies": { + "@changesets/types": "workspace:^", + "jsonc-parser": "^3.3.1", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@changesets/test-utils": "workspace:*" + }, + "engines": { + "node": "^22.11 || ^24 || >=26" + } +} diff --git a/packages/catalogs/src/constants.ts b/packages/catalogs/src/constants.ts new file mode 100644 index 000000000..0e6072a97 --- /dev/null +++ b/packages/catalogs/src/constants.ts @@ -0,0 +1,2 @@ +// The catalog that both `catalog:` and `catalog:default` refer to +export const DEFAULT_CATALOG_NAME = "default"; diff --git a/packages/catalogs/src/index.ts b/packages/catalogs/src/index.ts new file mode 100644 index 000000000..da3310fd1 --- /dev/null +++ b/packages/catalogs/src/index.ts @@ -0,0 +1,9 @@ +export { + getChangedCatalogEntries, + parseCatalogProtocol, + resolveCatalogRange, + type ChangedCatalogEntry, +} from "./protocol.ts"; +export { DEFAULT_CATALOG_NAME } from "./constants.ts"; +export { parseCatalogs, readCatalogs, withCatalogs } from "./read.ts"; +export { updateCatalogEntries } from "./update.ts"; diff --git a/packages/catalogs/src/protocol.test.ts b/packages/catalogs/src/protocol.test.ts new file mode 100644 index 000000000..7d4c5d9bf --- /dev/null +++ b/packages/catalogs/src/protocol.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + getChangedCatalogEntries, + parseCatalogProtocol, + resolveCatalogRange, +} from "./protocol.ts"; + +const catalogs = { + entries: new Map([ + ["default", new Map([["react", "^18.3.1"]])], + ["react17", new Map([["react", "^17.0.2"]])], + ]), + source: undefined, +}; + +describe("parseCatalogProtocol", () => { + it("returns the default catalog for a bare reference", () => { + expect(parseCatalogProtocol("catalog:")).toBe("default"); + }); + + it("returns the default catalog for an explicit default reference", () => { + expect(parseCatalogProtocol("catalog:default")).toBe("default"); + }); + + it("returns the name of a named catalog", () => { + expect(parseCatalogProtocol("catalog:react17")).toBe("react17"); + }); + + it("ignores surrounding whitespace", () => { + expect(parseCatalogProtocol("catalog: react17 ")).toBe("react17"); + }); + + it("returns undefined for ranges that don't use the protocol", () => { + expect(parseCatalogProtocol("^1.0.0")).toBeUndefined(); + expect(parseCatalogProtocol("workspace:^")).toBeUndefined(); + expect(parseCatalogProtocol("npm:react@^18")).toBeUndefined(); + }); +}); + +describe("resolveCatalogRange", () => { + it("returns non-catalog ranges unchanged", () => { + expect(resolveCatalogRange("^1.0.0", "react", catalogs)).toBe("^1.0.0"); + expect(resolveCatalogRange("workspace:^", "react", catalogs)).toBe( + "workspace:^", + ); + }); + + it("resolves a reference to the default catalog", () => { + expect(resolveCatalogRange("catalog:", "react", catalogs)).toBe("^18.3.1"); + }); + + it("resolves a reference to a named catalog", () => { + expect(resolveCatalogRange("catalog:react17", "react", catalogs)).toBe( + "^17.0.2", + ); + }); + + it("returns undefined when the catalog has no entry for the dependency", () => { + expect(resolveCatalogRange("catalog:", "vue", catalogs)).toBeUndefined(); + }); + + it("returns undefined when the catalog doesn't exist", () => { + expect( + resolveCatalogRange("catalog:nope", "react", catalogs), + ).toBeUndefined(); + }); + + it("returns undefined when there are no catalogs at all", () => { + expect(resolveCatalogRange("catalog:", "react", undefined)).toBeUndefined(); + }); +}); + +describe("getChangedCatalogEntries", () => { + it("reports updated entries", () => { + expect( + getChangedCatalogEntries( + new Map([["default", new Map([["react", "^18.0.0"]])]]), + new Map([["default", new Map([["react", "^19.0.0"]])]]), + ), + ).toStrictEqual([{ catalogName: "default", dependencyName: "react" }]); + }); + + it("reports added and removed entries", () => { + expect( + getChangedCatalogEntries( + new Map([["default", new Map([["react", "^18.0.0"]])]]), + new Map([["testing", new Map([["jest", "^30.0.0"]])]]), + ), + ).toStrictEqual([ + { catalogName: "default", dependencyName: "react" }, + { catalogName: "testing", dependencyName: "jest" }, + ]); + }); + + it("ignores entries that stayed the same", () => { + const entries = new Map([ + ["default", new Map([["react", "^18.0.0"]])], + ["testing", new Map([["jest", "^30.0.0"]])], + ]); + expect(getChangedCatalogEntries(entries, entries)).toStrictEqual([]); + }); + + it("treats the same dependency in different catalogs separately", () => { + expect( + getChangedCatalogEntries( + new Map([ + ["default", new Map([["react", "^18.0.0"]])], + ["react17", new Map([["react", "^17.0.2"]])], + ]), + new Map([ + ["default", new Map([["react", "^19.0.0"]])], + ["react17", new Map([["react", "^17.0.2"]])], + ]), + ), + ).toStrictEqual([{ catalogName: "default", dependencyName: "react" }]); + }); +}); diff --git a/packages/catalogs/src/protocol.ts b/packages/catalogs/src/protocol.ts new file mode 100644 index 000000000..61c0ff542 --- /dev/null +++ b/packages/catalogs/src/protocol.ts @@ -0,0 +1,58 @@ +import type { CatalogEntries, Catalogs } from "@changesets/types"; +import { DEFAULT_CATALOG_NAME } from "./constants.ts"; + +const CATALOG_PROTOCOL = "catalog:"; + +export function parseCatalogProtocol(range: string): string | undefined { + if (!range.startsWith(CATALOG_PROTOCOL)) { + return undefined; + } + + return range.slice(CATALOG_PROTOCOL.length).trim() || DEFAULT_CATALOG_NAME; +} + +export function resolveCatalogRange( + range: string, + dependencyName: string, + catalogs: Catalogs | undefined, +): string | undefined { + const catalogName = parseCatalogProtocol(range); + + if (catalogName == null) { + return range; + } + + return catalogs?.entries.get(catalogName)?.get(dependencyName); +} + +export interface ChangedCatalogEntry { + catalogName: string; + dependencyName: string; +} + +export function getChangedCatalogEntries( + before: CatalogEntries, + after: CatalogEntries, +): ChangedCatalogEntry[] { + const changed: ChangedCatalogEntry[] = []; + + for (const catalogName of new Set([...before.keys(), ...after.keys()])) { + const beforeCatalog = before.get(catalogName); + const afterCatalog = after.get(catalogName); + + const dependencyNames = new Set([ + ...(beforeCatalog?.keys() ?? []), + ...(afterCatalog?.keys() ?? []), + ]); + + for (const dependencyName of dependencyNames) { + if ( + beforeCatalog?.get(dependencyName) !== afterCatalog?.get(dependencyName) + ) { + changed.push({ catalogName, dependencyName }); + } + } + } + + return changed; +} diff --git a/packages/catalogs/src/read.test.ts b/packages/catalogs/src/read.test.ts new file mode 100644 index 000000000..8cb9fab95 --- /dev/null +++ b/packages/catalogs/src/read.test.ts @@ -0,0 +1,176 @@ +import { testdir } from "@changesets/test-utils"; +import { describe, expect, it } from "vitest"; +import { parseCatalogs, readCatalogs } from "./read.ts"; + +describe("parseCatalogs", () => { + it("reads the default and named catalogs of a pnpm workspace", () => { + const entries = parseCatalogs( + ` +packages: + - packages/* + +catalog: + react: ^19.0.0 + +catalogs: + react17: + react: ^17.0.2 + react-dom: ^17.0.2 +`, + "pnpm-workspace", + ); + + expect(entries).toStrictEqual( + new Map([ + ["default", new Map([["react", "^19.0.0"]])], + [ + "react17", + new Map([ + ["react", "^17.0.2"], + ["react-dom", "^17.0.2"], + ]), + ], + ]), + ); + }); + + it("merges `catalog` and `catalogs.default` into the default catalog", () => { + expect( + parseCatalogs( + ` +catalog: + react: ^19.0.0 + +catalogs: + default: + lodash: ^4.17.21 +`, + "pnpm-workspace", + ), + ).toStrictEqual( + new Map([ + [ + "default", + new Map([ + ["react", "^19.0.0"], + ["lodash", "^4.17.21"], + ]), + ], + ]), + ); + }); + + it("reads the catalogs of a Yarn workspace", () => { + expect( + parseCatalogs( + ` +nodeLinker: node-modules + +catalog: + react: ^18.3.1 +`, + "yarnrc", + ), + ).toStrictEqual(new Map([["default", new Map([["react", "^18.3.1"]])]])); + }); + + it("prefers the catalogs under `workspaces` in a package.json", () => { + expect( + parseCatalogs( + JSON.stringify({ + name: "root", + workspaces: { + packages: ["packages/*"], + catalog: { react: "^19.0.0" }, + }, + catalog: { react: "^18.0.0" }, + }), + "package-json", + ), + ).toStrictEqual(new Map([["default", new Map([["react", "^19.0.0"]])]])); + }); + + it("falls back to the catalogs at the root of a package.json", () => { + expect( + parseCatalogs( + JSON.stringify({ + name: "root", + workspaces: ["packages/*"], + catalogs: { testing: { jest: "^30.0.0" } }, + }), + "package-json", + ), + ).toStrictEqual(new Map([["testing", new Map([["jest", "^30.0.0"]])]])); + }); + + it("ignores entries that aren't version ranges", () => { + expect( + parseCatalogs( + ` +catalog: + react: ^19.0.0 + broken: + - nope +`, + "pnpm-workspace", + ), + ).toStrictEqual(new Map([["default", new Map([["react", "^19.0.0"]])]])); + }); + + it("returns nothing for a malformed file", () => { + expect(parseCatalogs("{ not json", "package-json").size).toBe(0); + }); +}); + +describe("readCatalogs", () => { + it("reads the catalogs from pnpm-workspace.yaml", async () => { + const cwd = await testdir({ + "pnpm-workspace.yaml": "catalog:\n react: ^19.0.0\n", + "package.json": JSON.stringify({ name: "root" }), + }); + + await expect(readCatalogs(cwd)).resolves.toStrictEqual({ + entries: new Map([["default", new Map([["react", "^19.0.0"]])]]), + source: { format: "pnpm-workspace", filePath: "pnpm-workspace.yaml" }, + }); + }); + + it("reads the catalogs from package.json when there is no other source", async () => { + const cwd = await testdir({ + "package.json": JSON.stringify({ + name: "root", + workspaces: { packages: ["packages/*"], catalog: { react: "^19.0.0" } }, + }), + }); + + await expect(readCatalogs(cwd)).resolves.toStrictEqual({ + entries: new Map([["default", new Map([["react", "^19.0.0"]])]]), + source: { format: "package-json", filePath: "package.json" }, + }); + }); + + it("skips files that declare no catalogs", async () => { + const cwd = await testdir({ + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + "package.json": JSON.stringify({ + name: "root", + catalog: { react: "^19.0.0" }, + }), + }); + + await expect(readCatalogs(cwd)).resolves.toMatchObject({ + source: { format: "package-json", filePath: "package.json" }, + }); + }); + + it("returns no catalogs for a workspace that doesn't use them", async () => { + const cwd = await testdir({ + "package.json": JSON.stringify({ name: "root" }), + }); + + await expect(readCatalogs(cwd)).resolves.toStrictEqual({ + entries: new Map(), + source: undefined, + }); + }); +}); diff --git a/packages/catalogs/src/read.ts b/packages/catalogs/src/read.ts new file mode 100644 index 000000000..c40fc3ed4 --- /dev/null +++ b/packages/catalogs/src/read.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + CatalogEntries, + CatalogFormat, + Catalogs, + CatalogSource, +} from "@changesets/types"; +import { parse as parseYaml } from "yaml"; +import { DEFAULT_CATALOG_NAME } from "./constants.ts"; + +const CATALOG_SOURCES: readonly CatalogSource[] = [ + { format: "pnpm-workspace", filePath: "pnpm-workspace.yaml" }, + { format: "pnpm-workspace", filePath: "pnpm-workspace.yml" }, + { format: "yarnrc", filePath: ".yarnrc.yml" }, + { format: "package-json", filePath: "package.json" }, +]; + +export function parseCatalogs( + contents: string, + format: CatalogFormat, +): CatalogEntries { + let parsed: unknown; + + try { + parsed = + format === "package-json" ? JSON.parse(contents) : parseYaml(contents); + } catch { + // A malformed manifest is the package manager's responsibility to report, not ours + return new Map(); + } + + if (format !== "package-json") { + return collectCatalogs(parsed); + } + + // Bun allows catalogs both under `workspaces` and at the root, preferring `workspaces` + const fromWorkspaces = collectCatalogs( + isRecord(parsed) ? parsed.workspaces : undefined, + ); + + return fromWorkspaces.size > 0 ? fromWorkspaces : collectCatalogs(parsed); +} + +export async function readCatalogs(rootDir: string): Promise { + for (const source of CATALOG_SOURCES) { + const contents = await readFileIfExists( + path.join(rootDir, source.filePath), + ); + + if (contents == null) { + continue; + } + + const entries = parseCatalogs(contents, source.format); + + if (entries.size > 0) { + return { entries, source }; + } + } + + return { entries: new Map(), source: undefined }; +} + +export async function withCatalogs( + packages: T, +): Promise { + return { ...packages, catalogs: await readCatalogs(packages.rootDir) }; +} + +async function readFileIfExists(filePath: string) { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return undefined; + } +} + +function collectCatalogs(value: unknown): Map> { + if (!isRecord(value)) { + return new Map(); + } + + const declared: [string, unknown][] = [ + [DEFAULT_CATALOG_NAME, value.catalog], + ...(isRecord(value.catalogs) ? Object.entries(value.catalogs) : []), + ]; + + const catalogs = new Map>(); + + for (const [name, declaration] of declared) { + const ranges = toRanges(declaration); + + if (ranges.size === 0) { + continue; + } + + // `catalog` and `catalogs.default` describe the same catalog, and `catalog` wins + catalogs.set(name, new Map([...ranges, ...(catalogs.get(name) ?? [])])); + } + + return catalogs; +} + +function toRanges(value: unknown): Map { + if (!isRecord(value)) { + return new Map(); + } + + return new Map( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} diff --git a/packages/catalogs/src/update.test.ts b/packages/catalogs/src/update.test.ts new file mode 100644 index 000000000..695ab10d3 --- /dev/null +++ b/packages/catalogs/src/update.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { updateCatalogEntries } from "./update.ts"; + +describe("updateCatalogEntries", () => { + it("leaves comments and formatting of a pnpm workspace intact", () => { + const contents = `packages: + - packages/* + +# The versions everything shares +catalog: + # our own stuff + "@scope/a": ^1.0.0 + react: ^19.0.0 # do not touch + +catalogs: + react17: + react: '^17.0.2' +`; + + expect( + updateCatalogEntries(contents, "pnpm-workspace", [ + { catalogName: "default", dependencyName: "@scope/a", value: "^2.0.0" }, + { catalogName: "react17", dependencyName: "react", value: "^17.1.0" }, + ]), + ).toMatchInlineSnapshot(` + "packages: + - packages/* + + # The versions everything shares + catalog: + # our own stuff + "@scope/a": ^2.0.0 + react: ^19.0.0 # do not touch + + catalogs: + react17: + react: '^17.1.0' + " + `); + }); + + it("updates an entry declared under `catalogs.default`", () => { + expect( + updateCatalogEntries( + "catalogs:\n default:\n a: ^1.0.0\n", + "pnpm-workspace", + [{ catalogName: "default", dependencyName: "a", value: "^2.0.0" }], + ), + ).toBe("catalogs:\n default:\n a: ^2.0.0\n"); + }); + + it("quotes ranges that would change meaning as a plain YAML scalar", () => { + expect( + updateCatalogEntries("catalog:\n a: 1.0.0\n", "pnpm-workspace", [ + { catalogName: "default", dependencyName: "a", value: ">=2.0.0" }, + ]), + ).toBe('catalog:\n a: ">=2.0.0"\n'); + }); + + it("ignores entries that don't exist", () => { + const contents = "catalog:\n a: ^1.0.0\n"; + expect( + updateCatalogEntries(contents, "pnpm-workspace", [ + { catalogName: "default", dependencyName: "b", value: "^2.0.0" }, + { catalogName: "nope", dependencyName: "a", value: "^2.0.0" }, + ]), + ).toBe(contents); + }); + + it("leaves the formatting of a package.json intact", () => { + const contents = `{ + "name": "root", + "workspaces": { + "packages": ["packages/*"], + "catalog": { "@scope/a": "^1.0.0" }, + "catalogs": { + "testing": { "jest": "^30.0.0" } + } + } +} +`; + + expect( + updateCatalogEntries(contents, "package-json", [ + { catalogName: "default", dependencyName: "@scope/a", value: "^2.0.0" }, + { catalogName: "testing", dependencyName: "jest", value: "^31.0.0" }, + ]), + ).toMatchInlineSnapshot(` + "{ + "name": "root", + "workspaces": { + "packages": ["packages/*"], + "catalog": { "@scope/a": "^2.0.0" }, + "catalogs": { + "testing": { "jest": "^31.0.0" } + } + } + } + " + `); + }); + + it("updates catalogs declared at the root of a package.json", () => { + expect( + updateCatalogEntries( + '{ "workspaces": ["packages/*"], "catalog": { "a": "^1.0.0" } }', + "package-json", + [{ catalogName: "default", dependencyName: "a", value: "^2.0.0" }], + ), + ).toBe('{ "workspaces": ["packages/*"], "catalog": { "a": "^2.0.0" } }'); + }); + + it("returns the contents as is when there is nothing to update", () => { + expect( + updateCatalogEntries("catalog:\n a: ^1.0.0\n", "pnpm-workspace", []), + ).toBe("catalog:\n a: ^1.0.0\n"); + }); +}); diff --git a/packages/catalogs/src/update.ts b/packages/catalogs/src/update.ts new file mode 100644 index 000000000..30e6f9b3f --- /dev/null +++ b/packages/catalogs/src/update.ts @@ -0,0 +1,137 @@ +import type { CatalogEntryUpdate, CatalogFormat } from "@changesets/types"; +import { + applyEdits, + findNodeAtLocation, + parseTree, + type Node, +} from "jsonc-parser"; +import { isScalar, parseDocument } from "yaml"; +import { DEFAULT_CATALOG_NAME } from "./constants.ts"; + +interface TextEdit { + start: number; + end: number; + text: string; +} + +export function updateCatalogEntries( + contents: string, + format: CatalogFormat, + updates: readonly CatalogEntryUpdate[], +): string { + if (updates.length === 0) { + return contents; + } + + return format === "package-json" + ? updateJsonCatalogEntries(contents, updates) + : updateYamlCatalogEntries(contents, format, updates); +} + +function getEntryPaths( + format: CatalogFormat, + { catalogName, dependencyName }: CatalogEntryUpdate, +): string[][] { + const paths = + catalogName === DEFAULT_CATALOG_NAME + ? [ + ["catalog", dependencyName], + ["catalogs", DEFAULT_CATALOG_NAME, dependencyName], + ] + : [["catalogs", catalogName, dependencyName]]; + + // Bun allows catalogs both under `workspaces` and at the root of `package.json` + return format === "package-json" + ? [...paths.map((entryPath) => ["workspaces", ...entryPath]), ...paths] + : paths; +} + +function updateYamlCatalogEntries( + contents: string, + format: CatalogFormat, + updates: readonly CatalogEntryUpdate[], +): string { + const doc = parseDocument(contents); + const edits: TextEdit[] = []; + + for (const update of updates) { + for (const entryPath of getEntryPaths(format, update)) { + const node: unknown = doc.getIn(entryPath, true); + + if (!isScalar(node) || node.range == null) { + continue; + } + + const [start, end] = node.range; + edits.push({ + start, + end, + text: formatYamlScalar(update.value, contents.slice(start, end)), + }); + + break; + } + } + + let updated = contents; + + for (const edit of edits.toSorted((a, b) => b.start - a.start)) { + updated = + updated.slice(0, edit.start) + edit.text + updated.slice(edit.end); + } + + return updated; +} + +function updateJsonCatalogEntries( + contents: string, + updates: readonly CatalogEntryUpdate[], +): string { + const root = parseTree(contents); + + if (!root) { + return contents; + } + + const edits = []; + + for (const update of updates) { + let node: Node | undefined; + + for (const entryPath of getEntryPaths("package-json", update)) { + node = findNodeAtLocation(root, entryPath); + + if (node) { + break; + } + } + + if (!node) { + continue; + } + + edits.push({ + offset: node.offset, + length: node.length, + content: JSON.stringify(update.value), + }); + } + + return applyEdits(contents, edits); +} + +// Characters that give a plain YAML scalar a meaning other than "this text" +const YAML_INDICATOR_START = /^[-?:,[\]{}#&*!|>'"%@`]/; + +function formatYamlScalar(value: string, originalSource: string): string { + switch (originalSource[0]) { + case "'": + return `'${value.replaceAll("'", "''")}'`; + case '"': + return JSON.stringify(value); + default: + return YAML_INDICATOR_START.test(value) || /:\s|\s#/.test(value) + ? JSON.stringify(value) + : value; + } +} diff --git a/packages/catalogs/tsdown.config.ts b/packages/catalogs/tsdown.config.ts new file mode 100644 index 000000000..8eddd7f1b --- /dev/null +++ b/packages/catalogs/tsdown.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "tsdown/config"; +import { baseConfig } from "../../tsdown.config.ts"; + +export default defineConfig(baseConfig); diff --git a/packages/cli/package.json b/packages/cli/package.json index fc6f3d9e6..2a98f9bd7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "dependencies": { "@changesets/apply-release-plan": "workspace:^", "@changesets/assemble-release-plan": "workspace:^", + "@changesets/catalogs": "workspace:^", "@changesets/changelog-git": "workspace:^", "@changesets/config": "workspace:^", "@changesets/errors": "workspace:^", diff --git a/packages/cli/src/commands/add/index.ts b/packages/cli/src/commands/add/index.ts index 9b1e83255..d15ba21a2 100644 --- a/packages/cli/src/commands/add/index.ts +++ b/packages/cli/src/commands/add/index.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import * as git from "@changesets/git"; @@ -30,7 +31,7 @@ export interface AddOptions { export async function add(options?: AddOptions): Promise { const cwd = options?.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); if (packages.packages.length === 0) { log.error( diff --git a/packages/cli/src/commands/git-tag/index.ts b/packages/cli/src/commands/git-tag/index.ts index 2bac120b5..ed1b01e4f 100644 --- a/packages/cli/src/commands/git-tag/index.ts +++ b/packages/cli/src/commands/git-tag/index.ts @@ -1,3 +1,4 @@ +import { withCatalogs } from "@changesets/catalogs"; import { shouldSkipPackage } from "@changesets/should-skip-package"; import { spinner } from "@clack/prompts"; import { getPackages } from "@manypkg/get-packages"; @@ -14,7 +15,7 @@ export interface GitTagOptions { export async function gitTag(options?: GitTagOptions) { await using reporter = await createOutputReport(options?.output); const cwd = options?.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); const config = await readConfig(packages); diff --git a/packages/cli/src/commands/pack/index.ts b/packages/cli/src/commands/pack/index.ts index 418640e79..3f3e580b0 100644 --- a/packages/cli/src/commands/pack/index.ts +++ b/packages/cli/src/commands/pack/index.ts @@ -3,6 +3,7 @@ import { createReadStream } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { pipeline } from "node:stream/promises"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import { log } from "@clack/prompts"; @@ -45,7 +46,7 @@ async function getIntegrity(filePath: string) { export async function pack(options: PackOptions) { const cwd = options.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); const config = await readConfig(packages); diff --git a/packages/cli/src/commands/publish-plan/getPublishPlan.ts b/packages/cli/src/commands/publish-plan/getPublishPlan.ts index fa08fe4b4..6f0cc8339 100644 --- a/packages/cli/src/commands/publish-plan/getPublishPlan.ts +++ b/packages/cli/src/commands/publish-plan/getPublishPlan.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import { getDependentsGraph } from "@changesets/get-dependents-graph"; @@ -277,7 +278,7 @@ export async function getPublishPlan( config: Config, options?: { tag?: string }, ): Promise { - const packages = await getPackages(rootDir); + const packages = await withCatalogs(await getPackages(rootDir)); const preState = await readPreState(rootDir); const releases = await getUnpublishedPackages( diff --git a/packages/cli/src/commands/publish-plan/index.ts b/packages/cli/src/commands/publish-plan/index.ts index 596b053fe..0d2f3fe6e 100644 --- a/packages/cli/src/commands/publish-plan/index.ts +++ b/packages/cli/src/commands/publish-plan/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { withCatalogs } from "@changesets/catalogs"; import { log } from "@clack/prompts"; import { getPackages } from "@manypkg/get-packages"; import { readConfig } from "../../utils/read-config.ts"; @@ -21,7 +22,7 @@ export async function publishPlan( ): Promise { const cwd = options?.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); const config = await readConfig(packages); const plan = await getPublishPlan(packages.rootDir, config, { diff --git a/packages/cli/src/commands/publish/index.ts b/packages/cli/src/commands/publish/index.ts index 9e994f3ec..126f447db 100644 --- a/packages/cli/src/commands/publish/index.ts +++ b/packages/cli/src/commands/publish/index.ts @@ -1,4 +1,5 @@ import path, { resolve } from "node:path"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import { readPreState } from "@changesets/pre"; @@ -114,7 +115,7 @@ export async function publish(options?: PublishOptions) { ? path.resolve(cwd, options.fromPackDir) : undefined; - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); const packagesByName = new Map( packages.packages.map((pkg) => [pkg.packageJson.name, pkg]), ); diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts index 7a8b3c4c0..b17254df4 100644 --- a/packages/cli/src/commands/status/index.ts +++ b/packages/cli/src/commands/status/index.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { assembleReleasePlan } from "@changesets/assemble-release-plan"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import { readPreState } from "@changesets/pre"; @@ -22,7 +23,7 @@ export interface StatusOptions { export async function status(options?: StatusOptions) { const cwd = options?.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); const config = await readConfig(packages); diff --git a/packages/cli/src/commands/version/index.ts b/packages/cli/src/commands/version/index.ts index 9b5b906f1..3f819931c 100644 --- a/packages/cli/src/commands/version/index.ts +++ b/packages/cli/src/commands/version/index.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { applyReleasePlan } from "@changesets/apply-release-plan"; import { assembleReleasePlan } from "@changesets/assemble-release-plan"; +import { withCatalogs } from "@changesets/catalogs"; import c from "@changesets/color"; import { ExitError } from "@changesets/errors"; import { getDependentsGraph } from "@changesets/get-dependents-graph"; @@ -27,7 +28,7 @@ export interface VersionOptions { export async function version(options: VersionOptions) { const cwd = options.cwd ?? process.cwd(); - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); await ensureChangesetFolder(packages.rootDir); const config = await readConfig(packages); diff --git a/packages/cli/src/utils/versionablePackages.ts b/packages/cli/src/utils/versionablePackages.ts index 8594643f6..06caf74bc 100644 --- a/packages/cli/src/utils/versionablePackages.ts +++ b/packages/cli/src/utils/versionablePackages.ts @@ -15,6 +15,7 @@ export async function getVersionableChangedPackages( const changedPackages = await getChangedPackagesSinceRef({ ref: ref ?? config.baseBranch, changedFilePatterns: config.changedFilePatterns, + detectCatalogChanges: config.detectCatalogChanges, cwd, }); return changedPackages.filter( diff --git a/packages/config/package.json b/packages/config/package.json index e934fb9d7..014c07013 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -22,6 +22,7 @@ "generate-json-schema": "node scripts/generate-json-schema.ts" }, "dependencies": { + "@changesets/catalogs": "workspace:^", "@changesets/get-dependents-graph": "workspace:^", "@changesets/should-skip-package": "workspace:^", "@changesets/types": "workspace:^", diff --git a/packages/config/schema.json b/packages/config/schema.json index 01e225477..bad06e11d 100644 --- a/packages/config/schema.json +++ b/packages/config/schema.json @@ -133,6 +133,11 @@ "description": "Determines whether Changesets should only bump dependency ranges that use workspace protocol of packages that are part of the workspace.", "default": false }, + "detectCatalogChanges": { + "type": "boolean", + "description": "Determines whether updating the version range of a catalog entry counts as a change to every package that references it through the `catalog:` protocol.", + "default": true + }, "snapshot": { "type": "object", "properties": { diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 1832466c8..16170a5b4 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -108,6 +108,11 @@ export const WrittenConfigSchema = v.object({ "Determines whether Changesets should only bump dependency ranges that use workspace protocol of packages that are part of the workspace.", false, ), + detectCatalogChanges: rootKey( + v.boolean(), + "Determines whether updating the version range of a catalog entry counts as a change to every package that references it through the `catalog:` protocol.", + true, + ), snapshot: rootKey( v.object({ useCalculatedVersion: v.optional( diff --git a/packages/config/src/parse.test.ts b/packages/config/src/parse.test.ts index 4e91b729e..b2441c8e3 100644 --- a/packages/config/src/parse.test.ts +++ b/packages/config/src/parse.test.ts @@ -43,6 +43,7 @@ describe("readConfig", () => { "skipCI": "version", }, ], + "detectCatalogChanges": true, "fixed": [], "format": "auto", "ignore": [], @@ -124,6 +125,7 @@ describe("defaultConfig", () => { null, ], "commit": false, + "detectCatalogChanges": true, "fixed": [], "format": "auto", "ignore": [], diff --git a/packages/config/src/parse.ts b/packages/config/src/parse.ts index 19b70a0e2..78cf23666 100644 --- a/packages/config/src/parse.ts +++ b/packages/config/src/parse.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { withCatalogs } from "@changesets/catalogs"; import type { Config, Packages } from "@changesets/types"; import { getPackages } from "@manypkg/get-packages"; import { type BaseIssue, getDotPath, safeParse } from "valibot"; @@ -61,7 +62,7 @@ export async function readConfig( packages?: Packages, ): Promise { cwd ??= process.cwd(); - packages ??= await getPackages(cwd); + packages ??= await withCatalogs(await getPackages(cwd)); // read const json = JSON.parse( diff --git a/packages/get-dependents-graph/package.json b/packages/get-dependents-graph/package.json index ed40e4d3e..b70d8ab9c 100644 --- a/packages/get-dependents-graph/package.json +++ b/packages/get-dependents-graph/package.json @@ -17,6 +17,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@changesets/catalogs": "workspace:^", "@changesets/types": "workspace:^", "semver": "^7.8.1" }, diff --git a/packages/get-dependents-graph/src/get-dependency-graph.test.ts b/packages/get-dependents-graph/src/get-dependency-graph.test.ts index c6d420741..78acc06f9 100644 --- a/packages/get-dependents-graph/src/get-dependency-graph.test.ts +++ b/packages/get-dependents-graph/src/get-dependency-graph.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { stripVTControlCharacters } from "node:util"; import { temporarilySilenceLogs } from "@changesets/test-utils"; -import type { Package } from "@changesets/types"; +import type { Package, Packages } from "@changesets/types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDependencyGraph } from "./get-dependency-graph.ts"; @@ -301,4 +301,112 @@ describe("getting the dependency graph", function () { ); }), ); + + describe("catalogs", () => { + const makePackages = ( + barRange: string, + catalogs?: Packages["catalogs"], + ) => { + const rootPackage: Package = { + dir: path.resolve(), + packageJson: { name: "root", version: "1.0.0" }, + }; + return { + rootPackage, + packages: { + tool: { type: "pnpm" }, + rootDir: rootPackage.dir, + rootPackage, + packages: [ + { + dir: "packages/foo", + packageJson: { + name: "foo", + version: "1.0.0", + dependencies: { bar: barRange }, + }, + }, + { + dir: "packages/bar", + packageJson: { name: "bar", version: "1.0.0" }, + }, + ], + catalogs, + } satisfies Packages, + }; + }; + + it("should resolve a dependency referenced through the default catalog", () => { + const { packages, rootPackage } = makePackages("catalog:", { + entries: new Map([["default", new Map([["bar", "^1.0.0"]])]]), + source: undefined, + }); + const { graph, valid } = getDependencyGraph(packages, rootPackage); + + expect(graph.get("foo")!.dependencies).toStrictEqual(["bar"]); + expect(valid).toBe(true); + expect((console.error as any).mock.calls).toMatchInlineSnapshot(`[]`); + }); + + it("should resolve a dependency referenced through a named catalog", () => { + const { packages, rootPackage } = makePackages("catalog:internal", { + entries: new Map([["internal", new Map([["bar", "^1.0.0"]])]]), + source: undefined, + }); + const { graph, valid } = getDependencyGraph(packages, rootPackage); + + expect(graph.get("foo")!.dependencies).toStrictEqual(["bar"]); + expect(valid).toBe(true); + }); + + it( + "should error when the catalog has no entry for the dependency", + temporarilySilenceLogs(() => { + const { packages, rootPackage } = makePackages("catalog:", { + entries: new Map([["default", new Map([["react", "^19.0.0"]])]]), + source: undefined, + }); + const { graph, valid } = getDependencyGraph(packages, rootPackage); + + expect(graph.get("foo")!.dependencies).toStrictEqual([]); + expect(valid).toBe(false); + expect( + stripVTControlCharacters((console.error as any).mock.calls[0][0]), + ).toMatchInlineSnapshot( + `"Package foo depends on bar through catalog:, but the default catalog has no entry for it"`, + ); + }), + ); + + it( + "should error when the referenced catalog doesn't exist", + temporarilySilenceLogs(() => { + const { packages, rootPackage } = makePackages("catalog:internal", { + entries: new Map([["default", new Map([["bar", "^1.0.0"]])]]), + source: undefined, + }); + const { valid } = getDependencyGraph(packages, rootPackage); + + expect(valid).toBe(false); + expect( + stripVTControlCharacters((console.error as any).mock.calls[0][0]), + ).toMatchInlineSnapshot( + `"Package foo depends on bar through catalog:internal, but the "internal" catalog has no entry for it"`, + ); + }), + ); + + it("should skip catalog dependencies with bumpVersionsWithWorkspaceProtocolOnly", () => { + const { packages, rootPackage } = makePackages("catalog:", { + entries: new Map([["default", new Map([["bar", "^1.0.0"]])]]), + source: undefined, + }); + const { graph, valid } = getDependencyGraph(packages, rootPackage, { + bumpVersionsWithWorkspaceProtocolOnly: true, + }); + + expect(graph.get("foo")!.dependencies).toStrictEqual([]); + expect(valid).toBe(true); + }); + }); }); diff --git a/packages/get-dependents-graph/src/get-dependency-graph.ts b/packages/get-dependents-graph/src/get-dependency-graph.ts index 7bd24c402..de9bc8134 100644 --- a/packages/get-dependents-graph/src/get-dependency-graph.ts +++ b/packages/get-dependents-graph/src/get-dependency-graph.ts @@ -1,4 +1,9 @@ import path from "node:path"; +import { + DEFAULT_CATALOG_NAME, + parseCatalogProtocol, + resolveCatalogRange, +} from "@changesets/catalogs"; import c from "@changesets/color"; import type { Package, Packages, PackageJSON } from "@changesets/types"; import Range from "semver/classes/range.js"; @@ -104,6 +109,31 @@ export function getDependencyGraph( const expected = match.packageJson.version; const rawDepRange = depRange; + + const catalogName = parseCatalogProtocol(depRange); + + if (catalogName != null) { + const resolvedRange = resolveCatalogRange( + depRange, + depName, + packages.catalogs, + ); + + if (resolvedRange == null) { + valid = false; + const catalog = + catalogName === DEFAULT_CATALOG_NAME + ? "the default catalog" + : `the "${catalogName}" catalog`; + // TODO: replace with returning errors/warnings + console.error( + `Package ${c.blue(name)} depends on ${c.blue(depName)} through ${c.red(rawDepRange)}, but ${catalog} has no entry for it`, + ); + continue; + } + depRange = resolvedRange; + } + const usesWorkspaceRange = depRange.startsWith("workspace:"); if (usesWorkspaceRange) { diff --git a/packages/get-release-plan/package.json b/packages/get-release-plan/package.json index 387e6f05e..eff6fe0be 100644 --- a/packages/get-release-plan/package.json +++ b/packages/get-release-plan/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@changesets/assemble-release-plan": "workspace:^", + "@changesets/catalogs": "workspace:^", "@changesets/config": "workspace:^", "@changesets/pre": "workspace:^", "@changesets/read": "workspace:^", diff --git a/packages/get-release-plan/src/index.ts b/packages/get-release-plan/src/index.ts index b6b0eff2a..c46680fb7 100644 --- a/packages/get-release-plan/src/index.ts +++ b/packages/get-release-plan/src/index.ts @@ -1,4 +1,5 @@ import { assembleReleasePlan } from "@changesets/assemble-release-plan"; +import { withCatalogs } from "@changesets/catalogs"; import { readConfig } from "@changesets/config"; import { readPreState } from "@changesets/pre"; import { readChangesets } from "@changesets/read"; @@ -10,7 +11,7 @@ export async function getReleasePlan( sinceRef?: string, passedConfig?: Config, ): Promise { - const packages = await getPackages(cwd); + const packages = await withCatalogs(await getPackages(cwd)); const configResult = await readConfig(packages.rootDir, packages); if (configResult.config == null) { diff --git a/packages/git/package.json b/packages/git/package.json index c7dc45aa8..1f9f861ce 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -17,6 +17,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@changesets/catalogs": "workspace:^", "@changesets/errors": "workspace:^", "@changesets/types": "workspace:^", "@manypkg/get-packages": "^3.1.0", diff --git a/packages/git/src/index.test.ts b/packages/git/src/index.test.ts index d4d216039..f2078df8a 100644 --- a/packages/git/src/index.test.ts +++ b/packages/git/src/index.test.ts @@ -764,6 +764,152 @@ describe("git", { tags: ["slow"] }, () => { "pkg-a", ]); }); + + describe("with catalogs", () => { + const catalogWorkspace = (catalog: Record) => ({ + "package.json": JSON.stringify({ private: true, name: "root" }), + "pnpm-workspace.yaml": [ + "packages:", + " - packages/*", + "catalog:", + ...Object.entries(catalog).map( + ([name, range]) => ` ${name}: ${range}`, + ), + "catalogs:", + " legacy:", + " react: ^17.0.2", + "", + ].join("\n"), + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + dependencies: { react: "catalog:" }, + }), + "packages/pkg-b/package.json": JSON.stringify({ + name: "pkg-b", + devDependencies: { jest: "catalog:" }, + }), + "packages/pkg-c/package.json": JSON.stringify({ + name: "pkg-c", + dependencies: { react: "catalog:legacy" }, + }), + }); + + const updateCatalog = async ( + cwd: string, + catalog: Record, + ) => { + await outputFile( + path.join(cwd, "pnpm-workspace.yaml"), + catalogWorkspace(catalog)["pnpm-workspace.yaml"], + ); + await commit("update catalog", cwd); + }; + + it("should return the packages referencing an updated entry", async () => { + const cwd = await gitdir( + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" }), + ); + await exec("git", ["checkout", "-b", "new-branch"], { + nodeOptions: { cwd }, + }); + await updateCatalog(cwd, { react: "^19.0.0", jest: "^30.0.0" }); + + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: "main", + }); + + expect(changedPackages.map((pkg) => pkg.packageJson.name)).toEqual([ + "pkg-a", + ]); + }); + + it("should ignore catalogs when detectCatalogChanges is off", async () => { + const cwd = await gitdir( + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" }), + ); + await exec("git", ["checkout", "-b", "new-branch"], { + nodeOptions: { cwd }, + }); + await updateCatalog(cwd, { react: "^19.0.0", jest: "^30.0.0" }); + + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: "main", + detectCatalogChanges: false, + }); + + expect(changedPackages).toHaveLength(0); + }); + + it("should not return packages referencing an entry that didn't change", async () => { + const cwd = await gitdir( + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" }), + ); + await exec("git", ["checkout", "-b", "new-branch"], { + nodeOptions: { cwd }, + }); + await updateCatalog(cwd, { react: "^18.0.0", jest: "^31.0.0" }); + + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: "main", + }); + + expect(changedPackages.map((pkg) => pkg.packageJson.name)).toEqual([ + "pkg-b", + ]); + }); + + it("should tell entries of different catalogs apart", async () => { + const cwd = await gitdir( + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" }), + ); + await exec("git", ["checkout", "-b", "new-branch"], { + nodeOptions: { cwd }, + }); + await outputFile( + path.join(cwd, "pnpm-workspace.yaml"), + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" })[ + "pnpm-workspace.yaml" + ].replace("react: ^17.0.2", "react: ^17.1.0"), + ); + await commit("update legacy catalog", cwd); + + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: "main", + }); + + expect(changedPackages.map((pkg) => pkg.packageJson.name)).toEqual([ + "pkg-c", + ]); + }); + + it("should still report packages with changed files", async () => { + const cwd = await gitdir( + catalogWorkspace({ react: "^18.0.0", jest: "^30.0.0" }), + ); + await exec("git", ["checkout", "-b", "new-branch"], { + nodeOptions: { cwd }, + }); + await outputFile( + path.join(cwd, "packages/pkg-b/index.js"), + "export const answer = 42;", + ); + await add("packages/pkg-b/index.js", cwd); + await updateCatalog(cwd, { react: "^19.0.0", jest: "^30.0.0" }); + + const changedPackages = await getChangedPackagesSinceRef({ + cwd, + ref: "main", + }); + + expect( + changedPackages.map((pkg) => pkg.packageJson.name).toSorted(), + ).toEqual(["pkg-a", "pkg-b"]); + }); + }); }); describe("getChangedChangesetFilesSinceRef", () => { diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 668dd1764..c78906591 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -1,7 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + getChangedCatalogEntries, + parseCatalogProtocol, + parseCatalogs, + readCatalogs, +} from "@changesets/catalogs"; import { GitError } from "@changesets/errors"; -import type { Package } from "@changesets/types"; +import type { Package, Packages } from "@changesets/types"; import { getPackages } from "@manypkg/get-packages"; import picomatch from "picomatch"; import { exec } from "tinyexec"; @@ -274,14 +280,27 @@ export async function getChangedPackagesSinceRef({ cwd, ref, changedFilePatterns = ["**"], + detectCatalogChanges = true, }: { cwd: string; ref: string; changedFilePatterns?: readonly string[]; + detectCatalogChanges?: boolean; }): Promise { const changedFiles = await getChangedFilesSince({ ref, cwd, fullPath: true }); - - return (await getPackages(cwd)).packages + const packages = await getPackages(cwd); + + const packagesWithChangedCatalogEntries = detectCatalogChanges + ? await getPackagesWithChangedCatalogEntries({ + cwd, + ref, + packages, + changedFiles, + changedFilePatterns, + }) + : new Set(); + + return packages.packages .toSorted((pkgA, pkgB) => pkgB.dir.length - pkgA.dir.length) .filter((pkg) => { const changedPackageFiles: string[] = []; @@ -297,12 +316,106 @@ export async function getChangedPackagesSinceRef({ } return ( - changedPackageFiles.length > 0 && - globMatchSome(changedPackageFiles, changedFilePatterns) + packagesWithChangedCatalogEntries.has(pkg.packageJson.name) || + (changedPackageFiles.length > 0 && + globMatchSome(changedPackageFiles, changedFilePatterns)) ); }); } +const DEPENDENCY_TYPES = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +async function getPackagesWithChangedCatalogEntries({ + cwd, + ref, + packages, + changedFiles, + changedFilePatterns, +}: { + cwd: string; + ref: string; + packages: Packages; + changedFiles: readonly string[]; + changedFilePatterns: readonly string[]; +}): Promise> { + const changed = new Set(); + + // A catalog entry replaces a range in each package.json referencing it, + // so a config that excludes package.json should exclude catalog updates too + if (!globMatchSome(["package.json"], changedFilePatterns)) { + return changed; + } + + const catalogs = await readCatalogs(packages.rootDir); + + if (!catalogs.source) { + return changed; + } + + const catalogPath = path.resolve(packages.rootDir, catalogs.source.filePath); + + if (!changedFiles.includes(catalogPath)) { + return changed; + } + + const repoRoot = await getRepoRoot({ cwd }); + const previousContents = await getFileContentsAtRef({ + cwd, + ref: await getDivergedCommit(cwd, ref), + filePath: path.relative(repoRoot, catalogPath).replace(/\\/g, "/"), + }); + + const changedEntries = getChangedCatalogEntries( + previousContents == null + ? new Map() + : parseCatalogs(previousContents, catalogs.source.format), + catalogs.entries, + ); + + if (changedEntries.length === 0) { + return changed; + } + + for (const pkg of packages.packages) { + const referencesChangedEntry = changedEntries.some( + ({ catalogName, dependencyName }) => + DEPENDENCY_TYPES.some((depType) => { + const range = pkg.packageJson[depType]?.[dependencyName]; + + return range != null && parseCatalogProtocol(range) === catalogName; + }), + ); + + if (referencesChangedEntry) { + changed.add(pkg.packageJson.name); + } + } + + return changed; +} + +async function getFileContentsAtRef({ + cwd, + ref, + filePath, +}: { + cwd: string; + ref: string; + filePath: string; +}): Promise { + const cmd = await exec("git", ["show", `${ref}:${filePath}`], { + nodeOptions: { cwd }, + }); + + // A non-zero exit code means the file didn't exist at that ref + return cmd.exitCode === 0 ? cmd.stdout.toString() : undefined; +} + export async function tagExists(tagStr: string, cwd: string) { const gitCmd = await exec("git", ["tag", "-l", tagStr], { nodeOptions: { cwd }, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e7398ea8e..1d6b86038 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -117,6 +117,8 @@ export type Config = { ignore: ReadonlyArray; /** This is supposed to be used with pnpm's `link-workspace-packages: false` and Berry's `enableTransparentWorkspaces: false` */ bumpVersionsWithWorkspaceProtocolOnly?: boolean; + /** Whether updating a catalog entry counts as a change to every package referencing it */ + detectCatalogChanges?: boolean; ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: Required; snapshot: { useCalculatedVersion: boolean; @@ -147,6 +149,8 @@ export type WrittenConfig = { updateInternalDependencies?: "patch" | "minor"; ignore?: ReadonlyArray; bumpVersionsWithWorkspaceProtocolOnly?: boolean; + /** Whether updating a catalog entry counts as a change to every package referencing it */ + detectCatalogChanges?: boolean; snapshot?: { useCalculatedVersion?: boolean; prereleaseTemplate?: string; @@ -215,4 +219,35 @@ export interface Packages { tool: { type: "yarn" | "pnpm" | "lerna" | "bolt" | "root" | (string & {}); }; + /** When absent, `catalog:` ranges can't be resolved and are ignored. */ + catalogs?: Catalogs; +} + +export type CatalogFormat = + /** `catalog` / `catalogs` at the top level of `pnpm-workspace.yaml` */ + | "pnpm-workspace" + /** `catalog` / `catalogs` at the top level of `.yarnrc.yml` */ + | "yarnrc" + /** `catalog` / `catalogs` in `package.json`, at the root or under `workspaces` */ + | "package-json"; + +export interface CatalogSource { + format: CatalogFormat; + /** Relative to the workspace root */ + filePath: string; +} + +/** Catalog name -> dependency name -> version range */ +export type CatalogEntries = ReadonlyMap>; + +export interface Catalogs { + entries: CatalogEntries; + /** Where the entries were read from, or `undefined` when the workspace has no catalogs */ + source: CatalogSource | undefined; +} + +export interface CatalogEntryUpdate { + catalogName: string; + dependencyName: string; + value: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb035623..856faab3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: packages/apply-release-plan: dependencies: + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/config': specifier: workspace:^ version: link:../config @@ -122,6 +125,9 @@ importers: packages/assemble-release-plan: dependencies: + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/errors': specifier: workspace:^ version: link:../errors @@ -142,6 +148,22 @@ importers: specifier: workspace:* version: link:../config + packages/catalogs: + dependencies: + '@changesets/types': + specifier: workspace:^ + version: link:../types + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@changesets/test-utils': + specifier: workspace:* + version: link:../../scripts/test-utils + packages/changelog-git: dependencies: '@changesets/types': @@ -169,6 +191,9 @@ importers: '@changesets/assemble-release-plan': specifier: workspace:^ version: link:../assemble-release-plan + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/changelog-git': specifier: workspace:^ version: link:../changelog-git @@ -274,6 +299,9 @@ importers: packages/config: dependencies: + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/get-dependents-graph': specifier: workspace:^ version: link:../get-dependents-graph @@ -307,6 +335,9 @@ importers: packages/get-dependents-graph: dependencies: + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/types': specifier: workspace:^ version: link:../types @@ -336,6 +367,9 @@ importers: '@changesets/assemble-release-plan': specifier: workspace:^ version: link:../assemble-release-plan + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/config': specifier: workspace:^ version: link:../config @@ -356,6 +390,9 @@ importers: packages/git: dependencies: + '@changesets/catalogs': + specifier: workspace:^ + version: link:../catalogs '@changesets/errors': specifier: workspace:^ version: link:../errors diff --git a/site/.vitepress/config.ts b/site/.vitepress/config.ts index 612f05fbe..ee4175487 100644 --- a/site/.vitepress/config.ts +++ b/site/.vitepress/config.ts @@ -216,6 +216,7 @@ function getMainSidebar(): DefaultTheme.SidebarItem[] { }, { text: "Automating Changesets", link: "automating" }, { text: "Backporting Changes", link: "backporting-changes" }, + { text: "Catalogs", link: "catalogs" }, { text: "Fixed Packages", link: "fixed-packages" }, { text: "Linked Packages", link: "linked-packages" }, { diff --git a/site/guide/catalogs.md b/site/guide/catalogs.md new file mode 100644 index 000000000..9faf25e2d --- /dev/null +++ b/site/guide/catalogs.md @@ -0,0 +1,58 @@ +# Catalogs + +Catalogs let a workspace declare a version range once and reference it from any number of packages through the `catalog:` protocol: + +```json [packages/app/package.json] +{ + "dependencies": { + "react": "catalog:", + "jest": "catalog:testing" + } +} +``` + +Changesets understands catalogs out of the box. Where a range lives doesn't change how a release is calculated. + +## Supported package managers + +| Package manager | Where the catalogs live | +| --------------- | --------------------------------------------------------------------------- | +| pnpm | `catalog` / `catalogs` in `pnpm-workspace.yaml` | +| Yarn | `catalog` / `catalogs` in `.yarnrc.yml` | +| Bun | `catalog` / `catalogs` in `package.json`, at the root or under `workspaces` | + +Both the default catalog (`catalog:`, also spelled `catalog:default`) and named catalogs (`catalog:testing`) are supported. + +## Packages in your workspace + +Use the [`workspace:` protocol](https://pnpm.io/workspaces#workspace-protocol) for packages that live in your workspace. A catalog can point at one anyway, which is occasionally useful to pin a single version of a package that is both published and consumed internally, and Changesets handles it: the reference is resolved before deciding what to release, so the dependent is bumped exactly as it would be with the range written out in full. + +Releasing a package that a catalog points at updates the catalog entry, keeping the range style it had. Giving `@scope/pkg` a major release turns this: + +```yaml [pnpm-workspace.yaml] +catalog: + "@scope/pkg": ^1.0.0 +``` + +into this: + +```yaml [pnpm-workspace.yaml] +catalog: + "@scope/pkg": ^2.0.0 +``` + +The packages referencing it keep saying `catalog:`, only the catalog changes. Entries that no package in the workspace references are left alone. + +## Dependencies outside your workspace + +Editing a dependency range in a package's own `package.json` marks that package as changed, because the file lives inside the package. A catalog lives at the root of the workspace and belongs to no package in particular, so Changesets treats an updated catalog entry as a change to every package referencing it. + +That means `changeset add` picks those packages up, and a pull request from a dependency update bot that only touches the catalog still asks for a changeset. + +Set [`detectCatalogChanges`](./config.md#detectcatalogchanges) to `false` to opt out: + +```json [.changeset/config.json] +{ + "detectCatalogChanges": false +} +``` diff --git a/site/guide/config.md b/site/guide/config.md index f1d364879..dfdb0e400 100644 --- a/site/guide/config.md +++ b/site/guide/config.md @@ -233,6 +233,19 @@ These restrictions exist to ensure your repository or published code do not end Whether to only bump dependency ranges that use the `workspace:` protocol of packages that are part of the workspace. +## detectCatalogChanges + +- **Type:** `boolean` +- **Default:** `true` +- **Related:** [Catalogs](./catalogs.md) +- **Note:** Only applicable in monorepos using catalogs. + +Whether updating the version range of a catalog entry counts as a change to every package that references it through the `catalog:` protocol, for the purposes of `changeset add` and `changeset status`. + +Editing a dependency range in a package's own `package.json` marks that package as changed, because the file lives inside the package. A catalog lives at the root of the workspace, so without this option an updated entry would go unnoticed. Set this to `false` if you'd rather catalog updates never ask for a changeset. + +This option has no effect on packages inside your workspace that are referenced through a catalog. Those are always resolved and released as if the range had been written out in full. + ## snapshot - **Type:** `{ useCalculatedVersion?: boolean; prereleaseTemplate?: string }`