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
7 changes: 7 additions & 0 deletions .changeset/bold-views-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +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".
103 changes: 103 additions & 0 deletions .changeset/changelog.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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;
Comment on lines +28 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MEDIUM RISK

Suggestion: The import logic may fail if the required modules do not have a .default export. Consider using a fallback to the module itself for better compatibility.

Suggested change
const github = require("@changesets/changelog-github").default;
const git = require("@changesets/changelog-git").default;
const github = require("@changesets/changelog-github").default || require("@changesets/changelog-github");
const git = require("@changesets/changelog-git").default || require("@changesets/changelog-git");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Holding off on this one. Both deps are exact-pinned (@changesets/changelog-github@0.6.0, @changesets/changelog-git@0.2.1) and I verified each exports only default (Object.keys(require(...))['default']). Changesets' own loader (apply-release-plan) also unwraps .default, so if it were ever missing the whole release would break regardless — and || require(...) would just resolve to the namespace object (which has no getReleaseLine), so it wouldn't actually add safety. Keeping the explicit .default.

🤖 Generated by /pr-fixup command


// Read tunables at call time (not module load) so tests can override them via
// 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 {
// 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,
};
}
Comment on lines +35 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If process.env.CHANGELOG_GITHUB_RETRY_MS is defined but is not a valid number (e.g., "abc"), Number(process.env.CHANGELOG_GITHUB_RETRY_MS) will return NaN. This causes sleep(retryDelayMs * attempt) to receive NaN, which can lead to unexpected timer behavior in Node.js.

We should validate that both parsed values are valid non-negative integers, falling back to safe defaults if they are not.

function getConfig() {
  const attempts = Number(process.env.CHANGELOG_GITHUB_ATTEMPTS);
  const delay = Number(process.env.CHANGELOG_GITHUB_RETRY_MS);
  return {
    maxAttempts: Number.isInteger(attempts) && attempts >= 0 ? attempts : 3,
    retryDelayMs: Number.isInteger(delay) && delay >= 0 ? delay : 1000,
  };
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — getConfig() now validates both tunables: maxAttempts must be an integer ≥ 1 and retryDelayMs must be finite and ≥ 0, otherwise it falls back to the safe defaults (3 / 1000). Added tests for an invalid attempt count and a non-numeric delay.

🤖 Generated by /pr-fixup command


// 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
// 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}): ${errorMessage(error)}. Retrying...`,
);
// Linear backoff: 1x, 2x, ... the base delay.
await sleep(retryDelayMs * attempt);
}
}
}

console.warn(
`[changelog] GitHub enrichment for ${label} failed after ${maxAttempts} ` +
`attempts: ${errorMessage(lastError)}. Falling back to a plain ` +
`(git) changelog entry for this release.`,
);
return gitFn();
}
Comment on lines +56 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If githubFn() rejects with a non-Error value (such as null, undefined, or a plain string), accessing error.message will throw a TypeError (e.g., Cannot read properties of null (reading 'message')). Since this TypeError occurs inside the catch block, it will propagate uncaught and crash the entire changelog generation process, defeating the resilience of this wrapper.

Additionally, if maxAttempts is configured to be 0 or negative, the loop will not execute, leaving lastError as undefined and causing the final log message to print attempts: undefined.

We should safely extract the error message using optional chaining/fallback (error?.message || String(error)) and add an early return if maxAttempts <= 0.

async function withFallback(label, githubFn, gitFn) {
  const { maxAttempts, retryDelayMs } = getConfig();
  if (maxAttempts <= 0) {
    return gitFn();
  }

  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await githubFn();
    } catch (error) {
      lastError = error;
      if (attempt < maxAttempts) {
        const errMsg = error?.message || String(error);
        console.warn(
          "[changelog] GitHub enrichment for " + label + " failed " +
            "(attempt " + attempt + "/" + maxAttempts + "): " + errMsg + ". Retrying..."
        );
        // Linear backoff: 1x, 2x, ... the base delay.
        await sleep(retryDelayMs * attempt);
      }
    }
  }

  const finalErrMsg = lastError?.message || String(lastError);
  console.warn(
    "[changelog] GitHub enrichment for " + label + " failed after " + maxAttempts + " " +
      "attempts: " + finalErrMsg + ". Falling back to a plain " +
      "(git) changelog entry for this release."
  );
  return gitFn();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — fixed. Added an errorMessage() helper ((err && err.message) || String(err)) used in both warnings so a non-Error rejection can't throw inside the catch and defeat the resilience. getConfig() now also clamps maxAttempts to ≥ 1, so the loop always runs at least once (no more lastError: undefined). Added a test for the non-Error rejection path.

🤖 Generated by /pr-fixup command


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 };
111 changes: 111 additions & 0 deletions .changeset/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, afterEach, 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. 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");

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("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 () => {
vi.stubEnv("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("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");
});
});
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
15 changes: 15 additions & 0 deletions SPECS/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading