From c05837d3e8b7cc67635eaa027d64ae173ec2b7d5 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 18 May 2026 19:38:16 +0100 Subject: [PATCH 01/15] Scaffold `update-ghes-versions.ts` script --- pr-checks/update-ghes-versions.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 pr-checks/update-ghes-versions.ts diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts new file mode 100644 index 0000000000..cf8b205177 --- /dev/null +++ b/pr-checks/update-ghes-versions.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env npx tsx + +/** + * Updates src/api-compatibility.json with the current range of supported + * GitHub Enterprise Server versions by reading the releases.json file from + * an `enterprise-releases` checkout. + */ + +function main() {} + +// Only call `main` if this script was run directly. +if (require.main === module) { + main(); +} From 04d4fd51e91c01115baa17a4aa26dc1634d61862 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 18 May 2026 19:42:36 +0100 Subject: [PATCH 02/15] Add `semver` dependency --- package-lock.json | 7 ++++--- pr-checks/package.json | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6608c05602..0604abf7d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8320,9 +8320,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10404,6 +10404,7 @@ "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": ">=9.2.2", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "semver": "^7.8.0", "yaml": "^2.8.4" }, "devDependencies": { diff --git a/pr-checks/package.json b/pr-checks/package.json index 2741560f68..f21f86fd70 100644 --- a/pr-checks/package.json +++ b/pr-checks/package.json @@ -7,6 +7,7 @@ "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": ">=9.2.2", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", + "semver": "^7.8.0", "yaml": "^2.8.4" }, "devDependencies": { From 952a538c2494e0a870e46f57cb12d4319b048503 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 12:34:53 +0100 Subject: [PATCH 03/15] Add constant for `API_COMPATIBILITY_FILE` --- pr-checks/config.ts | 6 ++++++ pr-checks/update-ghes-versions.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 92c8beef0a..97858c776c 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -21,3 +21,9 @@ export const BUILTIN_LANGUAGES_FILE = path.join( "languages", "builtin.json", ); + +/** Path to the api-compatibility.json file. */ +export const API_COMPATIBILITY_FILE = path.join( + SOURCE_ROOT, + "api-compatibility.json", +); diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index cf8b205177..38f8cd9abc 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -6,6 +6,8 @@ * an `enterprise-releases` checkout. */ +import { API_COMPATIBILITY_FILE } from "./config"; + function main() {} // Only call `main` if this script was run directly. From d98bedfdeaba5517699673b9941676502483b8af Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 12:35:13 +0100 Subject: [PATCH 04/15] Add constant for `FIRST_SUPPORTED_RELEASE` --- pr-checks/update-ghes-versions.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 38f8cd9abc..784c12a054 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -6,8 +6,14 @@ * an `enterprise-releases` checkout. */ +import { type SemVer } from "semver"; +import * as semver from "semver"; + import { API_COMPATIBILITY_FILE } from "./config"; +/** The first GHES version that included Code Scanning. */ +const FIRST_SUPPORTED_RELEASE: SemVer = new semver.SemVer("2.22.0"); + function main() {} // Only call `main` if this script was run directly. From 4536424fcf7cf51b567c6ef9707462ae03638b84 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 12:36:02 +0100 Subject: [PATCH 05/15] Throw if `ENTERPRISE_RELEASES_PATH` is not set --- pr-checks/update-ghes-versions.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 784c12a054..2836015727 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -14,7 +14,19 @@ import { API_COMPATIBILITY_FILE } from "./config"; /** The first GHES version that included Code Scanning. */ const FIRST_SUPPORTED_RELEASE: SemVer = new semver.SemVer("2.22.0"); -function main() {} +/** Environment variables specific to this script. */ +export enum EnvVar { + ENTERPRISE_RELEASES_PATH = "ENTERPRISE_RELEASES_PATH", +} + +function main() { + const enterpriseReleasesPath = process.env[EnvVar.ENTERPRISE_RELEASES_PATH]; + if (!enterpriseReleasesPath) { + throw new Error( + `${EnvVar.ENTERPRISE_RELEASES_PATH} environment variable must be set`, + ); + } +} // Only call `main` if this script was run directly. if (require.main === module) { From da26a016eee901f2047ac74b00a9c175a3313c73 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 12:50:40 +0100 Subject: [PATCH 06/15] Add `readApiCompatibility` --- pr-checks/update-ghes-versions.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 2836015727..72e7fbe2f8 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -6,9 +6,13 @@ * an `enterprise-releases` checkout. */ +import * as fs from "node:fs"; + import { type SemVer } from "semver"; import * as semver from "semver"; +import * as json from "../src/json"; + import { API_COMPATIBILITY_FILE } from "./config"; /** The first GHES version that included Code Scanning. */ @@ -19,6 +23,36 @@ export enum EnvVar { ENTERPRISE_RELEASES_PATH = "ENTERPRISE_RELEASES_PATH", } +/** The JSON schema for `API_COMPATIBILITY_FILE`. */ +const apiCompatibilitySchema = { + minimumVersion: json.string, + maximumVersion: json.string, +} as const satisfies json.Schema; + +/** The type representing the expected contents of `API_COMPATIBILITY_FILE`. */ +type ApiCompatibility = json.FromSchema; + +/** Reads the current contents of the `API_COMPATIBILITY_FILE` file. */ +export function readApiCompatibility(): ApiCompatibility { + const apiCompatibilityData: unknown = JSON.parse( + fs.readFileSync(API_COMPATIBILITY_FILE, "utf8"), + ); + + if (!json.isObject(apiCompatibilityData)) { + throw new Error( + `Expected '${API_COMPATIBILITY_FILE}' to contain an object.`, + ); + } + if (!json.validateSchema(apiCompatibilitySchema, apiCompatibilityData)) { + throw new Error( + `The contents of '${API_COMPATIBILITY_FILE}' do not match the expected JSON schema.`, + ); + } + + return apiCompatibilityData; +} + + function main() { const enterpriseReleasesPath = process.env[EnvVar.ENTERPRISE_RELEASES_PATH]; if (!enterpriseReleasesPath) { @@ -26,6 +60,9 @@ function main() { `${EnvVar.ENTERPRISE_RELEASES_PATH} environment variable must be set`, ); } + + // Get the version compatibility data stored in the repo. + const apiCompatibilityData = readApiCompatibility(); } // Only call `main` if this script was run directly. From f5808271b06681af837cfdfee1eca4b91230337d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 13:15:51 +0100 Subject: [PATCH 07/15] Add `readEnterpriseReleases` --- pr-checks/update-ghes-versions.ts | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 72e7fbe2f8..52b3367a26 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -7,6 +7,7 @@ */ import * as fs from "node:fs"; +import * as path from "node:path"; import { type SemVer } from "semver"; import * as semver from "semver"; @@ -52,6 +53,48 @@ export function readApiCompatibility(): ApiCompatibility { return apiCompatibilityData; } +/** The JSON schema for entries in the `releases.json` file. */ +const releaseDataSchema = { + feature_freeze: json.string, + end: json.string, +} as const satisfies json.Schema; + +/** The type representing entries in the `releases.json` file. */ +export type ReleaseData = json.FromSchema; + +/** A mapping from GHES releases to release information. */ +export type EnterpriseReleases = Record; + +/** Reads information about GHES releases. */ +export function readEnterpriseReleases( + enterpriseReleasesPath: string, +): EnterpriseReleases { + const releaseFilePath = path.join(enterpriseReleasesPath, "releases.json"); + const releases: unknown = JSON.parse( + fs.readFileSync(releaseFilePath, "utf8"), + ); + + if (!json.isObject(releases)) { + throw new Error(`Expected '${releaseFilePath}' to contain an object.`); + } + + // Remove GHES version using a previous version numbering scheme. + delete releases["11.10"]; + + // Validate that the object satisfies the schema. + for (const [, releaseData] of Object.entries(releases)) { + if (!json.isObject(releaseData)) { + throw new Error( + `Expected release data to be an object, but it is ${typeof releaseData}.`, + ); + } + if (!json.validateSchema(releaseDataSchema, releaseData)) { + throw new Error("Expected release data to satisfy schema."); + } + } + + return releases; +} function main() { const enterpriseReleasesPath = process.env[EnvVar.ENTERPRISE_RELEASES_PATH]; @@ -63,6 +106,9 @@ function main() { // Get the version compatibility data stored in the repo. const apiCompatibilityData = readApiCompatibility(); + + // Get the GHES release information. + const releases = readEnterpriseReleases(enterpriseReleasesPath); } // Only call `main` if this script was run directly. From 71b697dd8b66782914ce9f72d5184b81e04f3cf6 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 13:16:56 +0100 Subject: [PATCH 08/15] Add helpers for GHES versions --- pr-checks/update-ghes-versions.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 52b3367a26..8d171cfdc5 100644 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -24,6 +24,26 @@ export enum EnvVar { ENTERPRISE_RELEASES_PATH = "ENTERPRISE_RELEASES_PATH", } +/** + * The semver specification requires three numeric components, but GHES release families + * only have two. This function uses `semver.coerce` to first coerce the version string + * into an acceptable input for `semver.parse`. E.g. `3.10` becomes `3.10.0`. + */ +export function parseEnterpriseVersion(val: string): SemVer | null { + return semver.parse(semver.coerce(val)); +} + +/** + * Mirroring `parseEnterpriseVersion`, this function returns only the major and minor + * version components from `ver`. + */ +export function printEnterpriseVersion(ver: SemVer) { + if (ver.patch === 0) { + return `${ver.major}.${ver.minor}`; + } + return ver.toString(); +} + /** The JSON schema for `API_COMPATIBILITY_FILE`. */ const apiCompatibilitySchema = { minimumVersion: json.string, From 51cc08af6fe83e6ea15b1b2de56eced26b76c913 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 13:51:56 +0100 Subject: [PATCH 09/15] Add `determineSupportedRange` --- pr-checks/update-ghes-versions.ts | 103 ++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) mode change 100644 => 100755 pr-checks/update-ghes-versions.ts diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts old mode 100644 new mode 100755 index 8d171cfdc5..0508f9897e --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -116,6 +116,85 @@ export function readEnterpriseReleases( return releases; } +/** Adds `weeks`-many weeks to the UTC date of `date`. */ +export function addWeeks(date: Date, weeks: number): Date { + const result = new Date(date); + result.setUTCDate(date.getUTCDate() + weeks * 7); + return result; +} + +/** Determines the current range of GHES versions we should support. */ +export function determineSupportedRange( + apiCompatibilityData: ApiCompatibility, + releases: EnterpriseReleases, +): ApiCompatibility { + // Our goal is to identify the oldest and newest GHES release we should support. + // We begin with `oldestSupportRelease = undefined` so that we determine the + // minimum from scratch and don't stick to `apiCompatibilityData.minimumVersion` + // when it is no longer supported. + // For `newestSupportedRelease`, we assume that `apiCompatibilityData.maximumVersion` + // is guaranteed to not be outdated. + let oldestSupportedRelease: SemVer | undefined; + let newestSupportedRelease = parseEnterpriseVersion( + apiCompatibilityData.maximumVersion, + ); + + if (newestSupportedRelease === null) { + throw new Error( + `${apiCompatibilityData.maximumVersion} is not a valid semantic version.`, + ); + } + + const today = new Date(); + + // NOTE: We deliberately omit including any data from `releases` in the error messages below. + + for (const [releaseVersionString, releaseData] of Object.entries(releases)) { + const releaseVersion = parseEnterpriseVersion(releaseVersionString); + + if (releaseVersion === null) { + throw new Error("Invalid enterprise release version."); + } + + // Ignore GHES releases older than `FIRST_SUPPORTED_RELEASE`. + if (semver.compare(releaseVersion, FIRST_SUPPORTED_RELEASE) < 0) { + continue; + } + + // If the GHES release is newer than the current, newest release we support, + // check whether at least two weeks have passed since the feature freeze date + // so we don't set `newestSupportedRelease` too early. + if (semver.compare(releaseVersion, newestSupportedRelease) > 0) { + const featureFreezeDate = new Date(releaseData.feature_freeze); + if (featureFreezeDate < addWeeks(today, 2)) { + newestSupportedRelease = releaseVersion; + } + } + + if ( + oldestSupportedRelease === undefined || + semver.compare(releaseVersion, oldestSupportedRelease) < 0 + ) { + const endOfLifeDate = new Date(releaseData.end); + // The GHES version is not actually end of life until the end of the day + // specified by `endOfLifeDate`. Wait an extra week to be safe. + const isEndOfLife = today > addWeeks(endOfLifeDate, 1); + if (!isEndOfLife) { + oldestSupportedRelease = releaseVersion; + } + } + } + + if (!oldestSupportedRelease) { + throw new Error("Could not determine oldest supported release."); + } + + return { + maximumVersion: printEnterpriseVersion(newestSupportedRelease), + minimumVersion: printEnterpriseVersion(oldestSupportedRelease), + }; +} + function main() { const enterpriseReleasesPath = process.env[EnvVar.ENTERPRISE_RELEASES_PATH]; if (!enterpriseReleasesPath) { @@ -129,6 +208,30 @@ function main() { // Get the GHES release information. const releases = readEnterpriseReleases(enterpriseReleasesPath); + + // Determine the supported range. + const newCompatibilityData: ApiCompatibility = determineSupportedRange( + apiCompatibilityData, + releases, + ); + + // If the version range has changed, write the updates to `API_COMPATIBILITY_FILE`. + if ( + newCompatibilityData.minimumVersion !== + apiCompatibilityData.minimumVersion || + newCompatibilityData.maximumVersion !== apiCompatibilityData.maximumVersion + ) { + const data = JSON.stringify(newCompatibilityData); + fs.writeFileSync(API_COMPATIBILITY_FILE, `${data}\n`); + + console.log( + `Updated '${path.basename(API_COMPATIBILITY_FILE)}': ${newCompatibilityData.minimumVersion} - ${newCompatibilityData.maximumVersion}`, + ); + } else { + console.log( + `No changes, not writing to '${path.basename(API_COMPATIBILITY_FILE)}'.`, + ); + } } // Only call `main` if this script was run directly. From 15f19e18702e542de041230c6a334dfc968bc78b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 13:59:49 +0100 Subject: [PATCH 10/15] Add `date` as a parameter for `determineSupportedRange` --- pr-checks/update-ghes-versions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 0508f9897e..2e458711ff 100755 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -125,6 +125,7 @@ export function addWeeks(date: Date, weeks: number): Date { /** Determines the current range of GHES versions we should support. */ export function determineSupportedRange( + today: Date, apiCompatibilityData: ApiCompatibility, releases: EnterpriseReleases, ): ApiCompatibility { @@ -145,8 +146,6 @@ export function determineSupportedRange( ); } - const today = new Date(); - // NOTE: We deliberately omit including any data from `releases` in the error messages below. for (const [releaseVersionString, releaseData] of Object.entries(releases)) { @@ -211,6 +210,7 @@ function main() { // Determine the supported range. const newCompatibilityData: ApiCompatibility = determineSupportedRange( + new Date(), apiCompatibilityData, releases, ); From f9feddd874d4b3da054c2d5a50bf973b7c0dcbb9 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 14:03:20 +0100 Subject: [PATCH 11/15] Add tests for `update-ghes-versions.ts` --- pr-checks/update-ghes-versions.test.ts | 204 +++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 pr-checks/update-ghes-versions.test.ts diff --git a/pr-checks/update-ghes-versions.test.ts b/pr-checks/update-ghes-versions.test.ts new file mode 100644 index 0000000000..187c067c37 --- /dev/null +++ b/pr-checks/update-ghes-versions.test.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env npx tsx + +/* + * Tests for the update-ghes-versions.ts script + */ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + addWeeks, + determineSupportedRange, + type EnterpriseReleases, + parseEnterpriseVersion, + printEnterpriseVersion, +} from "./update-ghes-versions"; + +describe("parseEnterpriseVersion", async () => { + await it("parses a two-component version string", () => { + const ver = parseEnterpriseVersion("3.10"); + assert.notEqual(ver, null); + assert.equal(ver!.major, 3); + assert.equal(ver!.minor, 10); + assert.equal(ver!.patch, 0); + }); + + await it("parses a three-component version string", () => { + const ver = parseEnterpriseVersion("3.10.2"); + assert.notEqual(ver, null); + assert.equal(ver!.major, 3); + assert.equal(ver!.minor, 10); + assert.equal(ver!.patch, 2); + }); + + await it("returns null for invalid input", () => { + assert.equal(parseEnterpriseVersion("not-a-version"), null); + }); +}); + +describe("printEnterpriseVersion", async () => { + await it("prints only major.minor when patch is 0", () => { + const ver = parseEnterpriseVersion("3.10")!; + assert.equal(printEnterpriseVersion(ver), "3.10"); + }); + + await it("includes patch when non-zero", () => { + const ver = parseEnterpriseVersion("3.10.2")!; + assert.equal(printEnterpriseVersion(ver), "3.10.2"); + }); +}); + +describe("addWeeks", async () => { + await it("adds weeks to a date", () => { + const date = new Date("2025-01-01T00:00:00Z"); + const result = addWeeks(date, 2); + assert.equal(result.toISOString(), "2025-01-15T00:00:00.000Z"); + }); + + await it("does not mutate the original date", () => { + const date = new Date("2025-01-01T00:00:00Z"); + addWeeks(date, 2); + assert.equal(date.toISOString(), "2025-01-01T00:00:00.000Z"); + }); +}); + +/** + * Helper to build a release entry with a feature freeze and end-of-life date. + * Dates are ISO date strings (e.g. "2025-06-01"). + */ +function release(featureFreeze: string, end: string) { + return { feature_freeze: featureFreeze, end }; +} + +describe("determineSupportedRange", async () => { + // A fixed "today" for deterministic tests. + const today = new Date("2025-06-15"); + + const farPastEnd = "2020-01-01"; + const farFutureEnd = "2099-12-31"; + const farPastFreeze = "2020-01-01"; + const farFutureFreeze = "2099-12-31"; + + await it("returns the only supported release as both min and max", () => { + const releases: EnterpriseReleases = { + "3.10": release(farPastFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.10" }, + releases, + ); + assert.equal(result.minimumVersion, "3.10"); + assert.equal(result.maximumVersion, "3.10"); + }); + + await it("determines the range from multiple supported releases", () => { + const releases: EnterpriseReleases = { + "3.10": release(farPastFreeze, farFutureEnd), + "3.11": release(farPastFreeze, farFutureEnd), + "3.12": release(farPastFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.12" }, + releases, + ); + assert.equal(result.minimumVersion, "3.10"); + assert.equal(result.maximumVersion, "3.12"); + }); + + await it("drops an end-of-life release from the minimum", () => { + const releases: EnterpriseReleases = { + // 3.10 has been end of life for a long time. + "3.10": release(farPastFreeze, farPastEnd), + "3.11": release(farPastFreeze, farFutureEnd), + "3.12": release(farPastFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.12" }, + releases, + ); + assert.equal(result.minimumVersion, "3.11"); + assert.equal(result.maximumVersion, "3.12"); + }); + + await it("bumps the maximum when a newer release's feature freeze has passed", () => { + const releases: EnterpriseReleases = { + "3.10": release(farPastFreeze, farFutureEnd), + "3.11": release(farPastFreeze, farFutureEnd), + // 3.12 has a feature freeze far in the past, so it should be picked up. + "3.12": release(farPastFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + // The stored maximum is 3.11, but 3.12 should be picked up. + { minimumVersion: "3.10", maximumVersion: "3.11" }, + releases, + ); + assert.equal(result.minimumVersion, "3.10"); + assert.equal(result.maximumVersion, "3.12"); + }); + + await it("does not bump the maximum when feature freeze is far in the future", () => { + const releases: EnterpriseReleases = { + "3.10": release(farPastFreeze, farFutureEnd), + "3.11": release(farPastFreeze, farFutureEnd), + // 3.12 has a feature freeze far in the future, so it should NOT be picked up. + "3.12": release(farFutureFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.11" }, + releases, + ); + assert.equal(result.minimumVersion, "3.10"); + assert.equal(result.maximumVersion, "3.11"); + }); + + await it("ignores releases older than the first supported release (2.22)", () => { + const releases: EnterpriseReleases = { + "2.21": release(farPastFreeze, farFutureEnd), + "3.10": release(farPastFreeze, farFutureEnd), + "3.11": release(farPastFreeze, farFutureEnd), + }; + const result = determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.11" }, + releases, + ); + // 2.21 is older than 2.22, so it should be ignored — 3.10 remains the minimum. + assert.equal(result.minimumVersion, "3.10"); + assert.equal(result.maximumVersion, "3.11"); + }); + + await it("throws when no supported releases remain", () => { + const releases: EnterpriseReleases = { + // All releases are end of life. + "3.10": release(farPastFreeze, farPastEnd), + "3.11": release(farPastFreeze, farPastEnd), + }; + assert.throws( + () => + determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "3.11" }, + releases, + ), + /Could not determine oldest supported release/, + ); + }); + + await it("throws when maximumVersion is not a valid version", () => { + assert.throws( + () => + determineSupportedRange( + today, + { minimumVersion: "3.10", maximumVersion: "invalid" }, + {}, + ), + /is not a valid semantic version/, + ); + }); +}); From 5aed9f7d6474db332d9f131acf29f65467de602d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 14:08:22 +0100 Subject: [PATCH 12/15] Update workflow to use `update-ghes-versions.ts` --- ...e-supported-enterprise-server-versions.yml | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-supported-enterprise-server-versions.yml b/.github/workflows/update-supported-enterprise-server-versions.yml index 4cead58f4f..1727ea8f5d 100644 --- a/.github/workflows/update-supported-enterprise-server-versions.yml +++ b/.github/workflows/update-supported-enterprise-server-versions.yml @@ -9,7 +9,7 @@ on: - main paths: - .github/workflows/update-supported-enterprise-server-versions.yml - - .github/workflows/update-supported-enterprise-server-versions/update.py + - pr-checks/update-ghes-versions.ts jobs: update-supported-enterprise-server-versions: @@ -22,12 +22,18 @@ jobs: pull-requests: write # needed to create pull request steps: - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - name: Checkout CodeQL Action uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + - name: Checkout Enterprise Releases uses: actions/checkout@v6 with: @@ -35,18 +41,18 @@ jobs: token: ${{ secrets.ENTERPRISE_RELEASE_TOKEN }} path: ${{ github.workspace }}/enterprise-releases/ sparse-checkout: releases.json + - name: Update Supported Enterprise Server Versions + working-directory: pr-checks run: | - cd ./.github/workflows/update-supported-enterprise-server-versions/ - python3 -m pip install pipenv - pipenv install - pipenv run ./update.py + npx tsx update-ghes-versions.ts rm --recursive "$ENTERPRISE_RELEASES_PATH" - npm ci - npm run build env: ENTERPRISE_RELEASES_PATH: ${{ github.workspace }}/enterprise-releases/ + - name: Rebuild + run: npm run build + - name: Update git config run: | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" From 3e8588443487f8f5ec06d9ab1f6a51e21644dc75 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 14:08:51 +0100 Subject: [PATCH 13/15] Remove Python version --- .../Pipfile | 9 ---- .../Pipfile.lock | 27 ---------- .../update.py | 51 ------------------- 3 files changed, 87 deletions(-) delete mode 100644 .github/workflows/update-supported-enterprise-server-versions/Pipfile delete mode 100644 .github/workflows/update-supported-enterprise-server-versions/Pipfile.lock delete mode 100755 .github/workflows/update-supported-enterprise-server-versions/update.py diff --git a/.github/workflows/update-supported-enterprise-server-versions/Pipfile b/.github/workflows/update-supported-enterprise-server-versions/Pipfile deleted file mode 100644 index e892e49219..0000000000 --- a/.github/workflows/update-supported-enterprise-server-versions/Pipfile +++ /dev/null @@ -1,9 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -semver = "*" diff --git a/.github/workflows/update-supported-enterprise-server-versions/Pipfile.lock b/.github/workflows/update-supported-enterprise-server-versions/Pipfile.lock deleted file mode 100644 index 3357cf2864..0000000000 --- a/.github/workflows/update-supported-enterprise-server-versions/Pipfile.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "e3ba923dcb4888e05de5448c18a732bf40197e80fabfa051a61c01b22c504879" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "semver": { - "hashes": [ - "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", - "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f" - ], - "index": "pypi", - "version": "==2.13.0" - } - }, - "develop": {} -} diff --git a/.github/workflows/update-supported-enterprise-server-versions/update.py b/.github/workflows/update-supported-enterprise-server-versions/update.py deleted file mode 100755 index bdda902cd0..0000000000 --- a/.github/workflows/update-supported-enterprise-server-versions/update.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env python3 -import datetime -import json -import os -import pathlib - -import semver - -_API_COMPATIBILITY_PATH = pathlib.Path(__file__).absolute().parents[3] / "src" / "api-compatibility.json" -_ENTERPRISE_RELEASES_PATH = pathlib.Path(os.environ["ENTERPRISE_RELEASES_PATH"]) -_RELEASE_FILE_PATH = _ENTERPRISE_RELEASES_PATH / "releases.json" -_FIRST_SUPPORTED_RELEASE = semver.VersionInfo.parse("2.22.0") # Versions older than this did not include Code Scanning. - -def main(): - api_compatibility_data = json.loads(_API_COMPATIBILITY_PATH.read_text()) - - releases = json.loads(_RELEASE_FILE_PATH.read_text()) - - # Remove GHES version using a previous version numbering scheme. - if "11.10" in releases: - del releases["11.10"] - - oldest_supported_release = None - newest_supported_release = semver.VersionInfo.parse(api_compatibility_data["maximumVersion"] + ".0") - - for release_version_string, release_data in releases.items(): - release_version = semver.VersionInfo.parse(release_version_string + ".0") - if release_version < _FIRST_SUPPORTED_RELEASE: - continue - - if release_version > newest_supported_release: - feature_freeze_date = datetime.date.fromisoformat(release_data["feature_freeze"]) - if feature_freeze_date < datetime.date.today() + datetime.timedelta(weeks=2): - newest_supported_release = release_version - - if oldest_supported_release is None or release_version < oldest_supported_release: - end_of_life_date = datetime.date.fromisoformat(release_data["end"]) - # The GHES version is not actually end of life until the end of the day specified by - # `end_of_life_date`. Wait an extra week to be safe. - is_end_of_life = datetime.date.today() > end_of_life_date + datetime.timedelta(weeks=1) - if not is_end_of_life: - oldest_supported_release = release_version - - api_compatibility_data = { - "minimumVersion": f"{oldest_supported_release.major}.{oldest_supported_release.minor}", - "maximumVersion": f"{newest_supported_release.major}.{newest_supported_release.minor}", - } - _API_COMPATIBILITY_PATH.write_text(json.dumps(api_compatibility_data, sort_keys=True) + "\n") - -if __name__ == "__main__": - main() From 74374a38936300557019bb7cb361fac05c3c22e1 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 14:15:04 +0100 Subject: [PATCH 14/15] Rebuild (newer `semver` version) --- lib/entry-points.js | 43 +++++++++++++++++++++++++++++++++++++++++++ lib/upload-lib.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/lib/entry-points.js b/lib/entry-points.js index 079b8eee5a..3f0bc15480 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -26704,6 +26704,47 @@ var require_coerce = __commonJS({ } }); +// node_modules/semver/functions/truncate.js +var require_truncate = __commonJS({ + "node_modules/semver/functions/truncate.js"(exports2, module2) { + "use strict"; + var parse2 = require_parse2(); + var constants = require_constants6(); + var SemVer = require_semver(); + var truncate = (version, truncation, options) => { + if (!constants.RELEASE_TYPES.includes(truncation)) { + return null; + } + const clonedVersion = cloneInputVersion(version, options); + return clonedVersion && doTruncation(clonedVersion, truncation); + }; + var cloneInputVersion = (version, options) => { + const versionStringToParse = version instanceof SemVer ? version.version : version; + return parse2(versionStringToParse, options); + }; + var doTruncation = (version, truncation) => { + if (isPrerelease(truncation)) { + return version.version; + } + version.prerelease = []; + switch (truncation) { + case "major": + version.minor = 0; + version.patch = 0; + break; + case "minor": + version.patch = 0; + break; + } + return version.format(); + }; + var isPrerelease = (type2) => { + return type2.startsWith("pre"); + }; + module2.exports = truncate; + } +}); + // node_modules/semver/internal/lrucache.js var require_lrucache = __commonJS({ "node_modules/semver/internal/lrucache.js"(exports2, module2) { @@ -27738,6 +27779,7 @@ var require_semver2 = __commonJS({ var lte = require_lte(); var cmp = require_cmp(); var coerce3 = require_coerce(); + var truncate = require_truncate(); var Comparator = require_comparator(); var Range2 = require_range(); var satisfies2 = require_satisfies(); @@ -27776,6 +27818,7 @@ var require_semver2 = __commonJS({ lte, cmp, coerce: coerce3, + truncate, Comparator, Range: Range2, satisfies: satisfies2, diff --git a/lib/upload-lib.js b/lib/upload-lib.js index d355fedf43..66dfb4a73a 100644 --- a/lib/upload-lib.js +++ b/lib/upload-lib.js @@ -28009,6 +28009,47 @@ var require_coerce = __commonJS({ } }); +// node_modules/semver/functions/truncate.js +var require_truncate = __commonJS({ + "node_modules/semver/functions/truncate.js"(exports2, module2) { + "use strict"; + var parse2 = require_parse2(); + var constants = require_constants6(); + var SemVer = require_semver(); + var truncate = (version, truncation, options) => { + if (!constants.RELEASE_TYPES.includes(truncation)) { + return null; + } + const clonedVersion = cloneInputVersion(version, options); + return clonedVersion && doTruncation(clonedVersion, truncation); + }; + var cloneInputVersion = (version, options) => { + const versionStringToParse = version instanceof SemVer ? version.version : version; + return parse2(versionStringToParse, options); + }; + var doTruncation = (version, truncation) => { + if (isPrerelease(truncation)) { + return version.version; + } + version.prerelease = []; + switch (truncation) { + case "major": + version.minor = 0; + version.patch = 0; + break; + case "minor": + version.patch = 0; + break; + } + return version.format(); + }; + var isPrerelease = (type2) => { + return type2.startsWith("pre"); + }; + module2.exports = truncate; + } +}); + // node_modules/semver/internal/lrucache.js var require_lrucache = __commonJS({ "node_modules/semver/internal/lrucache.js"(exports2, module2) { @@ -29043,6 +29084,7 @@ var require_semver2 = __commonJS({ var lte = require_lte(); var cmp = require_cmp(); var coerce3 = require_coerce(); + var truncate = require_truncate(); var Comparator = require_comparator(); var Range2 = require_range(); var satisfies2 = require_satisfies(); @@ -29081,6 +29123,7 @@ var require_semver2 = __commonJS({ lte, cmp, coerce: coerce3, + truncate, Comparator, Range: Range2, satisfies: satisfies2, From 7ba697a3ec70911c8056f5a58199d02016517293 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 19 May 2026 14:32:00 +0100 Subject: [PATCH 15/15] Fix comment and clear time component --- pr-checks/update-ghes-versions.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pr-checks/update-ghes-versions.ts b/pr-checks/update-ghes-versions.ts index 2e458711ff..055424dff1 100755 --- a/pr-checks/update-ghes-versions.ts +++ b/pr-checks/update-ghes-versions.ts @@ -129,6 +129,9 @@ export function determineSupportedRange( apiCompatibilityData: ApiCompatibility, releases: EnterpriseReleases, ): ApiCompatibility { + // We only care about the UTC date component. + today.setUTCHours(0, 0, 0, 0); + // Our goal is to identify the oldest and newest GHES release we should support. // We begin with `oldestSupportRelease = undefined` so that we determine the // minimum from scratch and don't stick to `apiCompatibilityData.minimumVersion` @@ -160,9 +163,9 @@ export function determineSupportedRange( continue; } - // If the GHES release is newer than the current, newest release we support, - // check whether at least two weeks have passed since the feature freeze date - // so we don't set `newestSupportedRelease` too early. + // Set `newestSupportedRelease` to a GHES release if it has a greater version + // than the current `newestSupportedRelease` and the feature freeze has + // already happened or will be in the next two weeks. if (semver.compare(releaseVersion, newestSupportedRelease) > 0) { const featureFreezeDate = new Date(releaseData.feature_freeze); if (featureFreezeDate < addWeeks(today, 2)) {