Repository: atlassian/changesets
Pull request #124 (2019-07-17 14:09:37)

Noviny/assemble release plan
SOME NOTES:

This works in theory - I've thrown some gnarly test cases at it and it didn't break. Next step is to make this thorough. We had a lot of old tests for updating versions and determining dependencies that I want to rewrite for this, since errors here can be extremely costly.

There's some code neatness that needs to be addressed.

The `test-utils` file is an implementation of @mitchellhamilton's idea to generate a whole state to run tests against. We can set up and modify a data set easily now. I'm still not in love with how I am testing the output though.

Next package after this is the one that takes the output of this and applies it to a repository. After that, probably wire it into the CLI

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 4f39699e8a720478f1b91336cb76088d321fb503

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

[Click here to learn what changesets are, and how to add one](https://github.com/Noviny/changesets/blob/master/docs/adding-a-changeset.md).
Noviny: @mitchellhamilton think this is good for review if you want to look at it.
Noviny: @mitchellhamilton updated yah!

Patch:
diff --git a/.changeset/fast-plants-move/changes.json b/.changeset/fast-plants-move/changes.json
new file mode 100644
index 0000000..3fa5e36
--- /dev/null
+++ b/.changeset/fast-plants-move/changes.json
@@ -0,0 +1,6 @@
+{
+  "releases": [
+    { "name": "@changesets/assemble-release-plan", "type": "minor" }
+  ],
+  "dependents": []
+}
diff --git a/.changeset/fast-plants-move/changes.md b/.changeset/fast-plants-move/changes.md
new file mode 100644
index 0000000..03081f0
--- /dev/null
+++ b/.changeset/fast-plants-move/changes.md
@@ -0,0 +1,1 @@
+Initial release
\ No newline at end of file
diff --git a/packages/assemble-release-plan/package.json b/packages/assemble-release-plan/package.json
new file mode 100644
index 0000000..b33258a
--- /dev/null
+++ b/packages/assemble-release-plan/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "@changesets/assemble-release-plan",
+  "version": "0.0.0",
+  "description": "Reads changesets and adds information on dependents that need bumping",
+  "main": "dist/assemble-release-plan.cjs.js",
+  "module": "dist/assemble-release-plan.esm.js",
+  "license": "MIT",
+  "repository": "https://github.com/changesets/changesets/tree/master/packages/assemble-release-plan",
+  "dependencies": {
+    "@changesets/types": "^0.0.0",
+    "semver": "^5.4.1"
+  }
+}
\ No newline at end of file
diff --git a/packages/assemble-release-plan/src/README.md b/packages/assemble-release-plan/src/README.md
new file mode 100644
index 0000000..e4308f9
--- /dev/null
+++ b/packages/assemble-release-plan/src/README.md
@@ -0,0 +1,4 @@
+# Get Release Info
+
+This package does the end-to-end work of reading in the current set of changesets, and generating a release plan
+based on them.
diff --git a/packages/assemble-release-plan/src/apply-links.ts b/packages/assemble-release-plan/src/apply-links.ts
new file mode 100644
index 0000000..679b03f
--- /dev/null
+++ b/packages/assemble-release-plan/src/apply-links.ts
@@ -0,0 +1,50 @@
+import semver from "semver";
+import { Linked, ComprehensiveRelease } from "@changesets/types";
+
+/*
+  WARNING:
+  Important note for understanding how this package works:
+
+  We are doing some kind of wacky things with manipulating the objects within the
+  releases array, despite the fact that this was passed to us as an argument. We are
+  aware that this is generally bad practice, but have decided to to this here as
+  we control the entire flow of releases.
+
+  We could solve this by inlining this function, or by returning a deep-cloned then
+  modified array, but we decided both of those are worse than this solution.
+*/
+function applyLinks(
+  releases: ComprehensiveRelease[],
+  linked?: Linked
+): boolean {
+  let updated = false;
+  if (!linked) return updated;
+
+  // We do this for each set of linked packages
+  for (let linkedPackages of linked) {
+    // First we filter down to all the relevent releases for one set of linked packages
+    let releasingLinkedPackages = releases.filter(release =>
+      linkedPackages.includes(release.name)
+    );
+
+    let highestVersion;
+    // Next we determine what the highest version among the linked packages will be
+    for (let linkedPackage of releasingLinkedPackages) {
+      let version = linkedPackage.newVersion;
+      if (highestVersion === undefined || semver.gt(version, highestVersion)) {
+        highestVersion = version;
+      }
+    }
+    // Finally, we update the packages so all of them are on the highest version
+    for (let linkedPackage of releasingLinkedPackages) {
+      if (highestVersion && linkedPackage.newVersion !== highestVersion) {
+        updated = true;
+        linkedPackage.newVersion = highestVersion;
+      }
+    }
+  }
+
+  return updated;
+}
+
+export default applyLinks;
diff --git a/packages/assemble-release-plan/src/determine-dependents.ts b/packages/assemble-release-plan/src/determine-dependents.ts
new file mode 100644
index 0000000..c26f282
--- /dev/null
+++ b/packages/assemble-release-plan/src/determine-dependents.ts
@@ -0,0 +1,160 @@
+import semver from "semver";
+import {
+  Release,
+  Workspace,
+  DependencyType,
+  PackageJSON,
+  VersionType,
+  ComprehensiveRelease
+} from "@changesets/types";
+
+/*
+  WARNING:
+  Important note for understanding how this package works:
+
+  We are doing some kind of wacky things with manipulating the objects within the
+  releases array, despite the fact that this was passed to us as an argument. We are
+  aware that this is generally bad practice, but have decided to to this here as
+  we control the entire flow of releases.
+
+  We could solve this by inlining this function, or by returning a deep-cloned then
+  modified array, but we decided both of those are worse than this solution.
+*/
+export default function getDependents(
+  releases: ComprehensiveRelease[],
+  workspaces: Workspace[],
+  dependencyGraph: Map<string, string[]>
+): boolean {
+  let updated = false;
+  // NOTE this is intended to be called recursively
+  let pkgsToSearch = [...releases];
+
+  let pkgJsonsByName = new Map(
+    // TODO this seems an inefficient use of getting the whole workspaces
+    // Should we ask for this to be simplified 'above'?
+    workspaces.map(({ name, config }) => [name, config])
+  );
+
+  while (pkgsToSearch.length > 0) {
+    // nextRelease is our dependency, think of it as "avatar"
+    const nextRelease = pkgsToSearch.shift();
+    if (!nextRelease) continue;
+    // pkgDependents will be a list of packages that depend on nextRelease ie. ['avatar-group', 'comment']
+    const pkgDependents = dependencyGraph.get(nextRelease.name);
+    if (!pkgDependents) {
+      throw new Error(
+        `Error in determining dependents - could not find package in repository: ${
+          nextRelease.name
+        }`
+      );
+    }
+    // For each dependent we are going to see whether it needs to be bumped because it's dependency
+    // is leaving the version range.
+    pkgDependents
+      .map(dependent => {
+        let type: VersionType | undefined;
+
+        const dependentPkgJSON = pkgJsonsByName.get(dependent);
+        if (!dependentPkgJSON) throw new Error("Dependency map is incorrect");
+        const { depTypes, versionRange } = getDependencyVersionRange(
+          dependentPkgJSON,
+          nextRelease.name
+        );
+
+        // Firstly we check if it is a peerDependency because if it is, our dependent bump type needs to be major.
+        if (
+          depTypes.includes("peerDependencies") &&
+          nextRelease.type !== "patch" &&
+          (!releases.some(dep => dep.name === dependent) ||
+            releases.some(
+              dep => dep.name === dependent && dep.type !== "major"
+            ))
+        ) {
+          type = "major";
+        } else {
+          if (
+            // TODO validate this - I don't think it's right anymore
+            !releases.some(dep => dep.name === dependent) &&
+            !semver.satisfies(nextRelease.newVersion, versionRange)
+          ) {
+            type = "patch";
+          }
+        }
+        return { name: dependent, type, pkgJSON: dependentPkgJSON };
+      })
+      .filter(({ type }) => type)
+      .forEach(
+        // @ts-ignore - I don't know how to make typescript understand that the filter above guarantees this and I got sick of trying
+        ({ name, type, pkgJSON }: Release & { pkgJSON: PackageJSON }) => {
+          // At this point, we know if we are making a change
+          updated = true;
+
+          const existing = releases.find(dep => dep.name === name);
+          // For things that are being given a major bump, we check if we have already
+          // added them here. If we have, we update the existing item instead of pushing it on to search.
+          // It is safe to not add it to pkgsToSearch because it should have already been searched at the
+          // largest possible bump type.
+
+          if (existing && type === "major" && existing.type !== "major") {
+            existing.type = "major";
+
+            // @ts-ignore by this point we're comfy semver.inc will return a non-null value so just casting it
+            existing.newVersion = semver.inc(
+              existing.oldVersion,
+              existing.type
+            );
+
+            pkgsToSearch.push(existing);
+          } else {
+            let newDependent: ComprehensiveRelease = {
+              name,
+              type,
+              oldVersion: pkgJSON.version,
+              // @ts-ignore by this point we're comfy semver.inc will return a non-null value so just casting it
+              newVersion: semver.inc(pkgJSON.version, type),
+              changesets: []
+            };
+
+            pkgsToSearch.push(newDependent);
+            releases.push(newDependent);
+          }
+        }
+      );
+  }
+
+  return updated;
+}
+
+/*
+  Returns an object in the shape { depTypes: [], versionRange: '' } with a list of different depTypes
+  matched ('dependencies', 'peerDependencies', etc) and the versionRange itself ('^1.0.0')
+*/
+
+function getDependencyVersionRange(
+  dependentPkgJSON: PackageJSON,
+  dependencyName: string
+) {
+  const DEPENDENCY_TYPES = [
+    "dependencies",
+    "devDependencies",
+    "peerDependencies",
+    "optionalDependencies"
+  ] as const;
+  const dependencyVersionRange: {
+    depTypes: DependencyType[];
+    versionRange: string;
+  } = {
+    depTypes: [],
+    versionRange: ""
+  };
+  for (const type of DEPENDENCY_TYPES) {
+    const deps = dependentPkgJSON[type];
+    if (!deps) continue;
+    if (deps[dependencyName]) {
+      dependencyVersionRange.depTypes.push(type);
+      // We'll just override this each time, *hypothetically* it *should* be the same...
+      dependencyVersionRange.versionRange = deps[dependencyName];
+    }
+  }
+  return dependencyVersionRange;
+}
diff --git a/packages/assemble-release-plan/src/flatten-releases.ts b/packages/assemble-release-plan/src/flatten-releases.ts
new file mode 100644
index 0000000..2ec0afe
--- /dev/null
+++ b/packages/assemble-release-plan/src/flatten-releases.ts
@@ -0,0 +1,69 @@
+// This function takes in changesets and returns one release per
+// package listed in the changesets
+
+import {
+  NewChangeset,
+  ComprehensiveRelease,
+  Workspace,
+  VersionType
+} from "@changesets/types";
+import semver from "semver";
+
+export default function flattenReleases(
+  changesets: NewChangeset[],
+  workspaces: Workspace[]
+): ComprehensiveRelease[] {
+  let releases: Map<
+    string,
+    {
+      name: string;
+      type: VersionType;
+      oldVersion: string;
+      changesets: string[];
+    }
+  > = new Map();
+
+  changesets.forEach(changeset => {
+    changeset.releases.forEach(({ name, type }) => {
+      let release = releases.get(name);
+      let ws = workspaces.find(ws => ws.name === name);
+      if (!ws) {
+        throw new Error(`Could not find package information for ${name}`);
+      }
+      let { config } = ws;
+      if (!release) {
+        release = {
+          name,
+          type,
+          oldVersion: config.version,
+          changesets: [changeset.id]
+        };
+      } else {
+        // If the type was already major, we never need to update it
+        if (release.type === "minor" && type === "major") {
+          release.type = type;
+        } else if (
+          release.type === "patch" &&
+          (type === "major" || type === "minor")
+        ) {
+          release.type = type;
+        }
+        // Check whether the bumpType will change
+        // If the bumpType has changed recalc newVersion
+        // push new changeset to releases
+        release.changesets.push(changeset.id);
+      }
+
+      releases.set(name, release);
+    });
+  });
+
+  return [...releases.values()].map(release => {
+    // @ts-ignore by this point we're comfy semver.inc will return a non-null value so just casting it
+    let newVersion: string = semver.inc(release.oldVersion, release.type);
+    return {
+      ...release,
+      newVersion
+    };
+  });
+}
diff --git a/packages/assemble-release-plan/src/index.ts b/packages/assemble-release-plan/src/index.ts
new file mode 100644
index 0000000..1547370
--- /dev/null
+++ b/packages/assemble-release-plan/src/index.ts
@@ -0,0 +1,40 @@
+import {
+  ReleasePlan,
+  Workspace,
+  Config,
+  NewChangeset
+} from "@changesets/types";
+import determineDependents from "./determine-dependents";
+import flattenReleases from "./flatten-releases";
+import applyLinks from "./apply-links";
+
+function assembleReleasePlan(
+  changesets: NewChangeset[],
+  workspaces: Workspace[],
+  dependentsGraph: Map<string, string[]>,
+  config: Config
+): ReleasePlan {
+  // releases is, at this point a list of all packages we are going to releases,
+  // flattened down to one release per package, having a reference back to their
+  // changesets, and with a calculated new versions
+  let releases = flattenReleases(changesets, workspaces);
+
+  let releaseObjectValidated = false;
+  while (releaseObjectValidated === false) {
+    // The map passed in to determineDependents will be mutated
+    let dependentAdded = determineDependents(
+      releases,
+      workspaces || [],
+      dependentsGraph
+    );
+
+    // The map passed in to determineDependents will be mutated
+    let linksUpdated = applyLinks(releases, config.linked);
+
+    releaseObjectValidated = !linksUpdated && !dependentAdded;
+  }
+
+  return { changesets, releases };
+}
+
+export default assembleReleasePlan;
diff --git a/packages/get-dependents-graph/package.json b/packages/get-dependents-graph/package.json
new file mode 100644
index 0000000..1cf59dd
--- /dev/null
+++ b/packages/get-dependents-graph/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "get-dependents-graph",
+  "version": "0.0.0",
+  "description": "Get the graph of dependents in a mono-repo",
+  "main": "dist/get-dependents-graph.cjs.js",
+  "module": "dist/get-dependents-graph.esm.js",
+  "license": "MIT",
+  "repository": "https://github.com/changesets/changesets/tree/master/packages/get-dependents-graph",
+  "dependencies": {
+    "get-workspaces": "^0.4.0",
+    "@changesets/types": "^0.0.0",
+    "fs-extra": "^7.0.1",
+    "semver": "^5.4.1",
+    "chalk": "^2.1.0"
+  }
+}
\ No newline at end of file
diff --git a/packages/get-dependents-graph/src/get-dependency-graph.ts b/packages/get-dependents-graph/src/get-dependency-graph.ts
new file mode 100644
index 0000000..44aef6b
--- /dev/null
+++ b/packages/get-dependents-graph/src/get-dependency-graph.ts
@@ -0,0 +1,98 @@
+// This is a modified version of the graph-getting in bolt
+
+import fs from "fs-extra";
+import path from "path";
+import semver from "semver";
+import chalk from "chalk";
+import { PackageJSON, Workspace } from "@changesets/types";
+// @ts-ignore
+
+const DEPENDENCY_TYPES = [
+  "dependencies",
+  "devDependencies",
+  "peerDependencies",
+  "optionalDependencies"
+] as const;
+
+const getAllDependencies = (config: PackageJSON) => {
+  const allDependencies = new Map();
+
+  for (const type of DEPENDENCY_TYPES) {
+    const deps = config[type];
+    if (!deps) continue;
+
+    for (const name of Object.keys(deps)) {
+      allDependencies.set(name, deps[name]);
+    }
+  }
+
+  return allDependencies;
+};
+
+export default async function getDependencyGraph(
+  packages: Array<Workspace>,
+  cwd: string
+): Promise<{
+  graph: Map<string, { pkg: Workspace; dependencies: Array<string> }>;
+  valid: boolean;
+}> {
+  const graph = new Map<
+    string,
+    { pkg: Workspace; dependencies: Array<string> }
+  >();
+  let valid = true;
+
+  const pkgRoot = await fs
+    .readFile(path.resolve(cwd, "package.json"), "utf8")
+    .then(JSON.parse);
+
+  const pkgRootConfigged = {
+    config: pkgRoot,
+    name: pkgRoot.name,
+    dir: path.resolve(cwd)
+  };
+
+  const packagesByName: { [key: string]: Workspace } = {
+    [pkgRoot.name]: pkgRootConfigged
+  };
+
+  const queue = [pkgRootConfigged];
+
+  for (const pkg of packages) {
+    queue.push(pkg);
+    packagesByName[pkg.name] = pkg;
+  }
+
+  for (const pkg of queue) {
+    const { name } = pkg.config;
+    const dependencies = [];
+    const allDependencies = getAllDependencies(pkg.config);
+
+    for (const [depName, depVersion] of allDependencies) {
+      const match = packagesByName[depName];
+      if (!match) continue;
+
+      const expected = match.config.version;
+
+      // Workspace dependencies only need to semver satisfy, not '==='
+      if (!semver.satisfies(expected, depVersion)) {
+        valid = false;
+        console.error(
+          `Package ${chalk.cyan(
+            `"${name}"`
+          )} must depend on the current version of ${chalk.cyan(
+            `"${depName}"`
+          )}: ${chalk.green(`"${expected}"`)} vs ${chalk.red(
+            `"${depVersion}"`
+          )}`
+        );
+        continue;
+      }
+
+      dependencies.push(depName);
+    }
+
+    graph.set(name, { pkg, dependencies });
+  }
+  return { graph, valid };
+}
diff --git a/packages/get-dependents-graph/src/index.ts b/packages/get-dependents-graph/src/index.ts
new file mode 100644
index 0000000..d44a16e
--- /dev/null
+++ b/packages/get-dependents-graph/src/index.ts
@@ -0,0 +1,55 @@
+import getWorkspaces from "get-workspaces";
+import { Workspace } from "@changesets/types";
+import getDependencyGraph from "./get-dependency-graph";
+
+export default async function getDependentsGraph({ cwd }: { cwd: string }) {
+  const packages = await getWorkspaces({
+    cwd,
+    tools: ["yarn", "bolt", "root"]
+  });
+
+  if (!packages) {
+    throw new Error("could not get packages");
+  }
+  const graph: Map<
+    string,
+    { pkg: Workspace; dependents: string[] }
+  > = new Map();
+
+  const { graph: dependencyGraph } = await getDependencyGraph(packages, cwd);
+
+  const dependentsLookup: {
+    [key: string]: { pkg: Workspace; dependents: Array<string> };
+  } = {};
+
+  packages.forEach(pkg => {
+    dependentsLookup[pkg.config.name] = {
+      pkg,
+      dependents: []
+    };
+  });
+
+  packages.forEach(pkg => {
+    const dependent = pkg.config.name;
+    const valFromDependencyGraph = dependencyGraph.get(dependent);
+    if (valFromDependencyGraph) {
+      const dependencies = valFromDependencyGraph.dependencies;
+
+      dependencies.forEach(dependency => {
+        dependentsLookup[dependency].dependents.push(dependent);
+      });
+    }
+  });
+
+  Object.keys(dependentsLookup).forEach(key => {
+    graph.set(key, dependentsLookup[key]);
+  });
+
+  const simplifiedDependentsGraph: Map<string, string[]> = new Map();
+
+  graph.forEach((pkgInfo, pkgName) => {
+    simplifiedDependentsGraph.set(pkgName, pkgInfo.dependents);
+  });
+
+  return simplifiedDependentsGraph;
+}
diff --git a/packages/git/package.json b/packages/git/package.json
index 19cc50e..2aaf9d4 100644
--- a/packages/git/package.json
+++ b/packages/git/package.json
@@ -14,4 +14,4 @@
   "devDependencies": {
     "jest-fixtures": "^0.5.0"
   }
-}
\ No newline at end of file
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 8897ae9..081ac21 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -54,7 +54,7 @@ export type PackageJSON = {
 export type Linked = Array<Array<string>>;
 
 export type Config = {
-  linked: Linked;
+  linked?: Linked;
 };
 
 export type Workspace = { config: PackageJSON; name: string; dir: string };


Test patch:
diff --git a/packages/assemble-release-plan/src/index.test.ts b/packages/assemble-release-plan/src/index.test.ts
new file mode 100644
index 0000000..7fb9a80
--- /dev/null
+++ b/packages/assemble-release-plan/src/index.test.ts
@@ -0,0 +1,457 @@
+import assembleReleasePlan from "./";
+import FakeFullState from "./test-utils";
+
+describe("assemble-release-plan", () => {
+  let setup: FakeFullState;
+  beforeEach(() => {
+    setup = new FakeFullState();
+
+    setup.addWorkspace("pkg-b", "1.0.0");
+    setup.addWorkspace("pkg-c", "1.0.0");
+    setup.addWorkspace("pkg-d", "1.0.0");
+  });
+
+  it("should assemble release plan for basic setup", () => {
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(1);
+    expect(releases[0]).toEqual({
+      name: "pkg-a",
+      type: "patch",
+      newVersion: "1.0.1",
+      oldVersion: "1.0.0",
+      changesets: ["strange-words-combine"]
+    });
+  });
+  it("should assemble release plan with multiple packages", () => {
+    setup.addChangeset({
+      id: "big-cats-delight",
+      releases: [
+        { name: "pkg-b", type: "patch" },
+        { name: "pkg-c", type: "patch" },
+        { name: "pkg-d", type: "major" }
+      ]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(4);
+    expect(releases[0].name).toBe("pkg-a");
+    expect(releases[0].newVersion).toBe("1.0.1");
+    expect(releases[1].name).toBe("pkg-b");
+    expect(releases[1].newVersion).toBe("1.0.1");
+    expect(releases[2].name).toBe("pkg-c");
+    expect(releases[2].newVersion).toBe("1.0.1");
+    expect(releases[3].name).toBe("pkg-d");
+    expect(releases[3].newVersion).toBe("2.0.0");
+  });
+  it("should handle two changesets for a package", () => {
+    setup.addChangeset({
+      id: "big-cats-delight",
+      releases: [{ name: "pkg-a", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toEqual(1);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].type).toEqual("major");
+    expect(releases[0].newVersion).toEqual("2.0.0");
+  });
+  it("should assemble release plan with dependents", () => {
+    setup.updateDependency("pkg-b", "pkg-a", "^1.0.0");
+    setup.addChangeset({
+      id: "big-cats-delight",
+      releases: [{ name: "pkg-a", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toEqual(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+    expect(releases[1].changesets).toEqual([]);
+  });
+  it("should assemble release plan for linked packages", () => {
+    setup.addChangeset({
+      id: "just-some-umbrellas",
+      releases: [{ name: "pkg-b", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      { linked: [["pkg-a", "pkg-b"]] }
+    );
+
+    expect(releases.length).toEqual(2);
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+  it("should assemble release plan where a link causes a dependency to need changing which causes a second link to update", () => {
+    /*
+      Expected events:
+      - dependencies are checked, nothing leaves semver, nothing changes
+      - linked are checked, pkg-a is aligned with pkg-b
+      - depencencies are checked, pkg-c is now outside its dependency on pkg-a, and is given a patch
+      - linked is checked, pkg-c is aligned with pkg-d
+    */
+    setup.addChangeset({
+      id: "just-some-umbrellas",
+      releases: [{ name: "pkg-b", type: "major" }]
+    });
+    setup.addChangeset({
+      id: "totally-average-verbiage",
+      releases: [{ name: "pkg-d", type: "minor" }]
+    });
+
+    setup.updateDependency("pkg-c", "pkg-a", "^1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      { linked: [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]] }
+    );
+
+    expect(releases.length).toEqual(4);
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+    expect(releases[2].newVersion).toEqual("1.1.0");
+    expect(releases[3].newVersion).toEqual("1.1.0");
+  });
+  it("should return an empty release array when no chnages will occur", () => {
+    let { releases } = assembleReleasePlan(
+      [],
+      setup.workspaces,
+      setup.dependentsGraph,
+      { linked: [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]] }
+    );
+
+    expect(releases).toEqual([]);
+  });
+  it("should update multiple dependencies of a single package", () => {
+    setup.updateDependency("pkg-b", "pkg-a", "1.0.0");
+    setup.updateDependency("pkg-c", "pkg-a", "1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      { linked: [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]] }
+    );
+
+    expect(releases.length).toEqual(3);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+    expect(releases[2].name).toEqual("pkg-c");
+    expect(releases[2].newVersion).toEqual("1.0.1");
+  });
+  it("should update a second dependent based on updating a first dependant", () => {
+    setup.updateDependency("pkg-b", "pkg-a", "1.0.0");
+    setup.updateDependency("pkg-c", "pkg-b", "1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      { linked: [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]] }
+    );
+
+    expect(releases.length).toEqual(3);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+    expect(releases[2].name).toEqual("pkg-c");
+    expect(releases[2].newVersion).toEqual("1.0.1");
+  });
+  it("should update a peerDep by a major bump", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "~1.0.0");
+    setup.addChangeset({
+      id: "nonsense-words-combine",
+      releases: [{ name: "pkg-a", type: "minor" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toEqual(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.1.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+});
+
+describe("version update thoroughness", () => {
+  let setup: FakeFullState;
+  beforeEach(() => {
+    setup = new FakeFullState();
+
+    setup.addWorkspace("pkg-b", "1.0.0");
+    setup.addWorkspace("pkg-c", "1.0.0");
+    setup.addWorkspace("pkg-d", "1.0.0");
+    setup.updateDependency("pkg-b", "pkg-a", "1.0.0");
+    setup.updateDependency("pkg-c", "pkg-a", "~1.0.0");
+    setup.updateDependency("pkg-d", "pkg-a", "^1.0.0");
+  });
+
+  it("should patch a single pinned dependent", () => {
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+    expect(releases.length).toEqual(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+  });
+  it("should path a pinned and tilde dependents when minor versioning", () => {
+    setup.addChangeset({
+      id: "stuff-and-nonsense",
+      releases: [{ name: "pkg-a", type: "minor" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toEqual(3);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.1.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+    expect(releases[2].name).toEqual("pkg-c");
+    expect(releases[2].newVersion).toEqual("1.0.1");
+  });
+  it("should patch pinned, tilde and caret dependents when a major versioning", () => {
+    setup.addChangeset({
+      id: "stuff-and-nonsense",
+      releases: [{ name: "pkg-a", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toEqual(4);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+    expect(releases[2].name).toEqual("pkg-c");
+    expect(releases[2].newVersion).toEqual("1.0.1");
+    expect(releases[3].name).toEqual("pkg-d");
+    expect(releases[3].newVersion).toEqual("1.0.1");
+  });
+});
+
+describe("bumping peerDeps", () => {
+  let setup: FakeFullState;
+  beforeEach(() => {
+    setup = new FakeFullState();
+    setup.addWorkspace("pkg-b", "1.0.0");
+  });
+
+  it("should patch a pinned peerDep", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("1.0.1");
+  });
+  it("should not bump the dependent when bumping a tilde peerDep by a patch", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "~1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(1);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+  });
+  it("should major bump dependent when bumping a tilde peerDep by minor", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "~1.0.0");
+    setup.addChangeset({
+      id: "anyway-the-windblows",
+      releases: [{ name: "pkg-a", type: "minor" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.1.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+  it("should major bump dependent when bumping a tilde peerDep by major", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "~1.0.0");
+    setup.addChangeset({
+      id: "anyway-the-windblows",
+      releases: [{ name: "pkg-a", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+  it("should not bump dependent when bumping caret peerDep by patch", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "^1.0.0");
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(1);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.0.1");
+  });
+  it("should major bump dependent when bumping caret peerDep by minor", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "^1.0.0");
+    setup.addChangeset({
+      id: "anyway-the-windblows",
+      releases: [{ name: "pkg-a", type: "minor" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("1.1.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+  it("should major bump dependent when bumping caret peerDep by major", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "^1.0.0");
+    setup.addChangeset({
+      id: "anyway-the-windblows",
+      releases: [{ name: "pkg-a", type: "major" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(2);
+    expect(releases[0].name).toEqual("pkg-a");
+    expect(releases[0].newVersion).toEqual("2.0.0");
+    expect(releases[1].name).toEqual("pkg-b");
+    expect(releases[1].newVersion).toEqual("2.0.0");
+  });
+  it("should patch bump transitive dep that is only affected by peerDep bump", () => {
+    setup.updatePeerDep("pkg-b", "pkg-a", "^1.0.0");
+    setup.addWorkspace("pkg-c", "1.0.0");
+    setup.updateDependency("pkg-c", "pkg-b", "^1.0.0");
+    setup.addChangeset({
+      id: "anyway-the-windblows",
+      releases: [{ name: "pkg-a", type: "minor" }]
+    });
+
+    let { releases } = assembleReleasePlan(
+      setup.changesets,
+      setup.workspaces,
+      setup.dependentsGraph,
+      {}
+    );
+
+    expect(releases.length).toBe(3);
+    expect(releases[0]).toMatchObject({
+      name: "pkg-a",
+      newVersion: "1.1.0"
+    });
+    expect(releases[1]).toMatchObject({
+      name: "pkg-b",
+      newVersion: "2.0.0"
+    });
+    expect(releases[2]).toMatchObject({
+      name: "pkg-c",
+      newVersion: "1.0.1"
+    });
+  });
+});
+
+/*
+    Bumping peerDeps is a tricky issue, so we are testing every single combination here so that
+    we can have absolute certainty when changing anything to do with them.
+    In general the rule for bumping peerDeps is that:
+      * All MINOR or MAJOR peerDep bumps must MAJOR bump all dependents - regardless of ranges
+      * Otherwise - normal patching rules apply
+ */
diff --git a/packages/assemble-release-plan/src/test-utils.ts b/packages/assemble-release-plan/src/test-utils.ts
new file mode 100644
index 0000000..b73eb8f
--- /dev/null
+++ b/packages/assemble-release-plan/src/test-utils.ts
@@ -0,0 +1,168 @@
+import {
+  NewChangeset,
+  Workspace,
+  Config,
+  Release,
+  VersionType
+} from "@changesets/types";
+
+function getFakeData(
+  data: {
+    changesets?: NewChangeset[];
+    workspaces?: Workspace[];
+    dependentsGraph?: Map<string, string[]>;
+    config?: Config;
+  } = {}
+) {
+  let changesets = data.changesets || [];
+  let workspaces = data.workspaces || [];
+  let dependentsGraph = data.dependentsGraph || new Map();
+  let config = data.config || {};
+
+  return {
+    changesets,
+    workspaces,
+    dependentsGraph,
+    config
+  };
+}
+
+function getWorkspace(
+  name: string = "pkg-a",
+  version: string = "1.0.0"
+): Workspace {
+  return {
+    name,
+    config: {
+      name,
+      version
+    },
+    dir: "this-shouldn't-matter"
+  };
+}
+
+function getChangeset(
+  data: {
+    id?: string;
+    summary?: string;
+    releases?: Array<Release>;
+  } = {}
+): NewChangeset {
+  let id = data.id || "strange-words-combine";
+  let summary = data.summary || "base summary whatever";
+  let releases = data.releases || [];
+  return {
+    id,
+    summary,
+    releases
+  };
+}
+
+function getRelease(data: { name?: string; type?: VersionType } = {}): Release {
+  let name = data.name || "pkg-a";
+  let type = data.type || "patch";
+
+  return { name, type };
+}
+
+function getDependentsGraph(
+  thing: [[string, Array<String>]] = [["pkg-a", []]]
+) {
+  let map = new Map();
+  for (let acity of thing) {
+    let [name, depenents] = acity;
+    map.set(name, depenents);
+  }
+  return map;
+}
+
+function getSimpleSetup(
+  custom: {
+    workspaces?: Workspace[];
+    changesets?: NewChangeset[];
+    dependentsGraph?: Map<string, string[]>;
+  } = {}
+) {
+  return getFakeData({
+    workspaces: [getWorkspace()],
+    changesets: [getChangeset({ releases: [getRelease()] })],
+    dependentsGraph: getDependentsGraph(),
+    ...custom
+  });
+}
+
+class FakeFullState {
+  workspaces: Workspace[];
+  changesets: NewChangeset[];
+  dependentsGraph: Map<string, string[]>;
+
+  constructor(custom?: {
+    workspaces?: Workspace[];
+    changesets?: NewChangeset[];
+    dependentsGraph?: Map<string, string[]>;
+  }) {
+    let { workspaces, changesets, dependentsGraph } = getSimpleSetup(custom);
+    this.workspaces = workspaces;
+    this.changesets = changesets;
+    this.dependentsGraph = dependentsGraph;
+  }
+
+  addChangeset(
+    data: {
+      id?: string;
+      summary?: string;
+      releases?: Array<Release>;
+    } = {}
+  ) {
+    let changeset = getChangeset(data);
+    if (this.changesets.find(c => c.id === changeset.id)) {
+      throw new Error(
+        `tried to add a second changeset with same id: ${changeset.id}`
+      );
+    }
+    this.changesets.push(changeset);
+  }
+
+  updateDependency(pkgA: string, pkgB: string, version: string) {
+    let ws = this.workspaces.find(a => a.name === pkgA);
+    if (!ws) throw new Error("no ws");
+    if (!ws.config.dependencies) ws.config.dependencies = {};
+    ws.config.dependencies[pkgB] = version;
+
+    let depList = this.dependentsGraph.get(pkgB);
+    if (!depList) throw new Error("could not add dependency");
+    this.dependentsGraph.set(pkgB, [...depList, pkgA]);
+  }
+  updatePeerDep(pkgA: string, pkgB: string, version: string) {
+    let ws = this.workspaces.find(a => a.name === pkgA);
+    if (!ws) throw new Error("no ws");
+    if (!ws.config.peerDependencies) ws.config.peerDependencies = {};
+    ws.config.peerDependencies[pkgB] = version;
+
+    let depList = this.dependentsGraph.get(pkgB);
+    if (!depList) throw new Error("could not add dependency");
+    this.dependentsGraph.set(pkgB, [...depList, pkgA]);
+  }
+
+  addWorkspace(name: string, version: string) {
+    let ws = getWorkspace(name, version);
+    if (this.workspaces.find(c => c.name === ws.name)) {
+      throw new Error(
+        `tried to add a second workspace with same name': ${ws.name}`
+      );
+    }
+    this.workspaces.push(ws);
+    this.dependentsGraph.set(name, []);
+  }
+  updateWorkspace(name: string, version: string) {
+    let ws = this.workspaces.find(c => c.name === name);
+    if (!ws) {
+      throw new Error(
+        `could not update workspace ${name} because it doesn't exist - try addWorskpace`
+      );
+    }
+    ws.config.version = version;
+  }
+}
+
+export default FakeFullState;