Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/plenty-hounds-shop.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/config-file-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions packages/apply-release-plan/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"./package.json": "./package.json"
},
"dependencies": {
"@changesets/catalogs": "workspace:^",
"@changesets/config": "workspace:^",
"@changesets/format": "^0.1.1",
"@changesets/git": "workspace:^",
Expand Down
170 changes: 170 additions & 0 deletions packages/apply-release-plan/src/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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<Config>;
} = {}) {
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");
});
});
21 changes: 18 additions & 3 deletions packages/apply-release-plan/src/get-changelog-entry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { resolveCatalogRange } from "@changesets/catalogs";
import type {
Catalogs,
ChangelogFunctions,
ModCompWithPackage,
NewChangesetWithCommit,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions packages/apply-release-plan/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ import {
import * as git from "@changesets/git";
import { shouldSkipPackage } from "@changesets/should-skip-package";
import type {
Catalogs,
ChangelogFunctions,
ComprehensiveRelease,
Packages,
Expand All @@ -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";
Expand Down Expand Up @@ -73,6 +76,29 @@ async function updatePackageJson(dir: string, edits: EditJsonOperation[]) {
return pkgJsonPath;
}

async function updateCatalog(
packages: Packages,
updates: ReturnType<typeof getCatalogEntryUpdates>,
) {
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,
Expand Down Expand Up @@ -109,6 +135,7 @@ export async function applyReleasePlan(
config,
cwd,
contextDir,
packages.catalogs,
);

if (releasePlan.preState?.mode === "exit" && snapshot == null) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -241,6 +276,7 @@ async function getNewChangelogEntry(
config: Config,
cwd: string,
contextDir: string,
catalogs: Catalogs | undefined,
) {
if (!config.changelog) {
return Promise.resolve(
Expand Down Expand Up @@ -307,6 +343,7 @@ async function getNewChangelogEntry(
onlyUpdatePeerDependentsWhenOutOfRange:
config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH
.onlyUpdatePeerDependentsWhenOutOfRange,
catalogs,
},
);

Expand Down
Loading