Repository: atlassian/changesets (ts)

Issue:
Add support for the workspace protocol
## Affected Packages

<!-- Because monorepos, it's super helpful if you tell us which packages are affected, so it's easy to find the change points. Adding a tag pf `pkg:package-name` would be the best 🙏 -->

## Problem

Both pnpm and Yarn v2 support workspace ranges.
* [About workspace ranges in pnpm](https://pnpm.js.org/en/workspaces#workspace-ranges-workspace)
* [About workspace ranges in Yarn v2](https://yarnpkg.com/features/workspaces#workspace-ranges-workspace)

When I ran `changeset status` on the [pnpm monorepo](https://github.com/pnpm/pnpm/), I got lots of the following errors:

```
Package "@pnpm/tarball-fetcher" must depend on the current version of "@pnpm/error": "1.2.0" vs "workspace:1.2.0"
```

Workspace ranges are really useful in monorepos, so I think it is crucial to support them. And it shouldn't be hard to support. You could probably just strip the `workspace:` part. Only `workspace:*` is a special case. Before publish, it is replaced with the current version present in the monorepo.

## Proposed solution

<!-- If you have an idea of how to solve it, otherwise that's fine as well. We may edit this desciption to add details later -->


Pull request description:
feat: support workspace ranges
close #307

Patch:
diff --git a/.changeset/fuzzy-tips-provide.md b/.changeset/fuzzy-tips-provide.md
new file mode 100644
index 0000000..2c848aa
--- /dev/null
+++ b/.changeset/fuzzy-tips-provide.md
@@ -0,0 +1,5 @@
+---
+"@changesets/get-dependents-graph": minor
+---
+
+Added support for workspace ranges. Package graph validation understands them now and allows them to satisfy dependents' required ranges.
diff --git a/.changeset/itchy-pants-obey.md b/.changeset/itchy-pants-obey.md
new file mode 100644
index 0000000..2ec7ab3
--- /dev/null
+++ b/.changeset/itchy-pants-obey.md
@@ -0,0 +1,5 @@
+---
+"@changesets/apply-release-plan": minor
+---
+
+Added support for workspace ranges. They are now correctly kept and updated when applying a release plan.
diff --git a/__fixtures__/simple-workspace-range-dep/.changeset/README.md b/__fixtures__/simple-workspace-range-dep/.changeset/README.md
new file mode 100644
index 0000000..fd188f5
--- /dev/null
+++ b/__fixtures__/simple-workspace-range-dep/.changeset/README.md
@@ -0,0 +1,3 @@
+# We just want a file in here so git collects it
+
+For this we have deliberately not included a config file, as we want to test the defaults
\ No newline at end of file
diff --git a/__fixtures__/simple-workspace-range-dep/.changeset/config.json b/__fixtures__/simple-workspace-range-dep/.changeset/config.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/__fixtures__/simple-workspace-range-dep/.changeset/config.json
@@ -0,0 +1,1 @@
+{}
diff --git a/__fixtures__/simple-workspace-range-dep/package.json b/__fixtures__/simple-workspace-range-dep/package.json
new file mode 100644
index 0000000..2e742d8
--- /dev/null
+++ b/__fixtures__/simple-workspace-range-dep/package.json
@@ -0,0 +1,11 @@
+{
+  "private": true,
+  "name": "simple-project",
+  "description": "three projects, each depending on one other",
+  "version": "1.0.0",
+  "bolt": {
+    "workspaces": [
+      "packages/*"
+    ]
+  }
+}
diff --git a/__fixtures__/simple-workspace-range-dep/packages/pkg-a/package.json b/__fixtures__/simple-workspace-range-dep/packages/pkg-a/package.json
new file mode 100644
index 0000000..74bdb1f
--- /dev/null
+++ b/__fixtures__/simple-workspace-range-dep/packages/pkg-a/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "pkg-a",
+  "version": "1.0.0",
+  "dependencies": {
+    "pkg-b": "workspace:1.0.0"
+  }
+}
diff --git a/__fixtures__/simple-workspace-range-dep/packages/pkg-b/package.json b/__fixtures__/simple-workspace-range-dep/packages/pkg-b/package.json
new file mode 100644
index 0000000..be4760e
--- /dev/null
+++ b/__fixtures__/simple-workspace-range-dep/packages/pkg-b/package.json
@@ -0,0 +1,4 @@
+{
+  "name": "pkg-b",
+  "version": "1.0.0"
+}
diff --git a/packages/apply-release-plan/src/version-package.ts b/packages/apply-release-plan/src/version-package.ts
index b1dfb6f..af62a2c 100644
--- a/packages/apply-release-plan/src/version-package.ts
+++ b/packages/apply-release-plan/src/version-package.ts
@@ -26,8 +26,12 @@ export default function versionPackage(
     if (deps) {
       for (let { name, version } of versionsToUpdate) {
         let depCurrentVersion = deps[name];
+        if (!depCurrentVersion) continue;
+        const usesWorkspaceRange = depCurrentVersion.startsWith("workspace:");
+        if (usesWorkspaceRange) {
+          depCurrentVersion = depCurrentVersion.substr(10);
+        }
         if (
-          depCurrentVersion &&
           // an empty string is the normalised version of x/X/*
           // we don't want to change these versions because they will match
           // any version and if someone makes the range that
@@ -36,6 +40,7 @@ export default function versionPackage(
         ) {
           let rangeType = getVersionRangeType(depCurrentVersion);
           let newNewRange = `${rangeType}${version}`;
+          if (usesWorkspaceRange) newNewRange = `workspace:${newNewRange}`;
           deps[name] = newNewRange;
         }
       }
diff --git a/packages/get-dependents-graph/src/get-dependency-graph.ts b/packages/get-dependents-graph/src/get-dependency-graph.ts
index cf4c7eb..f1c2066 100644
--- a/packages/get-dependents-graph/src/get-dependency-graph.ts
+++ b/packages/get-dependents-graph/src/get-dependency-graph.ts
@@ -54,12 +54,16 @@ export default function getDependencyGraph(
     const dependencies = [];
     const allDependencies = getAllDependencies(pkg.packageJson);
 
-    for (const [depName, depVersion] of allDependencies) {
+    for (let [depName, depVersion] of allDependencies) {
       const match = packagesByName[depName];
       if (!match) continue;
 
       const expected = match.packageJson.version;
 
+      if (depVersion.startsWith("workspace:")) {
+        depVersion = depVersion.substr(10);
+      }
+
       // internal dependencies only need to semver satisfy, not '==='
       if (!semver.satisfies(expected, depVersion)) {
         valid = false;


Test patch:
diff --git a/packages/apply-release-plan/src/index.test.ts b/packages/apply-release-plan/src/index.test.ts
index 24f6747..75b5c5b 100644
--- a/packages/apply-release-plan/src/index.test.ts
+++ b/packages/apply-release-plan/src/index.test.ts
@@ -148,6 +148,43 @@ describe("apply release plan", () => {
         }
       });
     });
+    it("should update workspace ranges", async () => {
+      const releasePlan = new FakeReleasePlan(
+        [
+          {
+            id: "some-id",
+            releases: [{ name: "pkg-b", type: "minor" }],
+            summary: "a very useful summary"
+          }
+        ],
+        [
+          {
+            changesets: ["some-id"],
+            name: "pkg-b",
+            newVersion: "1.1.0",
+            oldVersion: "1.0.0",
+            type: "minor"
+          }
+        ]
+      );
+      let { changedFiles } = await testSetup(
+        "simple-workspace-range-dep",
+        releasePlan.getReleasePlan(),
+        releasePlan.config
+      );
+      let pkgPath = changedFiles.find(a => a.endsWith("pkg-a/package.json"));
+
+      if (!pkgPath) throw new Error(`could not find an updated package json`);
+      let pkgJSON = await fs.readJSON(pkgPath);
+
+      expect(pkgJSON).toEqual({
+        name: "pkg-a",
+        version: "1.1.0",
+        dependencies: {
+          "pkg-b": "workspace:1.1.0"
+        }
+      });
+    });
     it("should update a version for two packages with different new versions", async () => {
       const releasePlan = new FakeReleasePlan(
         [],
diff --git a/packages/cli/src/commands/version/version.test.ts b/packages/cli/src/commands/version/version.test.ts
index 24cf527..c781084 100644
--- a/packages/cli/src/commands/version/version.test.ts
+++ b/packages/cli/src/commands/version/version.test.ts
@@ -195,6 +195,40 @@ describe("running version in a simple project", () => {
   });
 });
 
+describe("running version in a simple project with workspace range", () => {
+  temporarilySilenceLogs();
+  let cwd: string;
+
+  beforeEach(async () => {
+    cwd = await copyFixtureIntoTempDir(__dirname, "simple-workspace-range-dep");
+    console.error = jest.fn();
+  });
+
+  afterEach(async () => {
+    console.error = consoleError;
+  });
+
+  describe("When there is a changeset commit", () => {
+    it("should bump releasedPackages", async () => {
+      await writeChangesets([simpleChangeset2], cwd);
+      const spy = jest.spyOn(fs, "writeFile");
+
+      await versionCommand(cwd, modifiedDefaultConfig);
+
+      expect(getPkgJSON("pkg-a", spy.mock.calls)).toEqual(
+        expect.objectContaining({
+          name: "pkg-a",
+          version: "1.1.0",
+          dependencies: { "pkg-b": "workspace:1.0.1" }
+        })
+      );
+      expect(getPkgJSON("pkg-b", spy.mock.calls)).toEqual(
+        expect.objectContaining({ name: "pkg-b", version: "1.0.1" })
+      );
+    });
+  });
+});
+
 const f = fixturez(__dirname);
 
 describe("pre", () => {
@@ -592,7 +626,7 @@ describe("pre", () => {
       }
     ]);
   });
-  it.only("should use the highest bump type for between all prereleases for every prerelease", async () => {
+  it("should use the highest bump type for between all prereleases for every prerelease", async () => {
     let cwd = f.copy("simple-project");
     await writeChangeset(
       {