Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/improve-parse-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@changesets/parse": patch
---

Improve error messages for malformed changeset files. The new error messages explain what went wrong, show what was received, and provide examples of the correct format.
109 changes: 106 additions & 3 deletions packages/parse/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,16 @@ describe("parsing a changeset", () => {
`;

expect(() => parse(changesetMd)).toThrowErrorMatchingInlineSnapshot(`
Comment thread
Andarist marked this conversation as resolved.
"could not parse changeset - invalid frontmatter: ---
"could not parse changeset - missing or invalid frontmatter.
Changesets must start with frontmatter delimited by "---".
Example:
---
"package-name": patch
---

Your changeset summary here.
Received content:
---
"cool-package": minor
--- fail

Expand All @@ -253,8 +262,102 @@ describe("parsing a changeset", () => {
`;

expect(() => parse(changesetMd)).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - invalid frontmatter: ---
: minor
"could not parse changeset - invalid YAML in frontmatter.
The frontmatter between the "---" delimiters must be valid YAML.
YAML error: incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line (2:1)

1 |
2 | : minor
-----^
Frontmatter content:

: minor"
`);
});

it("should throw when file is completely empty", () => {
expect(() => parse("")).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - file is empty.
Changesets must have frontmatter with package names and version types.
Example:
---
"package-name": patch
---

Your changeset summary here."
`);
expect(() => parse(" ")).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - file is empty.
Changesets must have frontmatter with package names and version types.
Example:
---
"package-name": patch
---

Your changeset summary here."
`);
expect(() => parse("\n\n")).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - file is empty.
Changesets must have frontmatter with package names and version types.
Example:
---
"package-name": patch
---

Your changeset summary here."
`);
});

it("should throw when frontmatter is missing", () => {
const changesetMd = "Just some content without frontmatter";
expect(() => parse(changesetMd)).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - missing or invalid frontmatter.
Changesets must start with frontmatter delimited by "---".
Example:
---
"package-name": patch
---

Your changeset summary here.
Received content:
Just some content without frontmatter"
`);
});

it("should throw when version type is invalid", () => {
const changesetMd = outdent`---
"cool-package": invalid-type
---

Nice simple summary
`;

expect(() => parse(changesetMd)).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - invalid version type "invalid-type" for package "cool-package".
Valid version types are: major, minor, patch, none
Changeset contents:
---
"cool-package": invalid-type
---

Nice simple summary"
`);
});
Comment thread
Andarist marked this conversation as resolved.

it("should throw with helpful message when package name is empty", () => {
const changesetMd = outdent`---
"": minor
---

Nice simple summary
`;

expect(() => parse(changesetMd)).toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - invalid package name in frontmatter.
Expected a non-empty string for package name, but got: ""
Changeset contents:
---
"": minor
---

Nice simple summary"
Expand Down
98 changes: 82 additions & 16 deletions packages/parse/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,108 @@ import { Release, VersionType } from "@changesets/types";

const mdRegex = /\s*---([^]*?)\n\s*---(\s*(?:\n|$)[^]*)/;

const EXAMPLE_FORMAT = `---\n"package-name": patch\n---`;

const validVersionTypes: readonly VersionType[] = [
"major",
"minor",
"patch",
"none",
];

function truncate(s: string, max = 200): string {
return s.length > max ? s.slice(0, max) + "..." : s;
}

function validateReleases(releases: Release[], contents: string): void {
for (const release of releases) {
if (typeof release.name !== "string" || release.name.trim() === "") {
throw new Error(
`could not parse changeset - invalid package name in frontmatter.\n` +
`Expected a non-empty string for package name, but got: ${JSON.stringify(
release.name
)}\n` +
`Changeset contents:\n${truncate(contents)}`
);
}

if (typeof release.type !== "string") {
throw new Error(
`could not parse changeset - invalid release type for package "${release.name}".\n` +
`Expected a string for release type, but got: ${typeof release.type}\n` +
`Changeset contents:\n${truncate(contents)}`
);
}

if (!validVersionTypes.includes(release.type)) {
throw new Error(
`could not parse changeset - invalid version type ${JSON.stringify(
release.type
)} for package "${release.name}".\n` +
`Valid version types are: ${validVersionTypes.join(", ")}\n` +
`Changeset contents:\n${truncate(contents)}`
);
}
Comment on lines +39 to +47
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation only checks if the version type is in the valid list, but doesn't verify that release.type is a string. If the YAML contains a non-string value (e.g., an object, array, number, or boolean), it should be rejected with a clear error message. Consider adding a type check before the validVersionTypes check: if (typeof release.type !== "string") { throw new Error(...) }

Copilot uses AI. Check for mistakes.
}
}

export default function parseChangesetFile(contents: string): {
summary: string;
releases: Release[];
} {
const trimmedContents = contents.trim();

if (!trimmedContents) {
throw new Error(
`could not parse changeset - file is empty.\n` +
`Changesets must have frontmatter with package names and version types.\n` +
`Example:\n${EXAMPLE_FORMAT}\n\nYour changeset summary here.`
);
}

const execResult = mdRegex.exec(contents);
if (!execResult) {
throw new Error(
`could not parse changeset - invalid frontmatter: ${contents}`
`could not parse changeset - missing or invalid frontmatter.\n` +
`Changesets must start with frontmatter delimited by "---".\n` +
`Example:\n${EXAMPLE_FORMAT}\n\nYour changeset summary here.\n` +
`Received content:\n${truncate(trimmedContents)}`
);
}
let [, roughReleases, roughSummary] = execResult;
let summary = roughSummary.trim();

let releases: Release[];
let yamlStuff: Record<string, VersionType> | undefined;
try {
const yamlStuff = yaml.load(roughReleases) as
| Record<string, VersionType>
| undefined;

if (yamlStuff) {
releases = Object.entries(yamlStuff).map(([name, type]) => ({
name,
type,
}));
} else {
releases = [];
}
yamlStuff = yaml.load(roughReleases) as typeof yamlStuff;
} catch (e) {
throw new Error(
`could not parse changeset - invalid frontmatter: ${contents}`
`could not parse changeset - invalid YAML in frontmatter.\n` +
`The frontmatter between the "---" delimiters must be valid YAML.\n` +
`YAML error: ${e instanceof Error ? e.message : String(e)}\n` +
`Frontmatter content:\n${roughReleases}`
);
}

if (!releases) {
throw new Error(`could not parse changeset - unknown error: ${contents}`);
if (yamlStuff) {
if (typeof yamlStuff !== "object" || Array.isArray(yamlStuff)) {
throw new Error(
`could not parse changeset - frontmatter must be an object mapping package names to version types.\n` +
`Expected format:\n${EXAMPLE_FORMAT}\n` +
`Received:\n${roughReleases}`
);
}

releases = Object.entries(yamlStuff).map(([name, type]) => ({
name,
type,
}));
} else {
releases = [];
}

validateReleases(releases, contents);

return { releases, summary };
}
25 changes: 19 additions & 6 deletions packages/read/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from "fs-extra";
import path from "node:path";
import outdent from "outdent";

import read from "./";
import { gitdir, silenceLogsInBlock, testdir } from "@changesets/test-utils";
Expand Down Expand Up @@ -98,13 +97,15 @@ I'm amazed we needed to update the best package, because it was already the best
},
]);
});

it("should return an empty array when no changesets are found", async () => {
const cwd = await testdir({});
await fs.mkdir(path.join(cwd, ".changeset"));

const changesets = await read(cwd);
expect(changesets).toEqual([]);
});

it("should error when there is no changeset folder", async () => {
const cwd = await testdir({});

Expand All @@ -118,7 +119,8 @@ I'm amazed we needed to update the best package, because it was already the best
}
expect("never run this because we returned above").toBe(true);
});
it("should error on broken changeset?", async () => {

it("should error on broken changeset", async () => {
const cwd = await testdir({
".changeset/broken-changeset.md": `---

Expand All @@ -129,16 +131,26 @@ I'm amazed we needed to update the best package, because it was already the best
Everything is wrong`,
});

expect(read(cwd)).rejects.toThrow(
outdent`could not parse changeset - invalid frontmatter: ---
await expect(read(cwd)).rejects.toThrowErrorMatchingInlineSnapshot(`
"could not parse changeset - missing or invalid frontmatter.
Changesets must start with frontmatter delimited by "---".
Example:
---
"package-name": patch
---

Your changeset summary here.
Received content:
---

"cool-package": minor

--

Everything is wrong`
);
Everything is wrong"
`);
});

it("should return no releases and empty summary when the changeset is empty", async () => {
const cwd = await testdir({
".changeset/empty-like-void.md": `---
Expand All @@ -154,6 +166,7 @@ Everything is wrong`,
},
]);
});

it("should filter out ignored changesets", async () => {
const cwd = await testdir({
"package.json": JSON.stringify({
Expand Down