From 349c4d316d6436fd0ab9f8b5ea9822e41f2d4c33 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 25 Jun 2026 20:26:35 +0100 Subject: [PATCH 1/2] fix(release): make changelog generation resilient to GitHub flakiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow's "version packages" step kept failing with "Failed to parse data from GitHub / Invalid response body ... Premature close". @changesets/changelog-github calls the GitHub GraphQL API to enrich changelog entries, and @changesets/apply-release-plan generates all entries inside one Promise.all() — so a single transient API failure rejects the whole batch and aborts versioning. No changeset gets applied and the release PR is never created. Wrap the GitHub changelog generator in .changeset/changelog.cjs so the enrichment is best-effort: retry the GraphQL call a few times (dataloader clears failed keys, so retries genuinely re-issue the query), then fall back to @changesets/changelog-git (plain entries with commit SHAs, no network) if GitHub stays unreachable. The release now proceeds even when GitHub's API is down; only the changelog decoration degrades. - Pin @changesets/changelog-git explicitly so the fallback never relies on transitive hoisting. - Tunable via CHANGELOG_GITHUB_ATTEMPTS / CHANGELOG_GITHUB_RETRY_MS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/bold-views-kiss.md | 2 + .changeset/changelog.cjs | 94 +++++++++++++++++++++++++++++++++++ .changeset/changelog.test.ts | 70 ++++++++++++++++++++++++++ .changeset/config.json | 2 +- SPECS/deployment.md | 15 ++++++ package-lock.json | 5 +- package.json | 1 + 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 .changeset/bold-views-kiss.md create mode 100644 .changeset/changelog.cjs create mode 100644 .changeset/changelog.test.ts diff --git a/.changeset/bold-views-kiss.md b/.changeset/bold-views-kiss.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/bold-views-kiss.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/changelog.cjs b/.changeset/changelog.cjs new file mode 100644 index 0000000..6549742 --- /dev/null +++ b/.changeset/changelog.cjs @@ -0,0 +1,94 @@ +// Resilient changelog generator for changesets. +// +// We normally use @changesets/changelog-github so the generated CHANGELOG gets +// rich, linked entries (PR numbers, author credits). That generator calls the +// GitHub GraphQL API, which is intermittently flaky in CI: a single dropped +// connection surfaces as +// +// Failed to parse data from GitHub +// Invalid response body while trying to fetch https://api.github.com/graphql: Premature close +// +// and, because @changesets/apply-release-plan generates all entries inside one +// Promise.all(), that single rejection aborts the entire `changeset version` +// run ("We have escaped applying the changesets..."). The release PR never gets +// created and a human has to babysit re-runs. +// +// This wrapper makes the GitHub enrichment best-effort: +// 1. Retry the GitHub call a few times. @changesets/get-github-info batches +// via dataloader, which clears failed keys on rejection, so each retry +// re-issues the GraphQL query rather than replaying a cached failure. +// 2. If GitHub is still unreachable, fall back to @changesets/changelog-git — +// plain entries with commit SHAs, no network required. +// +// The release proceeds either way; the only downside when GitHub is down is a +// less decorated changelog for that one release (which can be polished by hand +// afterwards if desired). Warnings are logged so the degradation is visible in +// the CI output. + +const github = require("@changesets/changelog-github").default; +const git = require("@changesets/changelog-git").default; + +// Read tunables at call time (not module load) so tests can override them via +// process.env without fighting ES module import hoisting. +function getConfig() { + return { + maxAttempts: Number(process.env.CHANGELOG_GITHUB_ATTEMPTS) || 3, + retryDelayMs: + process.env.CHANGELOG_GITHUB_RETRY_MS !== undefined + ? Number(process.env.CHANGELOG_GITHUB_RETRY_MS) + : 1000, + }; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Run the GitHub generator with retries; fall back to the git generator if it +// keeps failing. `label` is only used for log messages. +async function withFallback(label, githubFn, gitFn) { + const { maxAttempts, retryDelayMs } = getConfig(); + let lastError; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await githubFn(); + } catch (error) { + lastError = error; + if (attempt < maxAttempts) { + console.warn( + `[changelog] GitHub enrichment for ${label} failed ` + + `(attempt ${attempt}/${maxAttempts}): ${error.message}. Retrying...`, + ); + // Linear backoff: 1x, 2x, ... the base delay. + await sleep(retryDelayMs * attempt); + } + } + } + + console.warn( + `[changelog] GitHub enrichment for ${label} failed after ${maxAttempts} ` + + `attempts: ${lastError && lastError.message}. Falling back to a plain ` + + `(git) changelog entry for this release.`, + ); + return gitFn(); +} + +async function getReleaseLine(changeset, type, options) { + return withFallback( + "release line", + () => github.getReleaseLine(changeset, type, options), + () => git.getReleaseLine(changeset, type, options), + ); +} + +async function getDependencyReleaseLine(changesets, dependenciesUpdated, options) { + return withFallback( + "dependency release line", + () => github.getDependencyReleaseLine(changesets, dependenciesUpdated, options), + () => git.getDependencyReleaseLine(changesets, dependenciesUpdated, options), + ); +} + +// `withFallback` is exported for unit testing (vi.mock cannot intercept the +// require() of the underlying generators across the CJS boundary, so the retry +// /fallback logic is tested directly with injected fakes instead). +module.exports = { getReleaseLine, getDependencyReleaseLine, withFallback }; diff --git a/.changeset/changelog.test.ts b/.changeset/changelog.test.ts new file mode 100644 index 0000000..a3c56ad --- /dev/null +++ b/.changeset/changelog.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +// @ts-expect-error - plain CJS module, no type declarations +import changelog from "./changelog.cjs"; + +const { withFallback } = changelog; + +// The wrapper delegates getReleaseLine / getDependencyReleaseLine to +// `withFallback`, which holds the retry-then-fall-back logic. We test that logic +// directly with injected fakes rather than mocking @changesets/changelog-github +// (vi.mock cannot intercept the require() inside the .cjs module). +describe("changelog withFallback", () => { + beforeEach(() => { + // Remove retry delays so the failing-path tests run instantly. + process.env.CHANGELOG_GITHUB_RETRY_MS = "0"; + delete process.env.CHANGELOG_GITHUB_ATTEMPTS; + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + it("uses the GitHub generator when it succeeds (no fallback)", async () => { + const githubFn = vi.fn().mockResolvedValue("- GH line"); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + const result = await withFallback("release line", githubFn, gitFn); + + expect(result).toBe("- GH line"); + expect(githubFn).toHaveBeenCalledTimes(1); + expect(gitFn).not.toHaveBeenCalled(); + }); + + it("retries the GitHub generator and keeps its result on recovery", async () => { + const githubFn = vi + .fn() + .mockRejectedValueOnce(new Error("Premature close")) + .mockResolvedValue("- GH line"); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + const result = await withFallback("release line", githubFn, gitFn); + + expect(result).toBe("- GH line"); + expect(githubFn).toHaveBeenCalledTimes(2); + expect(gitFn).not.toHaveBeenCalled(); + }); + + it("falls back to the git generator after GitHub keeps failing", async () => { + const githubFn = vi.fn().mockRejectedValue(new Error("Premature close")); + const gitFn = vi.fn().mockResolvedValue("- abc1234: git line"); + + const result = await withFallback("release line", githubFn, gitFn); + + expect(result).toBe("- abc1234: git line"); + expect(githubFn).toHaveBeenCalledTimes(3); // default maxAttempts + expect(gitFn).toHaveBeenCalledTimes(1); + }); + + it("honours a custom attempt count via CHANGELOG_GITHUB_ATTEMPTS", async () => { + process.env.CHANGELOG_GITHUB_ATTEMPTS = "5"; + const githubFn = vi.fn().mockRejectedValue(new Error("Premature close")); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + await withFallback("release line", githubFn, gitFn); + + expect(githubFn).toHaveBeenCalledTimes(5); + expect(gitFn).toHaveBeenCalledTimes(1); + }); + + it("exposes getReleaseLine and getDependencyReleaseLine for changesets", () => { + expect(typeof changelog.getReleaseLine).toBe("function"); + expect(typeof changelog.getDependencyReleaseLine).toBe("function"); + }); +}); diff --git a/.changeset/config.json b/.changeset/config.json index d340d18..635ef98 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", "changelog": [ - "@changesets/changelog-github", + "./changelog.cjs", { "repo": "codacy/codacy-cloud-cli" } ], "commit": false, diff --git a/SPECS/deployment.md b/SPECS/deployment.md index 444fe5d..2488ad1 100644 --- a/SPECS/deployment.md +++ b/SPECS/deployment.md @@ -39,6 +39,21 @@ Steps: - Creates/updates a "chore: version packages" PR (bumps version, updates CHANGELOG.md) - If that PR was just merged, runs `changeset publish` to publish to npm with provenance +### Changelog generation (resilient wrapper) + +The `changelog` entry in `.changeset/config.json` points to `.changeset/changelog.cjs` +instead of `@changesets/changelog-github` directly. That wrapper still uses the +GitHub generator (rich entries with PR/author links) but makes it **best-effort**: +it retries the GitHub GraphQL call and, if GitHub is unreachable, falls back to +`@changesets/changelog-git` (plain entries with commit SHAs). This prevents a +transient GitHub API failure (`Failed to parse data from GitHub` / +`Premature close`) from aborting the whole "version packages" step. + +- Tunable via env vars `CHANGELOG_GITHUB_ATTEMPTS` (default 3) and + `CHANGELOG_GITHUB_RETRY_MS` (default 1000) — used by the wrapper's tests. +- `@changesets/changelog-git` is pinned as an explicit devDependency so the + fallback never relies on transitive hoisting. + ## Homebrew Formula Planned for future distribution as a separate brew formula for macOS/Linux/Windows. No implementation yet. diff --git a/package-lock.json b/package-lock.json index 3e7d661..bc8d588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codacy/codacy-cloud-cli", - "version": "1.2.1", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codacy/codacy-cloud-cli", - "version": "1.2.1", + "version": "1.3.1", "license": "ISC", "dependencies": { "@codacy/tooling": "0.1.0", @@ -24,6 +24,7 @@ "codacy": "dist/index.js" }, "devDependencies": { + "@changesets/changelog-git": "0.2.1", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.30.0", "@codacy/openapi-typescript-codegen": "0.0.8", diff --git a/package.json b/package.json index ea78c8d..f5514da 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "pluralize": "8.0.0" }, "devDependencies": { + "@changesets/changelog-git": "0.2.1", "@changesets/changelog-github": "0.6.0", "@changesets/cli": "2.30.0", "@codacy/openapi-typescript-codegen": "0.0.8", From 700afd9b092d99e7eb03a5c9a0f00ae030c9dd2f Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Thu, 25 Jun 2026 20:36:48 +0100 Subject: [PATCH 2/2] chore(release): harden changelog wrapper per review feedback Address the AI review round on the resilient changelog wrapper: - Guard against non-Error rejections via an errorMessage() helper so a `throw undefined`/string can't make .message throw inside the catch and defeat the resilience (Gemini, Copilot). - Validate the CHANGELOG_GITHUB_* tunables in getConfig(): integer attempts >= 1 and a finite non-negative delay, ignoring bad overrides (Gemini, Copilot). - Switch tests to vi.stubEnv/vi.unstubAllEnvs and add edge-case tests (non-Error rejection, invalid attempt count, non-numeric delay). - Add an explanatory note to the empty changeset (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/bold-views-kiss.md | 5 ++++ .changeset/changelog.cjs | 25 +++++++++++------ .changeset/changelog.test.ts | 51 +++++++++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/.changeset/bold-views-kiss.md b/.changeset/bold-views-kiss.md index a845151..69a8914 100644 --- a/.changeset/bold-views-kiss.md +++ b/.changeset/bold-views-kiss.md @@ -1,2 +1,7 @@ --- --- + +Internal release-infra change (no version bump): make Changesets changelog +generation resilient to transient GitHub GraphQL failures by retrying and +falling back to a git-based changelog, so the "version packages" release step +no longer aborts on "Failed to parse data from GitHub / Premature close". diff --git a/.changeset/changelog.cjs b/.changeset/changelog.cjs index 6549742..9c48cc3 100644 --- a/.changeset/changelog.cjs +++ b/.changeset/changelog.cjs @@ -29,17 +29,26 @@ const github = require("@changesets/changelog-github").default; const git = require("@changesets/changelog-git").default; // Read tunables at call time (not module load) so tests can override them via -// process.env without fighting ES module import hoisting. +// process.env without fighting ES module import hoisting. Invalid overrides +// (non-numeric, negative, NaN) are ignored in favour of the safe defaults so a +// typo'd env var can't silently break retries. function getConfig() { + const attempts = Number(process.env.CHANGELOG_GITHUB_ATTEMPTS); + const delay = Number(process.env.CHANGELOG_GITHUB_RETRY_MS); return { - maxAttempts: Number(process.env.CHANGELOG_GITHUB_ATTEMPTS) || 3, - retryDelayMs: - process.env.CHANGELOG_GITHUB_RETRY_MS !== undefined - ? Number(process.env.CHANGELOG_GITHUB_RETRY_MS) - : 1000, + // Always run at least one attempt. + maxAttempts: Number.isInteger(attempts) && attempts >= 1 ? attempts : 3, + // Non-negative, finite delay only. + retryDelayMs: Number.isFinite(delay) && delay >= 0 ? delay : 1000, }; } +// Extract a log-safe message from an unknown thrown value. The underlying +// generators throw Error objects, but we must never let a non-Error rejection +// (e.g. `throw undefined`) make `.message` throw inside the catch block — that +// would crash the very generation this wrapper exists to keep alive. +const errorMessage = (err) => (err && err.message) || String(err); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Run the GitHub generator with retries; fall back to the git generator if it @@ -56,7 +65,7 @@ async function withFallback(label, githubFn, gitFn) { if (attempt < maxAttempts) { console.warn( `[changelog] GitHub enrichment for ${label} failed ` + - `(attempt ${attempt}/${maxAttempts}): ${error.message}. Retrying...`, + `(attempt ${attempt}/${maxAttempts}): ${errorMessage(error)}. Retrying...`, ); // Linear backoff: 1x, 2x, ... the base delay. await sleep(retryDelayMs * attempt); @@ -66,7 +75,7 @@ async function withFallback(label, githubFn, gitFn) { console.warn( `[changelog] GitHub enrichment for ${label} failed after ${maxAttempts} ` + - `attempts: ${lastError && lastError.message}. Falling back to a plain ` + + `attempts: ${errorMessage(lastError)}. Falling back to a plain ` + `(git) changelog entry for this release.`, ); return gitFn(); diff --git a/.changeset/changelog.test.ts b/.changeset/changelog.test.ts index a3c56ad..a103810 100644 --- a/.changeset/changelog.test.ts +++ b/.changeset/changelog.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // @ts-expect-error - plain CJS module, no type declarations import changelog from "./changelog.cjs"; @@ -10,12 +10,18 @@ const { withFallback } = changelog; // (vi.mock cannot intercept the require() inside the .cjs module). describe("changelog withFallback", () => { beforeEach(() => { - // Remove retry delays so the failing-path tests run instantly. - process.env.CHANGELOG_GITHUB_RETRY_MS = "0"; - delete process.env.CHANGELOG_GITHUB_ATTEMPTS; + // Remove retry delays so the failing-path tests run instantly. vi.stubEnv + + // vi.unstubAllEnvs keeps these mutations from leaking out of the file. + vi.stubEnv("CHANGELOG_GITHUB_RETRY_MS", "0"); + vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", undefined); vi.spyOn(console, "warn").mockImplementation(() => {}); }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + it("uses the GitHub generator when it succeeds (no fallback)", async () => { const githubFn = vi.fn().mockResolvedValue("- GH line"); const gitFn = vi.fn().mockResolvedValue("- git line"); @@ -52,8 +58,20 @@ describe("changelog withFallback", () => { expect(gitFn).toHaveBeenCalledTimes(1); }); + it("falls back without throwing when GitHub rejects with a non-Error value", async () => { + // A non-Error rejection must not make `.message` throw inside the catch. + const githubFn = vi.fn().mockRejectedValue(undefined); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + const result = await withFallback("release line", githubFn, gitFn); + + expect(result).toBe("- git line"); + expect(githubFn).toHaveBeenCalledTimes(3); + expect(gitFn).toHaveBeenCalledTimes(1); + }); + it("honours a custom attempt count via CHANGELOG_GITHUB_ATTEMPTS", async () => { - process.env.CHANGELOG_GITHUB_ATTEMPTS = "5"; + vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "5"); const githubFn = vi.fn().mockRejectedValue(new Error("Premature close")); const gitFn = vi.fn().mockResolvedValue("- git line"); @@ -63,6 +81,29 @@ describe("changelog withFallback", () => { expect(gitFn).toHaveBeenCalledTimes(1); }); + it("ignores an invalid attempt-count override and uses the default", async () => { + vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "-1"); + const githubFn = vi.fn().mockRejectedValue(new Error("Premature close")); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + await withFallback("release line", githubFn, gitFn); + + expect(githubFn).toHaveBeenCalledTimes(3); // -1 ignored -> default 3 + expect(gitFn).toHaveBeenCalledTimes(1); + }); + + it("tolerates a non-numeric retry-delay override without producing NaN waits", async () => { + vi.stubEnv("CHANGELOG_GITHUB_RETRY_MS", "not-a-number"); + vi.stubEnv("CHANGELOG_GITHUB_ATTEMPTS", "1"); // 1 attempt -> no sleep path + const githubFn = vi.fn().mockRejectedValue(new Error("Premature close")); + const gitFn = vi.fn().mockResolvedValue("- git line"); + + const result = await withFallback("release line", githubFn, gitFn); + + expect(result).toBe("- git line"); + expect(githubFn).toHaveBeenCalledTimes(1); + }); + it("exposes getReleaseLine and getDependencyReleaseLine for changesets", () => { expect(typeof changelog.getReleaseLine).toBe("function"); expect(typeof changelog.getDependencyReleaseLine).toBe("function");