Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/better-laws-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lazy-release/changesets": fix
---

Fix error handling when publishing packages
5 changes: 5 additions & 0 deletions .changeset/swift-icons-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@lazy-release/changesets": fix
---

Skip creating GitHub release if release already exists
218 changes: 218 additions & 0 deletions src/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,67 @@ describe("publish command", () => {
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("No changelog found"));
});

test("should skip GitHub release when release already exists", async () => {
spyOn(tinyglobby, "globSync").mockReturnValue(["package.json"]);
spyOn(fs, "readFileSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
if (pathStr.includes("package.json")) {
return JSON.stringify(
{
name: "@test/package",
version: "1.0.0",
},
null,
2,
);
}
if (pathStr.includes("CHANGELOG.md")) {
return `## 1.0.0\n\n### 🚀 feat\n- Test changeset`;
}
return "";
});
spyOn(fs, "existsSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
return pathStr.includes("CHANGELOG.md");
});
spyOn(childProcess, "execSync").mockImplementation((cmd: string) => {
if (cmd.includes("ls-remote")) {
throw new Error("Tag not found");
}
if (cmd.includes("git config")) {
return "git@github.com:owner/repo.git";
}
return "";
});
spyOn(packageManagerDetector, "detect").mockResolvedValue({ name: "npm", agent: "npm" });

// Mock fetch to return 422 error (release already exists)
const fetchMock = async (url: string, options: any) => {
if (url.includes("releases") && options?.method === "POST") {
return {
ok: false,
status: 422,
text: async () => "Validation Failed",
} as Response;
}
return {
ok: false,
text: async () => "",
} as Response;
};
global.fetch = fetchMock as any;

process.env.GITHUB_TOKEN = "test-token";

await publish({ dryRun: false });

const calls = consoleLogSpy.mock.calls.flat();
const hasSkippedRelease = calls.some(
(arg: any) => typeof arg === "string" && arg.includes("already exists"),
);
expect(hasSkippedRelease).toBe(true);
});

test("should ignore packages in config ignore list", async () => {
spyOn(tinyglobby, "globSync").mockReturnValue(["package.json"]);
spyOn(fs, "readFileSync").mockImplementation((path: any) => {
Expand Down Expand Up @@ -880,4 +941,161 @@ describe("publish command", () => {
expect.stringContaining("2 package(s)"),
);
});

test("should continue publishing other packages when one fails to create git tag", async () => {
spyOn(tinyglobby, "globSync").mockReturnValue([
"packages/package1/package.json",
"packages/package2/package.json",
]);
let tagCreateCount = 0;
spyOn(fs, "readFileSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
if (pathStr.includes("package1/package.json")) {
return JSON.stringify(
{
name: "@test/package1",
version: "1.0.0",
},
null,
2,
);
}
if (pathStr.includes("package2/package.json")) {
return JSON.stringify(
{
name: "@test/package2",
version: "2.0.0",
},
null,
2,
);
}
if (pathStr.includes("package1") && pathStr.includes("CHANGELOG.md")) {
return `## 1.0.0\n\n### 🚀 feat\n- Test changeset`;
}
if (pathStr.includes("package2") && pathStr.includes("CHANGELOG.md")) {
return `## 2.0.0\n\n### 🚀 feat\n- Test changeset`;
}
return "";
});
spyOn(fs, "existsSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
return pathStr.includes("CHANGELOG.md");
});
spyOn(childProcess, "execSync").mockImplementation((cmd: string) => {
if (cmd.includes("ls-remote")) {
throw new Error("Tag not found");
}
if (cmd.includes("git tag -a")) {
// First package fails to create tag, second succeeds
tagCreateCount++;
if (tagCreateCount === 1) {
throw new Error("Failed to create git tag");
}
}
if (cmd.includes("git config")) {
return "git@github.com:owner/repo.git";
}
return "";
});
spyOn(packageManagerDetector, "detect").mockResolvedValue({ name: "npm", agent: "npm" });

const fetchMock = async (url: string, options: any) => {
return {
ok: true,
text: async () => "",
} as Response;
};
global.fetch = fetchMock as any;
process.env.GITHUB_TOKEN = "test-token";

await publish({ dryRun: false });

// Should show both packages were processed
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Found"),
expect.stringContaining("2 package(s)"),
);
// Should show completion message with 1 success and 1 failure
const calls = consoleLogSpy.mock.calls.flat();
const hasSuccessMessage = calls.some(
(arg: any) => typeof arg === "string" && arg.includes("successful") && arg.includes("failed"),
);
expect(hasSuccessMessage).toBe(true);
// Should show error for first package
const errorCalls = consoleErrorSpy.mock.calls.flat();
const hasErrorMessage = errorCalls.some(
(arg: any) => typeof arg === "string" && arg.includes("Failed to publish @test/package1"),
);
expect(hasErrorMessage).toBe(true);
// Should show continuation message
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Continuing with remaining packages"),
);
});

test("should continue with GitHub release even when npm publish fails", async () => {
spyOn(tinyglobby, "globSync").mockReturnValue(["package.json"]);
spyOn(fs, "readFileSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
if (pathStr.includes("package.json")) {
return JSON.stringify(
{
name: "@test/package",
version: "1.0.0",
},
null,
2,
);
}
if (pathStr.includes("CHANGELOG.md")) {
return `## 1.0.0\n\n### 🚀 feat\n- Test changeset`;
}
return "";
});
spyOn(fs, "existsSync").mockImplementation((path: any) => {
const pathStr = typeof path === "string" ? path : path.toString();
return pathStr.includes("CHANGELOG.md");
});
spyOn(childProcess, "execSync").mockImplementation((cmd: string) => {
if (cmd.includes("ls-remote")) {
throw new Error("Tag not found");
}
if (cmd.includes("npm publish")) {
throw new Error("npm publish failed");
}
if (cmd.includes("git config")) {
return "git@github.com:owner/repo.git";
}
return "";
});
spyOn(packageManagerDetector, "detect").mockResolvedValue({ name: "npm", agent: "npm" });

const fetchMock = async (url: string, options: any) => {
return {
ok: true,
text: async () => "",
} as Response;
};
global.fetch = fetchMock as any;
process.env.GITHUB_TOKEN = "test-token";

await publish({ dryRun: false });

// Should show npm publish failed
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("✗"),
expect.stringContaining("Failed to publish to npm"),
);
// Should show continuation message
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Continuing with GitHub release creation"),
);
// Should still create GitHub release
const calls = consoleLogSpy.mock.calls.flat();
const hasCreatedRelease = calls.some(
(arg: any) => typeof arg === "string" && arg.includes("Created GitHub release"),
);
expect(hasCreatedRelease).toBe(true);
});
});
47 changes: 41 additions & 6 deletions src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,28 @@ export async function publish({

console.log(pc.dim("Found"), pc.cyan(`${packages.length} package(s)`));

const results = { success: 0, failed: 0 };

for (const pkg of packages) {
await publishPackage(pkg, dryRun, config, githubToken, draft);
try {
await publishPackage(pkg, dryRun, config, githubToken, draft);
results.success++;
} catch (error) {
results.failed++;
console.error(pc.red(`\n✗ Failed to publish ${pkg.name}`));
if (error instanceof Error) {
console.error(pc.red(error.message));
}
console.log(pc.yellow("Continuing with remaining packages...\n"));
}
}

if (dryRun) {
console.log(pc.yellow("\nDry run complete - no changes were made."));
} else {
console.log(pc.green("\n✔ Publish complete!"));
console.log(
pc.green(`\n✔ Publish complete! ${results.success} successful, ${results.failed} failed`),
);
}
}

Expand Down Expand Up @@ -115,7 +129,15 @@ async function publishPackage(
} else if (dryRun) {
console.log(pc.yellow("[DRY RUN]"), pc.dim("Would publish to npm"));
} else {
await publishToNpm(pkg, config);
try {
await publishToNpm(pkg, config);
} catch (error) {
console.error(pc.red("✗"), "Failed to publish to npm");
if (error instanceof Error) {
console.error(pc.red(error.message));
}
console.log(pc.yellow("Continuing with GitHub release creation..."));
}
}

if (dryRun) {
Expand All @@ -135,7 +157,15 @@ async function publishPackage(
console.log(pc.dim(" Body:"), pc.yellow("(No changelog found for this version)"));
}
} else {
await createGitHubRelease(pkg, tag, githubToken, draft);
try {
await createGitHubRelease(pkg, tag, githubToken, draft);
} catch (error) {
console.error(pc.red("✗"), "Failed to create GitHub release");
if (error instanceof Error) {
console.error(pc.red(error.message));
}
// Don't throw, just log and continue
}
}
}

Expand Down Expand Up @@ -186,7 +216,6 @@ async function publishToNpm(pkg: PackageInfo, config: ChangesetConfig) {
execSync(publishCmd, { cwd: pkg.dir, stdio: "inherit" });
console.log(pc.green("✔"), "Published to npm");
} catch (error) {
console.error(pc.red("✗"), "Failed to publish to npm");
throw error;
}
}
Expand Down Expand Up @@ -236,12 +265,18 @@ async function createGitHubRelease(

if (!response.ok) {
const error = await response.text();

// GitHub returns 422 when a release already exists for the tag
if (response.status === 422) {
console.log(pc.dim(`GitHub release for ${tag} already exists. Skipping.`));
return;
}

throw new Error(`GitHub API error: ${response.status} ${error}`);
}

console.log(pc.green("✔"), "Created GitHub release");
} catch (error) {
console.error(pc.red("✗"), "Failed to create GitHub release");
throw error;
}
}
Expand Down