Skip to content

Commit c9ac858

Browse files
alerizzoclaude
andauthored
fix(release): make changelog generation resilient to GitHub flakiness (#21)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbf62d5 commit c9ac858

7 files changed

Lines changed: 241 additions & 3 deletions

File tree

.changeset/bold-views-kiss.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
---
3+
4+
Internal release-infra change (no version bump): make Changesets changelog
5+
generation resilient to transient GitHub GraphQL failures by retrying and
6+
falling back to a git-based changelog, so the "version packages" release step
7+
no longer aborts on "Failed to parse data from GitHub / Premature close".

.changeset/changelog.cjs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Resilient changelog generator for changesets.
2+
//
3+
// We normally use @changesets/changelog-github so the generated CHANGELOG gets
4+
// rich, linked entries (PR numbers, author credits). That generator calls the
5+
// GitHub GraphQL API, which is intermittently flaky in CI: a single dropped
6+
// connection surfaces as
7+
//
8+
// Failed to parse data from GitHub
9+
// Invalid response body while trying to fetch https://api.github.com/graphql: Premature close
10+
//
11+
// and, because @changesets/apply-release-plan generates all entries inside one
12+
// Promise.all(), that single rejection aborts the entire `changeset version`
13+
// run ("We have escaped applying the changesets..."). The release PR never gets
14+
// created and a human has to babysit re-runs.
15+
//
16+
// This wrapper makes the GitHub enrichment best-effort:
17+
// 1. Retry the GitHub call a few times. @changesets/get-github-info batches
18+
// via dataloader, which clears failed keys on rejection, so each retry
19+
// re-issues the GraphQL query rather than replaying a cached failure.
20+
// 2. If GitHub is still unreachable, fall back to @changesets/changelog-git —
21+
// plain entries with commit SHAs, no network required.
22+
//
23+
// The release proceeds either way; the only downside when GitHub is down is a
24+
// less decorated changelog for that one release (which can be polished by hand
25+
// afterwards if desired). Warnings are logged so the degradation is visible in
26+
// the CI output.
27+
28+
const github = require("@changesets/changelog-github").default;
29+
const git = require("@changesets/changelog-git").default;
30+
31+
// Read tunables at call time (not module load) so tests can override them via
32+
// process.env without fighting ES module import hoisting. Invalid overrides
33+
// (non-numeric, negative, NaN) are ignored in favour of the safe defaults so a
34+
// typo'd env var can't silently break retries.
35+
function getConfig() {
36+
const attempts = Number(process.env.CHANGELOG_GITHUB_ATTEMPTS);
37+
const delay = Number(process.env.CHANGELOG_GITHUB_RETRY_MS);
38+
return {
39+
// Always run at least one attempt.
40+
maxAttempts: Number.isInteger(attempts) && attempts >= 1 ? attempts : 3,
41+
// Non-negative, finite delay only.
42+
retryDelayMs: Number.isFinite(delay) && delay >= 0 ? delay : 1000,
43+
};
44+
}
45+
46+
// Extract a log-safe message from an unknown thrown value. The underlying
47+
// generators throw Error objects, but we must never let a non-Error rejection
48+
// (e.g. `throw undefined`) make `.message` throw inside the catch block — that
49+
// would crash the very generation this wrapper exists to keep alive.
50+
const errorMessage = (err) => (err && err.message) || String(err);
51+
52+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
53+
54+
// Run the GitHub generator with retries; fall back to the git generator if it
55+
// keeps failing. `label` is only used for log messages.
56+
async function withFallback(label, githubFn, gitFn) {
57+
const { maxAttempts, retryDelayMs } = getConfig();
58+
let lastError;
59+
60+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
61+
try {
62+
return await githubFn();
63+
} catch (error) {
64+
lastError = error;
65+
if (attempt < maxAttempts) {
66+
console.warn(
67+
`[changelog] GitHub enrichment for ${label} failed ` +
68+
`(attempt ${attempt}/${maxAttempts}): ${errorMessage(error)}. Retrying...`,
69+
);
70+
// Linear backoff: 1x, 2x, ... the base delay.
71+
await sleep(retryDelayMs * attempt);
72+
}
73+
}
74+
}
75+
76+
console.warn(
77+
`[changelog] GitHub enrichment for ${label} failed after ${maxAttempts} ` +
78+
`attempts: ${errorMessage(lastError)}. Falling back to a plain ` +
79+
`(git) changelog entry for this release.`,
80+
);
81+
return gitFn();
82+
}
83+
84+
async function getReleaseLine(changeset, type, options) {
85+
return withFallback(
86+
"release line",
87+
() => github.getReleaseLine(changeset, type, options),
88+
() => git.getReleaseLine(changeset, type, options),
89+
);
90+
}
91+
92+
async function getDependencyReleaseLine(changesets, dependenciesUpdated, options) {
93+
return withFallback(
94+
"dependency release line",
95+
() => github.getDependencyReleaseLine(changesets, dependenciesUpdated, options),
96+
() => git.getDependencyReleaseLine(changesets, dependenciesUpdated, options),
97+
);
98+
}
99+
100+
// `withFallback` is exported for unit testing (vi.mock cannot intercept the
101+
// require() of the underlying generators across the CJS boundary, so the retry
102+
// /fallback logic is tested directly with injected fakes instead).
103+
module.exports = { getReleaseLine, getDependencyReleaseLine, withFallback };

.changeset/changelog.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
// @ts-expect-error - plain CJS module, no type declarations
3+
import changelog from "./changelog.cjs";
4+
5+
const { withFallback } = changelog;
6+
7+
// The wrapper delegates getReleaseLine / getDependencyReleaseLine to
8+
// `withFallback`, which holds the retry-then-fall-back logic. We test that logic
9+
// directly with injected fakes rather than mocking @changesets/changelog-github
10+
// (vi.mock cannot intercept the require() inside the .cjs module).
11+
describe("changelog withFallback", () => {
12+
beforeEach(() => {
13+
// Remove retry delays so the failing-path tests run instantly. vi.stubEnv +
14+
// vi.unstubAllEnvs keeps these mutations from leaking out of the file.
15+
vi.stubEnv("CHANGELOG_GITHUB_RETRY_MS", "0");
16+
vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", undefined);
17+
vi.spyOn(console, "warn").mockImplementation(() => {});
18+
});
19+
20+
afterEach(() => {
21+
vi.unstubAllEnvs();
22+
vi.restoreAllMocks();
23+
});
24+
25+
it("uses the GitHub generator when it succeeds (no fallback)", async () => {
26+
const githubFn = vi.fn().mockResolvedValue("- GH line");
27+
const gitFn = vi.fn().mockResolvedValue("- git line");
28+
29+
const result = await withFallback("release line", githubFn, gitFn);
30+
31+
expect(result).toBe("- GH line");
32+
expect(githubFn).toHaveBeenCalledTimes(1);
33+
expect(gitFn).not.toHaveBeenCalled();
34+
});
35+
36+
it("retries the GitHub generator and keeps its result on recovery", async () => {
37+
const githubFn = vi
38+
.fn()
39+
.mockRejectedValueOnce(new Error("Premature close"))
40+
.mockResolvedValue("- GH line");
41+
const gitFn = vi.fn().mockResolvedValue("- git line");
42+
43+
const result = await withFallback("release line", githubFn, gitFn);
44+
45+
expect(result).toBe("- GH line");
46+
expect(githubFn).toHaveBeenCalledTimes(2);
47+
expect(gitFn).not.toHaveBeenCalled();
48+
});
49+
50+
it("falls back to the git generator after GitHub keeps failing", async () => {
51+
const githubFn = vi.fn().mockRejectedValue(new Error("Premature close"));
52+
const gitFn = vi.fn().mockResolvedValue("- abc1234: git line");
53+
54+
const result = await withFallback("release line", githubFn, gitFn);
55+
56+
expect(result).toBe("- abc1234: git line");
57+
expect(githubFn).toHaveBeenCalledTimes(3); // default maxAttempts
58+
expect(gitFn).toHaveBeenCalledTimes(1);
59+
});
60+
61+
it("falls back without throwing when GitHub rejects with a non-Error value", async () => {
62+
// A non-Error rejection must not make `.message` throw inside the catch.
63+
const githubFn = vi.fn().mockRejectedValue(undefined);
64+
const gitFn = vi.fn().mockResolvedValue("- git line");
65+
66+
const result = await withFallback("release line", githubFn, gitFn);
67+
68+
expect(result).toBe("- git line");
69+
expect(githubFn).toHaveBeenCalledTimes(3);
70+
expect(gitFn).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it("honours a custom attempt count via CHANGELOG_GITHUB_ATTEMPTS", async () => {
74+
vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "5");
75+
const githubFn = vi.fn().mockRejectedValue(new Error("Premature close"));
76+
const gitFn = vi.fn().mockResolvedValue("- git line");
77+
78+
await withFallback("release line", githubFn, gitFn);
79+
80+
expect(githubFn).toHaveBeenCalledTimes(5);
81+
expect(gitFn).toHaveBeenCalledTimes(1);
82+
});
83+
84+
it("ignores an invalid attempt-count override and uses the default", async () => {
85+
vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "-1");
86+
const githubFn = vi.fn().mockRejectedValue(new Error("Premature close"));
87+
const gitFn = vi.fn().mockResolvedValue("- git line");
88+
89+
await withFallback("release line", githubFn, gitFn);
90+
91+
expect(githubFn).toHaveBeenCalledTimes(3); // -1 ignored -> default 3
92+
expect(gitFn).toHaveBeenCalledTimes(1);
93+
});
94+
95+
it("tolerates a non-numeric retry-delay override without producing NaN waits", async () => {
96+
vi.stubEnv("CHANGELOG_GITHUB_RETRY_MS", "not-a-number");
97+
vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "1"); // 1 attempt -> no sleep path
98+
const githubFn = vi.fn().mockRejectedValue(new Error("Premature close"));
99+
const gitFn = vi.fn().mockResolvedValue("- git line");
100+
101+
const result = await withFallback("release line", githubFn, gitFn);
102+
103+
expect(result).toBe("- git line");
104+
expect(githubFn).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it("exposes getReleaseLine and getDependencyReleaseLine for changesets", () => {
108+
expect(typeof changelog.getReleaseLine).toBe("function");
109+
expect(typeof changelog.getDependencyReleaseLine).toBe("function");
110+
});
111+
});

.changeset/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
33
"changelog": [
4-
"@changesets/changelog-github",
4+
"./changelog.cjs",
55
{ "repo": "codacy/codacy-cloud-cli" }
66
],
77
"commit": false,

SPECS/deployment.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ Steps:
3939
- Creates/updates a "chore: version packages" PR (bumps version, updates CHANGELOG.md)
4040
- If that PR was just merged, runs `changeset publish` to publish to npm with provenance
4141

42+
### Changelog generation (resilient wrapper)
43+
44+
The `changelog` entry in `.changeset/config.json` points to `.changeset/changelog.cjs`
45+
instead of `@changesets/changelog-github` directly. That wrapper still uses the
46+
GitHub generator (rich entries with PR/author links) but makes it **best-effort**:
47+
it retries the GitHub GraphQL call and, if GitHub is unreachable, falls back to
48+
`@changesets/changelog-git` (plain entries with commit SHAs). This prevents a
49+
transient GitHub API failure (`Failed to parse data from GitHub` /
50+
`Premature close`) from aborting the whole "version packages" step.
51+
52+
- Tunable via env vars `CHANGELOG_GITHUB_ATTEMPTS` (default 3) and
53+
`CHANGELOG_GITHUB_RETRY_MS` (default 1000) — used by the wrapper's tests.
54+
- `@changesets/changelog-git` is pinned as an explicit devDependency so the
55+
fallback never relies on transitive hoisting.
56+
4257
## Homebrew Formula
4358

4459
Planned for future distribution as a separate brew formula for macOS/Linux/Windows. No implementation yet.

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"pluralize": "8.0.0"
5959
},
6060
"devDependencies": {
61+
"@changesets/changelog-git": "0.2.1",
6162
"@changesets/changelog-github": "0.6.0",
6263
"@changesets/cli": "2.30.0",
6364
"@codacy/openapi-typescript-codegen": "0.0.8",

0 commit comments

Comments
 (0)