Repository: atlassian/changesets (ts)

Issue:
Feature Request: add `--empty` flag to create an empty changeset
G'day.

We're looking at adding enforcement to PRs that will mean that all PRs must have a changeset. To make this cleaner/more consistent, it would be good if we could create empty changesets to signify "I have *deliberately chosen* to not release a package in this PR.

Happy to make a PR for v1.

Pull request description:
Flag to create empty changeset
Added the `--empty` flag to the add command, creates an empty changeset:
```
---
---
```
`yarn changeset add --empty` and `yarn changeset --empty`

Needed to change the regex for parsing changeset files because it assumed existing releases and summary. Also added tests for both creating and reading an empty changeset.

Resolves #145 

Patch:
diff --git a/.changeset/thick-forks-film.md b/.changeset/thick-forks-film.md
new file mode 100644
index 0000000..d91ea3b
--- /dev/null
+++ b/.changeset/thick-forks-film.md
@@ -0,0 +1,7 @@
+---
+"@changesets/cli": minor
+"@changesets/parse": minor
+"@changesets/read": minor
+---
+
+Added --empty flag to the add command for empty changeset files. New tests for adding, writing, parsing, and reading empty changesets.
diff --git a/fixtures/empty-changeset/.changeset/empty-changeset.md b/fixtures/empty-changeset/.changeset/empty-changeset.md
new file mode 100644
index 0000000..a845151
--- /dev/null
+++ b/fixtures/empty-changeset/.changeset/empty-changeset.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 3d3b557..cc183e7 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -95,13 +95,13 @@ This command sets up the `.changeset` folder. It generates a readme and a config
 ### add
 
 ```
-changeset
+changeset [--empty]
 ```
 
 or
 
 ```
-changeset add
+changeset add [--empty]
 ```
 
 This command will ask you a series of questions, first about what packages you want to release, then what semver bump type for each package, then it will ask for a summary of the entire changeset. At the final step it will show the changeset it will generate, and confirm that you want to add it.
@@ -120,6 +120,16 @@ A description of the major changes.
 
 If you want to modify this file after it's generated, that's completely fine or if you want to write changeset files yourself, that's also fine.
 
+- `--empty` - allows you to create an empty changeset if no packages are being bumped, usually only required if you have CI that blocks merges without a changeset.
+
+A changeset created with the `empty` flag would look like this:
+
+```md
+---
+---
+
+```
+
 If you set the `commit` option in the config, the command will add the updated changeset files and then commit them.
 
 ### version
@@ -144,7 +154,7 @@ changeset publish [--otp={token}]
 
 Publishes to NPM repo, and creates git tags. Because this command assumes that last commit is the release commit you should not commit any changes between calling `version` and `publish`. These commands are separate to enable you to check if release commit is acurate.
 
-`--otp={token}` - allows you to provide an npm one-time password if you have auth and writes enabled on npm. The CLI also prompts for the OTP if it's not provided with the `--otp` option.
+- `--otp={token}` - allows you to provide an npm one-time password if you have auth and writes enabled on npm. The CLI also prompts for the OTP if it's not provided with the `--otp` option.
 
 **NOTE:** You will still need to push your changes back to master after this
 
@@ -160,11 +170,11 @@ status [--verbose] [--output={filePath}] [--since-master]
 
 The status command provides information about the changesets that currently exist. If there are no changesets present, it exits with an error status code.
 
-Use verbose if you want to know the new versions, and get a link to the relevant changeset summary.
+- `--verbose` - use if you want to know the new versions, and get a link to the relevant changeset summary.
 
-You can use `output` to write the json object of the status out, for consumption by other tools, such as CI.
+- `--output` - allows you to write the json object of the status out, for consumption by other tools, such as CI.
 
-You can use the `since-master flag to only display information about changesets since the master branch. While this can be
+- `--since-master` - to only display information about changesets since the master branch. While this can be
 used to add a CI check for changesets, we recommend not doing this. We instead recommend using the [changeset bot](https://github.com/apps/changeset-bot)
 to detect pull requests missing changesets, as not all pull requests need one.
 
diff --git a/packages/cli/src/commands/add/index.ts b/packages/cli/src/commands/add/index.ts
index 5eac2f4..61b5381 100644
--- a/packages/cli/src/commands/add/index.ts
+++ b/packages/cli/src/commands/add/index.ts
@@ -12,7 +12,11 @@ import createChangeset from "./createChangeset";
 import getChangesetBase from "../../utils/getChangesetBase";
 import printConfirmationMessage from "./messages";
 
-export default async function add(cwd: string, config: Config) {
+export default async function add(
+  cwd: string,
+  { empty }: { empty?: boolean },
+  config: Config
+) {
   const changesetBase = await getChangesetBase(cwd);
 
   if (!fs.existsSync(changesetBase)) {
@@ -26,16 +30,23 @@ export default async function add(cwd: string, config: Config) {
     return;
   }
 
-  const changedPackages = await git.getChangedPackagesSinceMaster(cwd);
-  const changePackagesName = changedPackages
-    .filter(a => a)
-    .map(pkg => pkg.name);
-  const newChangeset = await createChangeset(changePackagesName, cwd);
-  printConfirmationMessage(newChangeset);
+  let newChangeset, confirmChangeset;
+  if (empty) {
+    newChangeset = {
+      releases: [],
+      summary: ``
+    };
+    confirmChangeset = true;
+  } else {
+    const changedPackages = await git.getChangedPackagesSinceMaster(cwd);
+    const changePackagesName = changedPackages
+      .filter(a => a)
+      .map(pkg => pkg.name);
+    newChangeset = await createChangeset(changePackagesName, cwd);
+    printConfirmationMessage(newChangeset);
 
-  const confirmChangeset = await cli.askConfirm(
-    "Is this your desired changeset?"
-  );
+    confirmChangeset = await cli.askConfirm("Is this your desired changeset?");
+  }
 
   if (confirmChangeset) {
     const changesetID = await writeChangeset(newChangeset, cwd);
@@ -45,9 +56,15 @@ export default async function add(cwd: string, config: Config) {
         `CHANGESET: ${changesetID}. ${newChangeset.summary}`,
         cwd
       );
-      logger.log(chalk.green("Changeset added and committed"));
+      logger.log(
+        chalk.green(`${empty ? "Empty " : ""}Changeset added and committed`)
+      );
     } else {
-      logger.log(chalk.green("Changeset added! - you can now commit it\n"));
+      logger.log(
+        chalk.green(
+          `${empty ? "Empty " : ""}Changeset added! - you can now commit it\n`
+        )
+      );
     }
 
     let hasMajorChange = [...newChangeset.releases].find(
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 783b8eb..467267e 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -21,7 +21,7 @@ const { input, flags } = meow(
     $ changesets [command]
   Commands
     init
-    add
+    add [--empty]
     version
     publish [--otp=code]
     status [--since-master --verbose --output=JSON_FILE.json]
@@ -42,6 +42,9 @@ const { input, flags } = meow(
       otp: {
         type: "string",
         default: undefined
+      },
+      empty: {
+        type: "boolean"
       }
     }
   }
@@ -91,14 +94,15 @@ const cwd = process.cwd();
   }
 
   if (input.length < 1) {
+    const { empty }: CliOptions = flags;
     // @ts-ignore if this is undefined, we have already exited
-    await add(cwd, config);
+    await add(cwd, { empty }, config);
   } else if (input.length > 1) {
     logger.error(
       "Too many arguments passed to changesets - we only accept the command name as an argument"
     );
   } else {
-    const { sinceMaster, verbose, output, otp }: CliOptions = flags;
+    const { sinceMaster, verbose, output, otp, empty }: CliOptions = flags;
     const deadFlags = ["updateChangelog", "isPublic", "skipCI", "commit"];
 
     deadFlags.forEach(flag => {
@@ -120,7 +124,7 @@ const cwd = process.cwd();
     switch (input[0]) {
       case "add": {
         // @ts-ignore if this is undefined, we have already exited
-        await add(cwd, config);
+        await add(cwd, { empty }, config);
         return;
       }
       case "version": {
diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts
index cfa2597..f67b714 100644
--- a/packages/cli/src/types.ts
+++ b/packages/cli/src/types.ts
@@ -6,6 +6,7 @@ export type CliOptions = {
   verbose?: boolean;
   output?: string;
   otp?: string;
+  empty?: boolean;
 };
 
 export type CommandOptions = CliOptions & {
diff --git a/packages/parse/src/index.ts b/packages/parse/src/index.ts
index 26c27d9..3710de2 100644
--- a/packages/parse/src/index.ts
+++ b/packages/parse/src/index.ts
@@ -1,7 +1,7 @@
 import yaml from "js-yaml";
 import { Release, VersionType } from "@changesets/types";
 
-const mdRegex = /\s*---([^]+?)\n\s*---\n([^]+)/;
+const mdRegex = /\s*---([^]*?)\n\s*---\n([^]*)/;
 
 export default function parseChangesetFile(
   contents: string
@@ -23,10 +23,14 @@ export default function parseChangesetFile(
     const yamlStuff: { [key: string]: VersionType } = yaml.safeLoad(
       roughReleases
     );
-    releases = Object.entries(yamlStuff).map(([name, type]) => ({
-      name,
-      type
-    }));
+    if (yamlStuff) {
+      releases = Object.entries(yamlStuff).map(([name, type]) => ({
+        name,
+        type
+      }));
+    } else {
+      releases = [];
+    }
   } catch (e) {
     throw new Error(
       `could not parse changeset - invalid frontmatter: ${contents}`


Test patch:
diff --git a/packages/cli/src/commands/add/__tests__/add.ts b/packages/cli/src/commands/add/__tests__/add.ts
index 1f79e5e..e7bab3d 100644
--- a/packages/cli/src/commands/add/__tests__/add.ts
+++ b/packages/cli/src/commands/add/__tests__/add.ts
@@ -70,8 +70,9 @@ describe("Changesets", () => {
 
   it("should generate changeset to patch a single package", async () => {
     const cwd = await copyFixtureIntoTempDir(__dirname, "simple-project");
+
     mockUserResponses({ releases: { "pkg-a": "patch" } });
-    await addChangeset(cwd, defaultConfig);
+    await addChangeset(cwd, { empty: false }, defaultConfig);
 
     const expectedChangeset = {
       summary: "summary message mock",
@@ -88,7 +89,24 @@ describe("Changesets", () => {
     );
 
     mockUserResponses({ releases: { "pkg-a": "patch" } });
-    await addChangeset(cwd, { ...defaultConfig, commit: true });
+    await addChangeset(
+      cwd,
+      { empty: false },
+      { ...defaultConfig, commit: true }
+    );
     expect(git.add).toHaveBeenCalledTimes(1);
   });
+  it("should create empty changeset when empty flag is passed in", async () => {
+    const cwd = await copyFixtureIntoTempDir(__dirname, "simple-project");
+
+    await addChangeset(cwd, { empty: true }, defaultConfig);
+
+    const expectedChangeset = {
+      releases: [],
+      summary: ""
+    };
+    // @ts-ignore
+    const call = writeChangeset.mock.calls[0][0];
+    expect(call).toEqual(expectedChangeset);
+  });
 });
diff --git a/packages/cli/src/commands/add/__tests__/writeChangeset.ts b/packages/cli/src/commands/add/__tests__/writeChangeset.ts
index b653118..aacb257 100644
--- a/packages/cli/src/commands/add/__tests__/writeChangeset.ts
+++ b/packages/cli/src/commands/add/__tests__/writeChangeset.ts
@@ -15,6 +15,11 @@ const simpleChangeset: { summary: string; releases: Release[] } = {
   releases: [{ name: "pkg-a", type: "minor" }]
 };
 
+const emptyChangeset: { summary: string; releases: Release[] } = {
+  summary: "",
+  releases: []
+};
+
 describe("simple project", () => {
   let cwd: string;
 
@@ -33,9 +38,20 @@ describe("simple project", () => {
     await writeChangeset(simpleChangeset, cwd);
 
     const mdPath = path.join(cwd, ".changeset", `${changesetID}.md`);
-
     const mdContent = await fs.readFile(mdPath, "utf-8");
 
     expect(parse(mdContent)).toEqual(simpleChangeset);
   });
+  it("should write an empty changeset", async () => {
+    const changesetID = "ascii";
+    // @ts-ignore
+    humanId.mockReturnValueOnce(changesetID);
+
+    await writeChangeset(emptyChangeset, cwd);
+
+    const mdPath = path.join(cwd, ".changeset", `${changesetID}.md`);
+    const mdContent = await fs.readFile(mdPath, "utf-8");
+
+    expect(parse(mdContent)).toEqual(emptyChangeset);
+  });
 });
diff --git a/packages/parse/src/index.test.ts b/packages/parse/src/index.test.ts
index 45b23cc..4f57fbd 100644
--- a/packages/parse/src/index.test.ts
+++ b/packages/parse/src/index.test.ts
@@ -4,21 +4,21 @@ import parse from "./";
 
 describe("parsing a changeset", () => {
   it("should parse a changeset", () => {
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "cool-package": minor
     ---
 
     Nice simple summary
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [{ name: "cool-package", type: "minor" }],
       summary: "Nice simple summary"
     });
   });
   it("should parse major, minor, and patch changes", () => {
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "cool-package": minor
     "cool-package2": major
     "cool-package3": patch
@@ -27,7 +27,7 @@ describe("parsing a changeset", () => {
     Nice simple summary
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [
         { name: "cool-package", type: "minor" },
@@ -38,27 +38,27 @@ describe("parsing a changeset", () => {
     });
   });
   it("should parse a changeset with a scoped package", () => {
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "@cool/package": minor
     ---
 
     Nice simple summary
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [{ name: "@cool/package", type: "minor" }],
       summary: "Nice simple summary"
     });
   });
   it("should parse a changeset with multiline summary", () => {
-    let expectedSummary = outdent`Let us go then you and I,
+    const expectedSummary = outdent`Let us go then you and I,
     When the evening is spread out against the sky
     Like a patient, etherized upon a table.
 
     - The Lovesong of J Alfred Prufrock, T. S. Eliot`;
 
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "cool-package": minor
     ---
 
@@ -69,32 +69,32 @@ describe("parsing a changeset", () => {
     - The Lovesong of J Alfred Prufrock, T. S. Eliot
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [{ name: "cool-package", type: "minor" }],
       summary: expectedSummary
     });
   });
   it("should parse a changeset with multiple packages and multiline summary", () => {
-    let expectedSummary = outdent`Let us go then you and I,
-        When the evening is spread out against the sky
-        Like a patient, etherized upon a table.
-    
-        - The Lovesong of J Alfred Prufrock, T. S. Eliot`;
-
-    let changesetMd = outdent`---
-        "cool-package": minor
-        "best-package": patch
-        ---
-    
-        Let us go then you and I,
-        When the evening is spread out against the sky
-        Like a patient, etherized upon a table.
-    
-        - The Lovesong of J Alfred Prufrock, T. S. Eliot
-        `;
-
-    let changeset = parse(changesetMd);
+    const expectedSummary = outdent`Let us go then you and I,
+    When the evening is spread out against the sky
+    Like a patient, etherized upon a table.
+
+    - The Lovesong of J Alfred Prufrock, T. S. Eliot`;
+
+    const changesetMd = outdent`---
+    "cool-package": minor
+    "best-package": patch
+    ---
+
+    Let us go then you and I,
+    When the evening is spread out against the sky
+    Like a patient, etherized upon a table.
+
+    - The Lovesong of J Alfred Prufrock, T. S. Eliot
+    `;
+
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [
         { name: "cool-package", type: "minor" },
@@ -104,24 +104,24 @@ describe("parsing a changeset", () => {
     });
   });
   it("should be fine if a packageName includes ---", () => {
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "cool---package": minor
     ---
 
     Nice simple summary
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [{ name: "cool---package", type: "minor" }],
       summary: "Nice simple summary"
     });
   });
   it("should be fine if the summary body includes ---", () => {
-    let expectedSummary = outdent`---
+    const expectedSummary = outdent`---
     Nice simple summary---that has this`;
 
-    let changesetMd = outdent`---
+    const changesetMd = outdent`---
     "cool-package": minor
     ---
 
@@ -130,10 +130,22 @@ describe("parsing a changeset", () => {
 
     `;
 
-    let changeset = parse(changesetMd);
+    const changeset = parse(changesetMd);
     expect(changeset).toEqual({
       releases: [{ name: "cool-package", type: "minor" }],
       summary: expectedSummary
     });
   });
+  it("should be fine if the changeset is empty", () => {
+    const changesetMd = outdent`---
+    ---
+
+    `;
+
+    const changeset = parse(changesetMd);
+    expect(changeset).toEqual({
+      releases: [],
+      summary: ""
+    });
+  });
 });
diff --git a/packages/read/src/index.test.ts b/packages/read/src/index.test.ts
index 52ea482..21b12ea 100644
--- a/packages/read/src/index.test.ts
+++ b/packages/read/src/index.test.ts
@@ -94,4 +94,16 @@ describe("read changesets from disc", () => {
       Everything is wrong`
     );
   });
+  it("should return no releases and empty summary when the changeset is emtpy", async () => {
+    const changesetPath = f.find("empty-changeset");
+
+    const changesets = await read(changesetPath);
+    expect(changesets).toEqual([
+      {
+        releases: [],
+        summary: "",
+        id: "empty-changeset"
+      }
+    ]);
+  });
 });