diff --git a/.changeset/clever-candles-cut.md b/.changeset/clever-candles-cut.md new file mode 100644 index 00000000000..216db512d7c --- /dev/null +++ b/.changeset/clever-candles-cut.md @@ -0,0 +1,6 @@ +--- +"electron-updater": patch +--- + +fix(updater): ensure full changelog includes only release notes up to the latest release + diff --git a/packages/electron-updater/src/providers/GitHubProvider.ts b/packages/electron-updater/src/providers/GitHubProvider.ts index 4535384718a..3b5afbda471 100644 --- a/packages/electron-updater/src/providers/GitHubProvider.ts +++ b/packages/electron-updater/src/providers/GitHubProvider.ts @@ -6,7 +6,7 @@ import { ResolvedUpdateFileInfo } from "../types" import { getChannelFilename, newBaseUrl, newUrlFromBase } from "../util" import { parseUpdateInfo, Provider, ProviderRuntimeOptions, resolveFiles } from "./Provider" -const hrefRegExp = /\/tag\/([^/]+)$/ +const hrefRegExp = /\/tag\/(v?[^/]+)$/ interface GithubUpdateInfo extends UpdateInfo { tag: string @@ -87,6 +87,10 @@ export class GitHubProvider extends BaseGitHubProvider { // This Release's Tag const hrefTag = hrefElement[1] + if (!semver.valid(hrefTag)) { + continue + } + //Get Channel from this release's tag const hrefChannel = (semver.prerelease(hrefTag)?.[0] as string) || null @@ -97,12 +101,14 @@ export class GitHubProvider extends BaseGitHubProvider { if (shouldFetchVersion && !isCustomChannel && !channelMismatch) { tag = hrefTag + latestRelease = element break } const isNextPreRelease = hrefChannel && hrefChannel === currentChannel if (isNextPreRelease) { tag = hrefTag + latestRelease = element break } } @@ -215,16 +221,41 @@ function getNoteValue(parent: XElement): string { return result === "No content." ? "" : result } -export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: any): string | Array | null { +export function computeReleaseNotes(currentVersion: semver.SemVer, isFullChangelog: boolean, feed: XElement, latestRelease: XElement): string | Array | null { if (!isFullChangelog) { return getNoteValue(latestRelease) } + const releaseVersionRegExp = /\/tag\/v?([^/]+)$/ + + let latestVersion: string | undefined = undefined + try { + latestVersion = releaseVersionRegExp.exec(latestRelease.element("link").attribute("href"))![1] + latestVersion = semver.valid(latestVersion) ? latestVersion : undefined + } catch { + // If we cannot parse the latest version, cntinue and return all release notes without filtering by version + } + + if (latestVersion == null) { + return null + } + const releaseNotes: Array = [] for (const release of feed.getElements("entry")) { - // noinspection TypeScriptValidateJSTypes - const versionRelease = /\/tag\/v?([^/]+)$/.exec(release.element("link").attribute("href"))![1] - if (semver.valid(versionRelease) && semver.lt(currentVersion, versionRelease)) { + let versionRelease: string | undefined = undefined + try { + versionRelease = releaseVersionRegExp.exec(release.element("link").attribute("href"))![1] + } catch { + continue + } + // check `semver.valid` to validate if an electron release, because some repositories can contain also non-electron releases (for example, with documentation or website updates) + if (!semver.valid(versionRelease)) { + continue + } + + const isGreaterThanCurrent = semver.gt(versionRelease, currentVersion.raw) + const isLessOrEqualThanLatest = semver.lte(versionRelease, latestVersion) + if (isGreaterThanCurrent && isLessOrEqualThanLatest) { releaseNotes.push({ version: versionRelease, note: getNoteValue(release), diff --git a/test/src/updater/updateUtilTest.ts b/test/src/updater/updateUtilTest.ts new file mode 100644 index 00000000000..f5aecadccfc --- /dev/null +++ b/test/src/updater/updateUtilTest.ts @@ -0,0 +1,207 @@ +import * as semver from "semver" +import { parseXml } from "builder-util-runtime" +import { computeReleaseNotes } from "electron-updater/src/providers/GitHubProvider" +import { expect } from "vitest" + +describe("GitHub Provider", () => { + describe("computeReleaseNotes", () => { + it("returns single release notes string when full changelog is false", () => { + const feed = parseXml(` + + + + Release 1.0.1 notes + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.0.0")!, false, feed, latest) + expect(result).toEqual("Release 1.0.1 notes") + }) + + it('treats "No content." as empty string when full changelog is false', () => { + const feed = parseXml(` + + + + No content. + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.0.0")!, false, feed, latest) + expect(result).toEqual("") + }) + + it("returns an array of release notes between currentVersion (exclusive) and latestVersion (inclusive), sorted desc", () => { + const feed = parseXml(` + + + + Notes v1.3.0 + + + + Notes 1.2.0 + + + + Notes 0.9.0 + + + + Docs + + + `) + const entries = feed.getElements("entry") + const latest = entries[0] // v1.3.0 + const result = computeReleaseNotes(semver.parse("1.0.0")!, true, feed, latest) + expect(result).deep.equal([ + { version: "1.3.0", note: "Notes v1.3.0" }, + { version: "1.2.0", note: "Notes 1.2.0" }, + ]) + }) + + it("returns null when latest release tag cannot be parsed to a version", () => { + const feed = parseXml(` + + + + Latest with invalid tag + + + + Should be ignored + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.0.0")!, true, feed, latest) + expect(result).toEqual(null) + }) + }) + + it("handles missing element when full changelog is false", () => { + const feed = parseXml(` + + + + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.0.0")!, false, feed, latest) + expect(result).toEqual("") + }) + + it("includes prerelease entries when they are > currentVersion and <= latestVersion", () => { + const feed = parseXml(` + + + + Release v1.2.0 + + + + Beta notes + + + + 1.1.0 notes + + + `) + const latest = feed.getElements("entry")[0] + const result = computeReleaseNotes(semver.parse("1.0.0")!, true, feed, latest) as any[] + expect(result).deep.equal([ + { version: "1.2.0", note: "Release v1.2.0" }, + { version: "1.2.0-beta.2", note: "Beta notes" }, + { version: "1.1.0", note: "1.1.0 notes" }, + ]) + }) + + it("excludes versions that are <= currentVersion", () => { + const feed = parseXml(` + + + + 1.2.0 + + + + 1.1.5 + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.2.0")!, true, feed, latest) + expect(result).toEqual([]) + }) + + it("supports tags with build metadata and sorts results correctly", () => { + const feed = parseXml(` + + + + 1.3.0 build + + + + 1.2.5 exp + + + + latest + + + `) + const latest = feed.getElements("entry")[2] + const result = computeReleaseNotes(semver.parse("1.2.0")!, true, feed, latest) as any[] + expect(result).deep.equal([ + { version: "1.3.0+build.1", note: "1.3.0 build" }, + { version: "1.3.0", note: "latest" }, + { version: "1.2.5+exp.sha.5114f85", note: "1.2.5 exp" }, + ]) + }) + + it("ignores entries whose link does not contain /tag/ (regex mismatch)", () => { + const feed = parseXml(` + + + + Bad link + + + + Good + + + `) + const latest = feed.getElements("entry")[1] + const result = computeReleaseNotes(semver.parse("1.3.0")!, true, feed, latest) as any[] + expect(result).deep.equal([{ version: "1.4.0", note: "Good" }]) + }) + + it("includes entry that is equal to latestVersion (latest included)", () => { + const feed = parseXml(` + + + + Latest notes + + + + 1.9.0 notes + + + `) + const latest = feed.element("entry") + const result = computeReleaseNotes(semver.parse("1.8.0")!, true, feed, latest) as any[] + expect(result).deep.equal([ + { version: "2.0.0", note: "Latest notes" }, + { version: "1.9.0", note: "1.9.0 notes" }, + ]) + }) +}) +