diff --git a/.changeset/bold-views-kiss.md b/.changeset/bold-views-kiss.md new file mode 100644 index 0000000..69a8914 --- /dev/null +++ b/.changeset/bold-views-kiss.md @@ -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". diff --git a/.changeset/changelog.cjs b/.changeset/changelog.cjs new file mode 100644 index 0000000..9c48cc3 --- /dev/null +++ b/.changeset/changelog.cjs @@ -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; + +// 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, + }; +} + +// 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(); +} + +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..a103810 --- /dev/null +++ b/.changeset/changelog.test.ts @@ -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"); + }); +}); 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",