From dab0ab7cb7869ab4383e9bcef08aada6ee7cbaf7 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 23 Jun 2026 19:24:34 +0100 Subject: [PATCH 01/56] Make `undici` an explicit dependency --- package-lock.json | 2 +- package.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 208964cbcc..7acded671f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "long": "^5.3.2", "node-forge": "^1.4.0", "semver": "^7.8.5", + "undici": "^6.24.0", "uuid": "^14.0.1" }, "devDependencies": { @@ -9363,7 +9364,6 @@ "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", "engines": { "node": ">=18.17" } diff --git a/package.json b/package.json index b1190b0438..e979911d3b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "long": "^5.3.2", "node-forge": "^1.4.0", "semver": "^7.8.5", - "uuid": "^14.0.1" + "uuid": "^14.0.1", + "undici": "^6.24.0" }, "devDependencies": { "@ava/typescript": "6.0.0", From 8763bac62542e9336524c8ccd20b3f713681605d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 15:51:15 +0100 Subject: [PATCH 02/56] Add `RegistryProxyVars` enum --- src/environment.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/environment.ts b/src/environment.ts index 80759d6d61..9d52a903ff 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -1,3 +1,13 @@ +/** + * Environment variables used by Default Setup to communicate the private registry proxy configuration. + */ +export enum RegistryProxyVars { + PROXY_HOST = "CODEQL_PROXY_HOST", + PROXY_PORT = "CODEQL_PROXY_PORT", + PROXY_CA_CERTIFICATE = "CODEQL_PROXY_CA_CERTIFICATE", + PROXY_URLS = "CODEQL_PROXY_URLS", +} + /** * Environment variables used by the CodeQL Action. * @@ -202,7 +212,7 @@ export enum ActionsEnvVars { } /** A type representing all known environment variables. */ -export type KnownEnvVar = EnvVar | ActionsEnvVars; +export type KnownEnvVar = EnvVar | ActionsEnvVars | RegistryProxyVars; /** * Gets an environment variable, but throws an error if it is not set. From 5172487de5b6b3080b199f2bc2a6665a85893dba Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 15:51:33 +0100 Subject: [PATCH 03/56] Allow `getTestEnv` to be parameterised over initial env --- src/testing-utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/testing-utils.ts b/src/testing-utils.ts index 60e8fb5540..f630082be4 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -178,8 +178,7 @@ export function makeMacro( return wrapper; } -export function getTestEnv(): Env { - const testEnv: NodeJS.ProcessEnv = {}; +export function getTestEnv(testEnv: NodeJS.ProcessEnv = {}): Env { return getEnv(testEnv); } From a29dee455ce5a1f1c42c8f99f90d7914fa1b8060 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 8 Jul 2026 16:24:23 +0100 Subject: [PATCH 04/56] Add minimal proxy init code --- lib/entry-points.js | 39 +++++++++++++++++++++++------ src/api-client.test.ts | 36 +++++++++++++++++++++++++-- src/api-client.ts | 56 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index ee64cb6a5e..5d9420a9f2 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -8941,7 +8941,7 @@ var require_proxy_agent = __commonJS({ return this.#client.destroy(err); } }; - var ProxyAgent = class extends DispatcherBase { + var ProxyAgent2 = class extends DispatcherBase { constructor(opts) { super(); if (!opts || typeof opts === "object" && !(opts instanceof URL2) && !opts.uri) { @@ -9082,7 +9082,7 @@ var require_proxy_agent = __commonJS({ throw new InvalidArgumentError("Proxy-Authorization should be sent in ProxyAgent constructor"); } } - module2.exports = ProxyAgent; + module2.exports = ProxyAgent2; } }); @@ -9092,7 +9092,7 @@ var require_env_http_proxy_agent = __commonJS({ "use strict"; var DispatcherBase = require_dispatcher_base(); var { kClose, kDestroy, kClosed, kDestroyed, kDispatch, kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent } = require_symbols(); - var ProxyAgent = require_proxy_agent(); + var ProxyAgent2 = require_proxy_agent(); var Agent = require_agent(); var DEFAULT_PORTS = { "http:": 80, @@ -9116,13 +9116,13 @@ var require_env_http_proxy_agent = __commonJS({ this[kNoProxyAgent] = new Agent(agentOpts); const HTTP_PROXY = httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY; if (HTTP_PROXY) { - this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY }); + this[kHttpProxyAgent] = new ProxyAgent2({ ...agentOpts, uri: HTTP_PROXY }); } else { this[kHttpProxyAgent] = this[kNoProxyAgent]; } const HTTPS_PROXY = httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY; if (HTTPS_PROXY) { - this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY }); + this[kHttpsProxyAgent] = new ProxyAgent2({ ...agentOpts, uri: HTTPS_PROXY }); } else { this[kHttpsProxyAgent] = this[kHttpProxyAgent]; } @@ -18906,7 +18906,7 @@ var require_undici = __commonJS({ var Pool = require_pool(); var BalancedPool = require_balanced_pool(); var Agent = require_agent(); - var ProxyAgent = require_proxy_agent(); + var ProxyAgent2 = require_proxy_agent(); var EnvHttpProxyAgent = require_env_http_proxy_agent(); var RetryAgent = require_retry_agent(); var errors = require_errors(); @@ -18929,7 +18929,7 @@ var require_undici = __commonJS({ module2.exports.Pool = Pool; module2.exports.BalancedPool = BalancedPool; module2.exports.Agent = Agent; - module2.exports.ProxyAgent = ProxyAgent; + module2.exports.ProxyAgent = ProxyAgent2; module2.exports.EnvHttpProxyAgent = EnvHttpProxyAgent; module2.exports.RetryAgent = RetryAgent; module2.exports.RetryHandler = RetryHandler; @@ -145590,6 +145590,9 @@ function retry(octokit, octokitOptions) { } retry.VERSION = VERSION7; +// src/api-client.ts +var import_undici = __toESM(require_undici()); + // src/repository.ts function getRepositoryNwo() { return getRepositoryNwoFromEnv("GITHUB_REPOSITORY"); @@ -145617,6 +145620,27 @@ function parseRepositoryNwo(input) { // src/api-client.ts var GITHUB_ENTERPRISE_VERSION_HEADER = "x-github-enterprise-version"; var DO_NOT_RETRY_STATUSES = [400, 410, 422, 451]; +function getRegistryProxy(env) { + const host = env.getOptional("CODEQL_PROXY_HOST" /* PROXY_HOST */); + const port = env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */); + const cert = env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */); + if (host && port) { + return new import_undici.ProxyAgent({ + uri: `http://${host}:${port}`, + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10, + requestTls: cert ? { ca: cert } : void 0 + }); + } + return void 0; +} +function getApiFetch(env) { + const dispatcher = getRegistryProxy(env); + const proxiedFetch = (req, init2) => { + return (0, import_undici.fetch)(req, { ...init2, dispatcher }); + }; + return proxiedFetch; +} function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) { const auth2 = allowExternal && apiDetails.externalRepoAuth || apiDetails.auth; const retryingOctokit = githubUtils.GitHub.plugin(retry); @@ -145630,6 +145654,7 @@ function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) warn: core5.warning, error: core5.error }, + request: { fetch: getApiFetch(getEnv()) }, retry: { doNotRetry: DO_NOT_RETRY_STATUSES } diff --git a/src/api-client.test.ts b/src/api-client.test.ts index 34a72f14e5..4c8342691e 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -6,7 +6,7 @@ import * as sinon from "sinon"; import * as actionsUtil from "./actions-util"; import * as api from "./api-client"; import { DO_NOT_RETRY_STATUSES } from "./api-client"; -import { ActionsEnvVars } from "./environment"; +import { ActionsEnvVars, RegistryProxyVars } from "./environment"; import { getTestEnv, setupTests } from "./testing-utils"; import * as util from "./util"; @@ -27,14 +27,17 @@ test.serial("getApiClient", async (t) => { sinon.stub(actionsUtil, "getRequiredInput").withArgs("token").returns("xyz"); - api.getApiClient(env); + const apiClient = api.getApiClient(env); + t.truthy(apiClient); + t.true(githubStub.calledOnce); t.assert( githubStub.calledOnceWithExactly({ auth: "token xyz", baseUrl: "http://api.github.localhost", log: sinon.match.any, userAgent: `CodeQL-Action/${actionsUtil.getActionVersion()}`, + request: sinon.match.any, retry: { doNotRetry: DO_NOT_RETRY_STATUSES, }, @@ -204,3 +207,32 @@ test.serial( } }, ); + +test("getRegistryProxy - returns undefined if the proxy is not configured", async (t) => { + // Empty environment. + t.is(api.getRegistryProxy(getTestEnv()), undefined); + // Only the host. + t.is( + api.getRegistryProxy( + getTestEnv({ [RegistryProxyVars.PROXY_HOST]: "localhost" }), + ), + undefined, + ); + // Only the port. + t.is( + api.getRegistryProxy( + getTestEnv({ [RegistryProxyVars.PROXY_PORT]: "1234" }), + ), + undefined, + ); +}); + +test("getRegistryProxy - returns value when both vars are set", async (t) => { + const proxy = api.getRegistryProxy( + getTestEnv({ + [RegistryProxyVars.PROXY_HOST]: "localhost", + [RegistryProxyVars.PROXY_PORT]: "1234", + }), + ); + t.truthy(proxy); +}); diff --git a/src/api-client.ts b/src/api-client.ts index eafac7ec76..70ac17c1d7 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,9 +1,21 @@ import * as core from "@actions/core"; import * as githubUtils from "@actions/github/lib/utils"; import * as retry from "@octokit/plugin-retry"; +import { + ProxyAgent, + RequestInfo, + RequestInit, + fetch as undiciFetch, +} from "undici"; import { getActionVersion, getRequiredInput } from "./actions-util"; -import { EnvVar, ReadOnlyEnv, ActionsEnvVars, getEnv } from "./environment"; +import { + ActionsEnvVars, + EnvVar, + ReadOnlyEnv, + RegistryProxyVars, + getEnv, +} from "./environment"; import { Logger } from "./logging"; import { getRepositoryNwo, RepositoryNwo } from "./repository"; import { @@ -43,6 +55,47 @@ export interface GitHubApiExternalRepoDetails { apiURL: string | undefined; } +/** + * Gets the configuration for the private registry authentication proxy, + * if it is available in the environment. + * + * @param env The environment to query for the proxy host and port. + * @returns A `ProxyAgent` corresponding to the private registry proxy, + * or `undefined` if we couldn't retrieve the host and port. + */ +export function getRegistryProxy(env: ReadOnlyEnv): ProxyAgent | undefined { + const host = env.getOptional(RegistryProxyVars.PROXY_HOST); + const port = env.getOptional(RegistryProxyVars.PROXY_PORT); + const cert = env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE); + + if (host && port) { + return new ProxyAgent({ + uri: `http://${host}:${port}`, + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10, + requestTls: cert ? { ca: cert } : undefined, + }); + } + + return undefined; +} + +/** + * Returns an implementation of `fetch` to use for API requests. + * This will run API requests through the private registry authentication proxy + * if it is configured. + * + * @param env The environment to query for the proxy host and port. + */ +export function getApiFetch(env: ReadOnlyEnv): typeof undiciFetch { + const dispatcher = getRegistryProxy(env); + + const proxiedFetch = (req: RequestInfo, init?: RequestInit) => { + return undiciFetch(req, { ...init, dispatcher }); + }; + return proxiedFetch; +} + function createApiClientWithDetails( apiDetails: GitHubApiCombinedDetails, { allowExternal = false } = {}, @@ -60,6 +113,7 @@ function createApiClientWithDetails( warn: core.warning, error: core.error, }, + request: { fetch: getApiFetch(getEnv()) }, retry: { doNotRetry: DO_NOT_RETRY_STATUSES, }, From e387ec1de1e303bbf1438ed546160c3154e3598d Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 16:00:17 +0100 Subject: [PATCH 05/56] Move `init` step at the end of `start-proxy` test workflow --- .github/workflows/__start-proxy.yml | 10 +++++----- pr-checks/checks/start-proxy.yml | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index 7ac2dede30..d8331470f0 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -57,11 +57,6 @@ jobs: version: ${{ matrix.version }} use-all-platform-bundle: 'false' setup-kotlin: 'true' - - uses: ./../action/init - with: - languages: csharp - tools: ${{ steps.prepare-test.outputs.tools-url }} - - name: Setup proxy for registries id: proxy uses: ./../action/start-proxy @@ -94,5 +89,10 @@ jobs: || !contains(steps.proxy.outputs.proxy_urls, 'https://repo.maven.apache.org/maven2/') || !contains(steps.proxy.outputs.proxy_urls, 'https://repo1.maven.org/maven2') run: exit 1 + + - uses: ./../action/init + with: + languages: csharp + tools: ${{ steps.prepare-test.outputs.tools-url }} env: CODEQL_ACTION_TEST_MODE: true diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index a4bf794873..ac9e494b09 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -7,11 +7,6 @@ operatingSystems: versions: - linked steps: - - uses: ./../action/init - with: - languages: csharp - tools: ${{ steps.prepare-test.outputs.tools-url }} - - name: Setup proxy for registries id: proxy uses: ./../action/start-proxy @@ -44,3 +39,8 @@ steps: || !contains(steps.proxy.outputs.proxy_urls, 'https://repo.maven.apache.org/maven2/') || !contains(steps.proxy.outputs.proxy_urls, 'https://repo1.maven.org/maven2') run: exit 1 + + - uses: ./../action/init + with: + languages: csharp + tools: ${{ steps.prepare-test.outputs.tools-url }} From b0eaa56a8f14c24a7065276c9bff0ed5f95a7722 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 16:00:51 +0100 Subject: [PATCH 06/56] Set language inputs in `start-proxy` check workflow --- .github/workflows/__start-proxy.yml | 3 ++- pr-checks/checks/start-proxy.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index d8331470f0..4dbea700f5 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -61,6 +61,7 @@ jobs: id: proxy uses: ./../action/start-proxy with: + language: java registry_secrets: | [ { @@ -92,7 +93,7 @@ jobs: - uses: ./../action/init with: - languages: csharp + languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} env: CODEQL_ACTION_TEST_MODE: true diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index ac9e494b09..2d4670f1eb 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -11,6 +11,7 @@ steps: id: proxy uses: ./../action/start-proxy with: + language: java registry_secrets: | [ { @@ -42,5 +43,5 @@ steps: - uses: ./../action/init with: - languages: csharp + languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} From b6d92e33f7afdb3f88b1dc4267abe3077a5fdddb Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 16:05:15 +0100 Subject: [PATCH 07/56] Set `CODEQL_PROXY_*` vars for `init` step in test workflow --- .github/workflows/__start-proxy.yml | 4 ++++ pr-checks/checks/start-proxy.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index 4dbea700f5..ddc8eaba15 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -92,6 +92,10 @@ jobs: run: exit 1 - uses: ./../action/init + env: + CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} + CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} + CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index 2d4670f1eb..10bf0bd808 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -42,6 +42,10 @@ steps: run: exit 1 - uses: ./../action/init + env: + CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} + CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} + CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} From 50b3687dd7f86d681a9ef4af6a04b32f52f905ca Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 13 Jul 2026 16:24:54 +0100 Subject: [PATCH 08/56] Specify custom `config-file` in `start-proxy` check --- .github/workflows/__start-proxy.yml | 2 ++ pr-checks/checks/start-proxy.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index ddc8eaba15..30ca74fd9c 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -96,8 +96,10 @@ jobs: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} + CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES: 'true' with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} + config-file: codeql-action@main:tests/multi-language-repo/.github/codeql/custom-queries.yml env: CODEQL_ACTION_TEST_MODE: true diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index 10bf0bd808..dedf5633ea 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -46,6 +46,8 @@ steps: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} + CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES: "true" with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} + config-file: codeql-action@main:tests/multi-language-repo/.github/codeql/custom-queries.yml From 4c2bf0117076adb8d499c1f840da7839663535c1 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 10:56:36 +0100 Subject: [PATCH 09/56] Scaffold basic `update-release-branch.ts` --- pr-checks/update-release-branch.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 pr-checks/update-release-branch.ts diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts new file mode 100644 index 0000000000..9134c52ea5 --- /dev/null +++ b/pr-checks/update-release-branch.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env npx tsx + +async function main(): Promise {} + +// Only call `main` if this script was run directly. +if (require.main === module) { + void main(); +} From 85052938f897cfc33a19151428a626fd72ae5e4c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:03:50 +0100 Subject: [PATCH 10/56] Add constants --- pr-checks/update-release-branch.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 9134c52ea5..596caf4334 100644 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -1,6 +1,34 @@ #!/usr/bin/env npx tsx -async function main(): Promise {} +/** Placeholder changelog content for a new release. */ +const EMPTY_CHANGELOG = `# CodeQL Action Changelog + +## [UNRELEASED] + +No user facing changes. + +`; + +/** + * NB: This exact commit message is used to find commits for reverting during backports. + * Changing it requires a transition period where both old and new versions are supported. + */ +export const BACKPORT_COMMIT_MESSAGE = "Update version and changelog for v"; + +/** + * Commit message used for rebuild commits, both those produced by this script and those produced + * by the `Rebuild Action` workflow (`.github/workflows/rebuild.yml`). + */ +export const REBUILD_COMMIT_MESSAGE = "Rebuild"; + +/** The name of the git remote. */ +const ORIGIN = "origin"; + +/** Environment variables checked (in order) for a GitHub API token. */ +const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"] as const; + +async function main(): Promise { +} // Only call `main` if this script was run directly. if (require.main === module) { From 14952376dc3e2a0576c48ac008ce0e9aa2554632 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:04:48 +0100 Subject: [PATCH 11/56] Add `getGitHubToken` --- pr-checks/update-release-branch.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 596caf4334..f8a2a27683 100644 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -27,6 +27,20 @@ const ORIGIN = "origin"; /** Environment variables checked (in order) for a GitHub API token. */ const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"] as const; +/** + * Gets a GitHub API token from one of the supported environment variables. + * @throws If none of the supported environment variables is set. + */ +export function getGitHubToken(): string { + for (const name of TOKEN_ENVIRONMENT_VARIABLES) { + const token = process.env[name]?.trim(); + if (token) { + return token; + } + } + throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); +} + async function main(): Promise { } From a10d7a7891eec0d733edb55dd270741082ddb49f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:07:24 +0100 Subject: [PATCH 12/56] Parse command line options --- pr-checks/update-release-branch.ts | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index f8a2a27683..9faf8a61d8 100644 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -1,5 +1,7 @@ #!/usr/bin/env npx tsx +import { parseArgs } from "node:util"; + /** Placeholder changelog content for a new release. */ const EMPTY_CHANGELOG = `# CodeQL Action Changelog @@ -41,7 +43,51 @@ export function getGitHubToken(): string { throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } +interface MainOptions { + repositoryNwo: string; + sourceBranch: string; + targetBranch: string; + isPrimaryRelease: boolean; + conductor: string; +} + +function parseCliOptions(): MainOptions { + const { values } = parseArgs({ + options: { + "repository-nwo": { type: "string" }, + "source-branch": { type: "string" }, + "target-branch": { type: "string" }, + "is-primary-release": { type: "boolean", default: false }, + conductor: { type: "string" }, + }, + strict: true, + }); + + if (!values["repository-nwo"]) { + throw new Error("--repository-nwo is required"); + } + if (!values["source-branch"]) { + throw new Error("--source-branch is required"); + } + if (!values["target-branch"]) { + throw new Error("--target-branch is required"); + } + if (!values["conductor"]) { + throw new Error("--conductor is required"); + } + + return { + repositoryNwo: values["repository-nwo"], + sourceBranch: values["source-branch"], + targetBranch: values["target-branch"], + isPrimaryRelease: values["is-primary-release"] ?? false, + conductor: values["conductor"], + }; +} + async function main(): Promise { + const options = parseCliOptions(); + const token = getGitHubToken(); } // Only call `main` if this script was run directly. From d0b11cae68ac88f6d50d115acdd481872614d1ea Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:09:30 +0100 Subject: [PATCH 13/56] Make script executable --- pr-checks/update-release-branch.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 pr-checks/update-release-branch.ts diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts old mode 100644 new mode 100755 From 1b146d1b6afe935c7f459fe3b3ae21e2800b0f75 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:13:31 +0100 Subject: [PATCH 14/56] Validate target branch and extract major version --- pr-checks/update-release-branch.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 9faf8a61d8..00c7c4f725 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -29,6 +29,9 @@ const ORIGIN = "origin"; /** Environment variables checked (in order) for a GitHub API token. */ const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"] as const; +/** The expected prefix for release branch names. */ +const RELEASE_BRANCH_PREFIX = "releases/v"; + /** * Gets a GitHub API token from one of the supported environment variables. * @throws If none of the supported environment variables is set. @@ -88,6 +91,17 @@ function parseCliOptions(): MainOptions { async function main(): Promise { const options = parseCliOptions(); const token = getGitHubToken(); + + if (!options.targetBranch.startsWith(RELEASE_BRANCH_PREFIX)) { + throw new Error( + `Expected target branch to start with '${RELEASE_BRANCH_PREFIX}', but got '${options.targetBranch}'.`, + ); + } + + const targetBranchMajorVersion = options.targetBranch.replace( + RELEASE_BRANCH_PREFIX, + "", + ); } // Only call `main` if this script was run directly. From 440cebc19d9dcbeba6efc43259871f58d1fc9a97 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:14:59 +0100 Subject: [PATCH 15/56] Add `getCurrentVersion` --- pr-checks/config.ts | 3 +++ pr-checks/update-release-branch.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 75cd0a1515..9458ef7802 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -12,6 +12,9 @@ export const REPO_ROOT = path.join(PR_CHECKS_DIR, ".."); /** The path of the file configuring which checks shouldn't be required. */ export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml"); +/** The path of the main `package.json`. */ +export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); + /** The path to the esbuild metadata file. */ export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 00c7c4f725..dcfec68bc1 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -1,7 +1,10 @@ #!/usr/bin/env npx tsx +import * as fs from "node:fs"; import { parseArgs } from "node:util"; +import { PACKAGE_JSON } from "./config"; + /** Placeholder changelog content for a new release. */ const EMPTY_CHANGELOG = `# CodeQL Action Changelog @@ -46,6 +49,14 @@ export function getGitHubToken(): string { throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } +/** Reads the current version from `package.json`. */ +export function getCurrentVersion(): string | undefined { + const pkg: { version: string } = JSON.parse( + fs.readFileSync(PACKAGE_JSON, "utf8"), + ); + return pkg.version; +} + interface MainOptions { repositoryNwo: string; sourceBranch: string; @@ -102,6 +113,15 @@ async function main(): Promise { RELEASE_BRANCH_PREFIX, "", ); + + const currentVersion = getCurrentVersion(); + + if (!currentVersion) { + throw new Error("Failed to read current version from package.json"); + } + + const [, vMinor, vPatch] = currentVersion.split("."); + const version = `${targetBranchMajorVersion}.${vMinor}.${vPatch}`; } // Only call `main` if this script was run directly. From 4ca9f5301b64f52a4779387c9960abd220469598 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:21:39 +0100 Subject: [PATCH 16/56] Add `runGit` and use it to obtain the source branch SHA --- pr-checks/update-release-branch.ts | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index dcfec68bc1..3b43405d37 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -1,5 +1,6 @@ #!/usr/bin/env npx tsx +import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import * as fs from "node:fs"; import { parseArgs } from "node:util"; @@ -49,6 +50,47 @@ export function getGitHubToken(): string { throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } +/** Options for {@link runGit}. */ +interface RunGitOptions { + /** When true, non-zero exit codes will not throw. */ + allowNonZeroExitCode?: boolean; +} + +/** + * Runs `git` with the given `args` and returns the stdout. + * + * @param args - Arguments to pass to `git`. + * @param options - Optional settings. + * @throws If `git` does not exit successfully, unless + * `options.allowNonZeroExitCode` is `true`. + * @returns The trimmed stdout output. + */ +export function runGit(args: string[], options?: RunGitOptions): string { + const execOptions: ExecFileSyncOptions = { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }; + + try { + const result = execFileSync("git", args, execOptions) as string; + return result.trimEnd(); + } catch (error: unknown) { + if (options?.allowNonZeroExitCode) { + // execFileSync throws an object with `stdout` when the process exits + // with a non-zero code. + const execError = error as { stdout?: Buffer | string }; + if (typeof execError.stdout === "string") { + return execError.stdout.trimEnd(); + } + if (Buffer.isBuffer(execError.stdout)) { + return execError.stdout.toString("utf8").trimEnd(); + } + return ""; + } + throw error; + } +} + /** Reads the current version from `package.json`. */ export function getCurrentVersion(): string | undefined { const pkg: { version: string } = JSON.parse( @@ -122,6 +164,21 @@ async function main(): Promise { const [, vMinor, vPatch] = currentVersion.split("."); const version = `${targetBranchMajorVersion}.${vMinor}.${vPatch}`; + + console.log( + `Considering difference between ${options.sourceBranch} and ${options.targetBranch}...`, + ); + + const sourceBranchShortSha = runGit([ + "rev-parse", + "--short", + `${ORIGIN}/${options.sourceBranch}`, + ]); + console.log( + `Current head of ${options.sourceBranch} is ${sourceBranchShortSha}.`, + ); + + console.log(`Target version: ${version}`); } // Only call `main` if this script was run directly. From c3da0a9ad35618d79e3a45187fd4fdfc85d5e16f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:38:59 +0100 Subject: [PATCH 17/56] Fetch commit info --- pr-checks/update-release-branch.ts | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 3b43405d37..ea2f83e9ac 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -4,6 +4,7 @@ import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import * as fs from "node:fs"; import { parseArgs } from "node:util"; +import { type ApiClient, getApiClient } from "./api-client"; import { PACKAGE_JSON } from "./config"; /** Placeholder changelog content for a new release. */ @@ -99,6 +100,73 @@ export function getCurrentVersion(): string | undefined { return pkg.version; } +/** Represents commits returned by the GitHub API (relevant fields only). */ +export interface GitHubCommit { + sha: string; + commit: { message: string; author: { date?: string } | null }; + author: { login: string } | null; + committer: { login: string } | null; + parents: Array<{ sha: string }>; +} + +/** Returns true if the commit is an automatic PR merge commit made by GitHub. */ +export function isPrMergeCommit(commit: GitHubCommit): boolean { + return commit.committer?.login === "web-flow" && commit.parents.length > 1; +} + +/** + * Gets a list of commits on the source branch that are not on the target branch, + * excluding automatic PR merge commits. + * + * Uses `git log` to find the SHAs, then fetches each commit from the GitHub API + * to obtain full metadata (author, parents, associated PRs, etc.). + * + * @param client - An authenticated GitHub API client. + * @param owner - The repository owner. + * @param repo - The repository name. + * @param sourceBranch - The source branch name (without `origin/` prefix). + * @param targetBranch - The target branch name (without `origin/` prefix). + * @returns The list of non-merge commits unique to the source branch. + */ +export async function getCommitDifference( + client: ApiClient, + owner: string, + repo: string, + sourceBranch: string, + targetBranch: string, +): Promise { + const logOutput = runGit([ + "log", + "--pretty=format:%H", + `${ORIGIN}/${targetBranch}..${ORIGIN}/${sourceBranch}`, + ]); + + // An empty log output means no commits to merge. + if (logOutput === "") { + return []; + } + + const shas = logOutput.split("\n"); + + // Fetch full commit objects from the API. + console.info( + `Fetching information about ${shas.length} commits from the API...`, + ); + + const commits: GitHubCommit[] = []; + for (const sha of shas) { + const { data } = await client.rest.repos.getCommit({ + owner, + repo, + ref: sha, + }); + commits.push(data as GitHubCommit); + } + + // Filter out automatic PR merge commits. + return commits.filter((c) => !isPrMergeCommit(c)); +} + interface MainOptions { repositoryNwo: string; sourceBranch: string; @@ -144,12 +212,18 @@ function parseCliOptions(): MainOptions { async function main(): Promise { const options = parseCliOptions(); const token = getGitHubToken(); + const client = getApiClient(token); if (!options.targetBranch.startsWith(RELEASE_BRANCH_PREFIX)) { throw new Error( `Expected target branch to start with '${RELEASE_BRANCH_PREFIX}', but got '${options.targetBranch}'.`, ); } + if (!options.repositoryNwo.includes("/")) { + throw new Error( + `Expected repository name with owner in 'owner/repo' format, but got '${options.repositoryNwo}'`, + ); + } const targetBranchMajorVersion = options.targetBranch.replace( RELEASE_BRANCH_PREFIX, @@ -178,6 +252,22 @@ async function main(): Promise { `Current head of ${options.sourceBranch} is ${sourceBranchShortSha}.`, ); + const [owner, repo] = options.repositoryNwo.split("/"); + const commits = await getCommitDifference( + client, + owner, + repo, + options.sourceBranch, + options.targetBranch, + ); + + if (commits.length === 0) { + console.log( + `No commits to merge from ${options.sourceBranch} to ${options.targetBranch}.`, + ); + return; + } + console.log(`Target version: ${version}`); } From 557921759be993fe73e92cbff1e4e4c292b9d9c3 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 11:48:07 +0100 Subject: [PATCH 18/56] Check whether the new branch exists --- pr-checks/update-release-branch.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index ea2f83e9ac..2028adea22 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -92,6 +92,12 @@ export function runGit(args: string[], options?: RunGitOptions): string { } } +/** Returns true if the given branch exists on the origin remote. */ +export function branchExistsOnRemote(branchName: string): boolean { + const result = runGit(["ls-remote", "--heads", ORIGIN, branchName]); + return result !== ""; +} + /** Reads the current version from `package.json`. */ export function getCurrentVersion(): string | undefined { const pkg: { version: string } = JSON.parse( @@ -268,7 +274,23 @@ async function main(): Promise { return; } - console.log(`Target version: ${version}`); + // Use a distinct branch prefix to support specific PR checks on backports. + const branchPrefix = options.isPrimaryRelease ? "update" : "backport"; + + // The branch name is based on the target version and the SHA of the source + // branch head. If the branch already exists we can assume this script has + // already run for this combination. + const newBranchName = `${branchPrefix}-v${version}-${sourceBranchShortSha}`; + console.log(`Branch name is '${newBranchName}'.`); + + // Check if the branch already exists. If so we can abort as this script + // has already run on this combination of branches. + if (branchExistsOnRemote(newBranchName)) { + console.log(`Branch '${newBranchName}' already exists. Nothing to do.`); + return; + } + + console.log(`Creating branch ${newBranchName}.`); } // Only call `main` if this script was run directly. From 460cc0c970e29515051e0f44ea69cefe1445023f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 12:10:23 +0100 Subject: [PATCH 19/56] Add dry run support and push branch --- pr-checks/update-release-branch.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 2028adea22..dcf70cfab1 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -55,6 +55,8 @@ export function getGitHubToken(): string { interface RunGitOptions { /** When true, non-zero exit codes will not throw. */ allowNonZeroExitCode?: boolean; + /** A value indicating whether to just log the command, rather than run it. */ + dryRun?: boolean; } /** @@ -73,8 +75,13 @@ export function runGit(args: string[], options?: RunGitOptions): string { }; try { - const result = execFileSync("git", args, execOptions) as string; - return result.trimEnd(); + if (!options?.dryRun) { + const result = execFileSync("git", args, execOptions) as string; + return result.trimEnd(); + } else { + console.info(`[DRY RUN] Would have executed 'git ${args.join(" ")}'`); + return ""; + } } catch (error: unknown) { if (options?.allowNonZeroExitCode) { // execFileSync throws an object with `stdout` when the process exits @@ -174,6 +181,7 @@ export async function getCommitDifference( } interface MainOptions { + dryRun: boolean; repositoryNwo: string; sourceBranch: string; targetBranch: string; @@ -184,6 +192,7 @@ interface MainOptions { function parseCliOptions(): MainOptions { const { values } = parseArgs({ options: { + "dry-run": { type: "boolean", default: false }, "repository-nwo": { type: "string" }, "source-branch": { type: "string" }, "target-branch": { type: "string" }, @@ -207,6 +216,7 @@ function parseCliOptions(): MainOptions { } return { + dryRun: values["dry-run"], repositoryNwo: values["repository-nwo"], sourceBranch: values["source-branch"], targetBranch: values["target-branch"], @@ -290,7 +300,9 @@ async function main(): Promise { return; } + // Push the new branch to the remote. console.log(`Creating branch ${newBranchName}.`); + runGit(["push", ORIGIN, newBranchName], { dryRun: options.dryRun }); } // Only call `main` if this script was run directly. From 212aa33f48022312241853515b8d6d211e1efb64 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 12:24:00 +0100 Subject: [PATCH 20/56] Add `runCommand` --- pr-checks/update-release-branch.ts | 53 +++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index dcf70cfab1..e3e3eb2537 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; -import { PACKAGE_JSON } from "./config"; +import { PACKAGE_JSON, REPO_ROOT } from "./config"; /** Placeholder changelog content for a new release. */ const EMPTY_CHANGELOG = `# CodeQL Action Changelog @@ -51,8 +51,45 @@ export function getGitHubToken(): string { throw new Error("Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN."); } +/** Options for {@link runCommand}. */ +export interface RunCommandOptions { + /** A value indicating whether to just log the command, rather than run it. */ + dryRun?: boolean; + + /** Options for `execFileSync`. */ + execOptions?: ExecFileSyncOptions; +} + +/** + * Runs a command, streaming output to the console by default. + * + * @param command The name of the command to run. + * @param args The arguments for the command. + * @throws When the process exists with a non-zero exit code. + * @param options How to run the command. + */ +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +) { + if (!options?.dryRun) { + console.log(`Running \`${command} ${args.join(" ")}\`.`); + return execFileSync(command, args, { + stdio: "inherit", + cwd: REPO_ROOT, + ...options?.execOptions, + }); + } else { + console.info( + `[DRY RUN] Would have executed '${command} ${args.join(" ")}'`, + ); + return ""; + } +} + /** Options for {@link runGit}. */ -interface RunGitOptions { +export interface RunGitOptions { /** When true, non-zero exit codes will not throw. */ allowNonZeroExitCode?: boolean; /** A value indicating whether to just log the command, rather than run it. */ @@ -75,13 +112,11 @@ export function runGit(args: string[], options?: RunGitOptions): string { }; try { - if (!options?.dryRun) { - const result = execFileSync("git", args, execOptions) as string; - return result.trimEnd(); - } else { - console.info(`[DRY RUN] Would have executed 'git ${args.join(" ")}'`); - return ""; - } + const result = runCommand("git", args, { + dryRun: options?.dryRun, + execOptions, + }) as string; + return result.trimEnd(); } catch (error: unknown) { if (options?.allowNonZeroExitCode) { // execFileSync throws an object with `stdout` when the process exits From c8ed70e459847d329cf7c41828bce8618d103765 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 12:34:57 +0100 Subject: [PATCH 21/56] Add `rebuildAction` function --- pr-checks/update-release-branch.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index e3e3eb2537..5d39204592 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -260,6 +260,27 @@ function parseCliOptions(): MainOptions { }; } +/** + * Rebuilds the action (npm ci + npm run build) and commits any changes. + */ +export function rebuildAction(options: MainOptions): void { + runCommand("npm", ["ci"]); + runCommand("npm", ["run", "build"]); + + runGit(["add", "--all"], { dryRun: options.dryRun }); + + // `git diff --cached --quiet` exits 0 if there are no staged changes. + try { + execFileSync("git", ["diff", "--cached", "--quiet"]); + console.log("Rebuild produced no changes; skipping Rebuild commit."); + } catch { + runGit(["commit", "-m", REBUILD_COMMIT_MESSAGE], { + dryRun: options.dryRun, + }); + console.log("Created Rebuild commit."); + } +} + async function main(): Promise { const options = parseCliOptions(); const token = getGitHubToken(); From 639fc5d7eabd0bdfd0d598125e6580c7b07db17c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 12:41:53 +0100 Subject: [PATCH 22/56] Add changelog helpers --- pr-checks/config.ts | 3 + pr-checks/update-release-branch.ts | 107 ++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 9458ef7802..c51c56bffb 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -15,6 +15,9 @@ export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml"); /** The path of the main `package.json`. */ export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); +/** THe path of the changelog. */ +export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); + /** The path to the esbuild metadata file. */ export const BUNDLE_METADATA_FILE = path.join(REPO_ROOT, "meta.json"); diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 5d39204592..79d1d0e8ae 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; -import { PACKAGE_JSON, REPO_ROOT } from "./config"; +import { CHANGELOG_FILE, PACKAGE_JSON, REPO_ROOT } from "./config"; /** Placeholder changelog content for a new release. */ const EMPTY_CHANGELOG = `# CodeQL Action Changelog @@ -148,6 +148,111 @@ export function getCurrentVersion(): string | undefined { return pkg.version; } +/** Returns today's date formatted as `DD Mon YYYY`. */ +export function getTodayString(): string { + const today = new Date(); + return today.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function updateChangelog(version: string): void { + let content: string; + + if (fs.existsSync(CHANGELOG_FILE)) { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + } else { + content = EMPTY_CHANGELOG; + } + + const newContent = content.replace( + "[UNRELEASED]", + `${version} - ${getTodayString()}`, + ); + + fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); +} + +/** + * Processes changelog entries for a backport, converting version references + * from the source major version to the target major version and filtering + * entries that only apply to newer versions. + */ +export function processChangelogForBackports( + sourceBranchMajorVersion: string, + targetBranchMajorVersion: string, +): void { + const content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + const lines = content.split("\n"); + const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; + + let output = ""; + let i = 0; + + // Copy lines until we find the first section heading. + let foundFirstSection = false; + while (!foundFirstSection && i < lines.length) { + let line = lines[i]; + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + foundFirstSection = true; + } + output += `${line}\n`; + i++; + } + + if (!foundFirstSection) { + throw new Error("Could not find any change sections in CHANGELOG.md"); + } + + // Process remaining lines. + let foundContent = false; + output += "\n"; + + while (i < lines.length) { + let line = lines[i]; + i++; + + // Filter out changelog entries that only apply to newer versions. + const match = someVersionsOnlyRegex.exec(line); + if (match) { + if ( + Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) + ) { + continue; + } + } + + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + if (!foundContent) { + output += "No user facing changes.\n"; + } + foundContent = false; + output += `\n${line}\n\n`; + } else { + if (line.trim() !== "") { + foundContent = true; + output += `${line}\n`; + } + } + } + + fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); +} + /** Represents commits returned by the GitHub API (relevant fields only). */ export interface GitHubCommit { sha: string; From 5e212030b855d52a116472f9c5c1f77c53397dc0 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 12:42:25 +0100 Subject: [PATCH 23/56] Add `prepareNewBranch` --- pr-checks/update-release-branch.ts | 215 ++++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 6 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 79d1d0e8ae..cdd098a3f0 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -148,6 +148,30 @@ export function getCurrentVersion(): string | undefined { return pkg.version; } +/** + * Replaces the version in `package.json` textually. Only updates the version + * field that immediately follows the `"name": "codeql"` line. + */ +export function replaceVersionInPackageJson( + prevVersion: string, + newVersion: string, +): void { + const lines = fs.readFileSync(PACKAGE_JSON, "utf8").split("\n"); + let prevLineIsCodeql = false; + const output: string[] = []; + + for (const line of lines) { + if (prevLineIsCodeql && line.includes(`"version": "${prevVersion}"`)) { + output.push(line.replace(prevVersion, newVersion)); + } else { + output.push(line); + } + prevLineIsCodeql = line.includes('"name": "codeql",'); + } + + fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); +} + /** Returns today's date formatted as `DD Mon YYYY`. */ export function getTodayString(): string { const today = new Date(); @@ -162,7 +186,7 @@ export function getTodayString(): string { * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version * and today's date. */ -export function updateChangelog(version: string): void { +export function updateChangelog(options: MainOptions, version: string): void { let content: string; if (fs.existsSync(CHANGELOG_FILE)) { @@ -171,12 +195,16 @@ export function updateChangelog(version: string): void { content = EMPTY_CHANGELOG; } - const newContent = content.replace( - "[UNRELEASED]", - `${version} - ${getTodayString()}`, - ); + const versionAndDate = `${version} - ${getTodayString()}`; + const newContent = content.replace("[UNRELEASED]", versionAndDate); - fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); + } else { + console.info( + `[DRY RUN] Would have replaced '[UNRELEASED]' in '${CHANGELOG_FILE}' with '${versionAndDate}'.`, + ); + } } /** @@ -386,6 +414,165 @@ export function rebuildAction(options: MainOptions): void { } } +/** + * Prepares the new update/backport branch. + * + * @param options The options we are running with. + * @param newBranchName The name of the new branch to create. + * @param targetBranchMajorVersion The target branch's major version. + * @param version The target version. + */ +export async function prepareNewBranch( + options: MainOptions, + newBranchName: string, + targetBranchMajorVersion: string, + version: string, +): Promise { + // The process of creating the v{Older} release can run into merge conflicts. We commit the unresolved + // conflicts so a maintainer can easily resolve them (vs erroring and requiring maintainers to + // reconstruct the release manually) + let conflictedFiles: string[] = []; + + if (!options.isPrimaryRelease) { + // For backports, the source branch is also a release branch. + const sourceBranchMajorVersion = options.sourceBranch.replace( + RELEASE_BRANCH_PREFIX, + "", + ); + + // Start from the target branch. + console.log( + `Creating ${newBranchName} from the ${ORIGIN}/${options.targetBranch} branch`, + ); + + runGit( + ["checkout", "-b", newBranchName, `${ORIGIN}/${options.targetBranch}`], + { dryRun: options.dryRun }, + ); + + // Revert the commit that updated the version number and changelog to refer + // to older variants. This avoids merge conflicts when we merge in the newer + // release branch. The commit won't exist the first time we release a new + // major version, so we search for it conditionally. + console.log( + "Reverting the version number and changelog updates from the last release to avoid conflicts", + ); + const vOlderUpdateCommits = runGit([ + "log", + "--grep", + `^${BACKPORT_COMMIT_MESSAGE}`, + "--format=%H", + ]) + .split("\n") + .filter((s) => s !== ""); + + if (vOlderUpdateCommits.length > 0) { + // Only revert the newest commit as older ones will already have been + // reverted in previous releases. + console.log(` Reverting ${vOlderUpdateCommits[0]}`); + runGit(["revert", vOlderUpdateCommits[0], "--no-edit"], { + dryRun: options.dryRun, + }); + + // Also revert the "Rebuild" commit, whether created by this script or + // by the `Rebuild Action` workflow. + const rebuildCommits = runGit([ + "log", + "--grep", + `^${REBUILD_COMMIT_MESSAGE}$`, + "--format=%H", + ]) + .split("\n") + .filter((s) => s !== ""); + const rebuildCommit = rebuildCommits[0]; + console.log(` Reverting ${rebuildCommit}`); + runGit(["revert", rebuildCommit, "--no-edit"], { + dryRun: options.dryRun, + }); + } else { + console.log(" Nothing to revert."); + } + + // Merge the source branch into the release prep branch. + console.log( + `Merging ${ORIGIN}/${options.sourceBranch} into the release prep branch`, + ); + runGit(["merge", `${ORIGIN}/${options.sourceBranch}`], { + allowNonZeroExitCode: true, + dryRun: options.dryRun, + }); + conflictedFiles = runGit(["diff", "--name-only", "--diff-filter", "U"]) + .split("\n") + .filter((s) => s !== ""); + if (conflictedFiles.length > 0) { + runGit(["add", "."], { + dryRun: options.dryRun, + }); + runGit(["commit", "--no-edit"], { + dryRun: options.dryRun, + }); + } + + // Migrate the package version number. + console.log(`Setting version number to '${version}' in package.json`); + const currentPkgVersion = getCurrentVersion(); + if (currentPkgVersion) { + replaceVersionInPackageJson(currentPkgVersion, version); + } + runGit(["add", "package.json"], { + dryRun: options.dryRun, + }); + + // Migrate the changelog notes from the source major version to the target. + console.log( + `Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`, + ); + processChangelogForBackports( + sourceBranchMajorVersion, + targetBranchMajorVersion, + ); + + runGit(["add", "CHANGELOG.md"], { + dryRun: options.dryRun, + }); + runGit(["commit", "-m", `${BACKPORT_COMMIT_MESSAGE}${version}`], { + dryRun: options.dryRun, + }); + } else { + // For a standard (primary) release, there won't be new commits on the + // target branch that aren't already on the source branch, so we can just + // start from the source branch. + runGit( + ["checkout", "-b", newBranchName, `${ORIGIN}/${options.sourceBranch}`], + { + dryRun: options.dryRun, + }, + ); + + console.log("Updating changelog"); + updateChangelog(options, version); + + runGit(["add", "CHANGELOG.md"], { + dryRun: options.dryRun, + }); + runGit(["commit", "-m", `Update changelog for v${version}`], { + dryRun: options.dryRun, + }); + } + + // For backports, rebuild the action unless there were merge conflicts. + if (!options.isPrimaryRelease) { + if (conflictedFiles.length === 0) { + console.log("Rebuilding the Action."); + rebuildAction(options); + } else { + console.log( + `Skipping automatic rebuild because the merge produced conflicts in: ${conflictedFiles.join(", ")}`, + ); + } + } +} + async function main(): Promise { const options = parseCliOptions(); const token = getGitHubToken(); @@ -396,6 +583,14 @@ async function main(): Promise { `Expected target branch to start with '${RELEASE_BRANCH_PREFIX}', but got '${options.targetBranch}'.`, ); } + if ( + !options.isPrimaryRelease && + !options.sourceBranch.startsWith(RELEASE_BRANCH_PREFIX) + ) { + throw new Error( + `Expected source branch to start with '${RELEASE_BRANCH_PREFIX}' for backports, but got '${options.sourceBranch}'.`, + ); + } if (!options.repositoryNwo.includes("/")) { throw new Error( `Expected repository name with owner in 'owner/repo' format, but got '${options.repositoryNwo}'`, @@ -461,6 +656,14 @@ async function main(): Promise { return; } + // Prepare the update/backport branch. + await prepareNewBranch( + options, + newBranchName, + targetBranchMajorVersion, + version, + ); + // Push the new branch to the remote. console.log(`Creating branch ${newBranchName}.`); runGit(["push", ORIGIN, newBranchName], { dryRun: options.dryRun }); From 78d71fb252654beb7fa3730c7884b7824faa79f8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 13:17:29 +0100 Subject: [PATCH 24/56] Create PR --- pr-checks/update-release-branch.ts | 272 ++++++++++++++++++++++++++++- 1 file changed, 270 insertions(+), 2 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index cdd098a3f0..6be88a3656 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -348,6 +348,256 @@ export async function getCommitDifference( return commits.filter((c) => !isPrMergeCommit(c)); } +/** Truncates a commit message for display. */ +export function getTruncatedCommitMessage(message: string): string { + const firstLine = message.split("\n")[0]; + if (firstLine.length > 60) { + return `${firstLine.slice(0, 57)}...`; + } + return firstLine; +} + +/** Represents pull requests associated with a commit (relevant fields only). */ +export interface AssociatedPullRequest { + number: number; + user: { login: string; site_admin: boolean } | null; + merge_commit_sha: string | null; +} + +/** + * Gets the pull request that introduced a commit to the source branch. + * Returns the earliest PR by number if multiple are associated. + */ +export async function getPrForCommit( + client: ApiClient, + owner: string, + repo: string, + commit: GitHubCommit, +): Promise { + const { data: prs } = + await client.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: commit.sha, + }); + + if (prs.length === 0) { + return undefined; + } + + // Return the earliest PR by number. + const sorted = [...prs].sort((a, b) => a.number - b.number); + return sorted[0]; +} + +/** + * Get the login of the person who merged a pull request. + * Falls back to the commit author of the merge commit. + */ +export async function getMergerOfPr( + client: ApiClient, + owner: string, + repo: string, + pr: AssociatedPullRequest, +): Promise { + if (!pr.merge_commit_sha) { + return "unknown"; + } + const { data: commit } = await client.rest.repos.getCommit({ + owner, + repo, + ref: pr.merge_commit_sha, + }); + return commit.author?.login ?? "unknown"; +} + +/** + * Returns the PR author's login if they are GitHub staff (site_admin), + * otherwise undefined. + */ +export function getPrAuthorIfStaff( + pr: AssociatedPullRequest, +): string | undefined { + if (pr.user?.site_admin) { + return pr.user.login; + } + return undefined; +} + +/** Parameters for {@link openPr}. */ +interface OpenPrParams { + client: ApiClient; + owner: string; + repo: string; + commits: GitHubCommit[]; + sourceBranchShortSha: string; + newBranchName: string; + sourceBranch: string; + targetBranch: string; + conductor: string; + isPrimaryRelease: boolean; + conflictedFiles: string[]; + dryRun: boolean; +} + +/** + * Opens a pull request from the new branch to the target branch and assigns + * the conductor. + */ +export async function openPr(params: OpenPrParams): Promise { + const { + client, + owner, + repo, + commits, + sourceBranchShortSha, + newBranchName, + sourceBranch, + targetBranch, + conductor, + isPrimaryRelease, + conflictedFiles, + dryRun, + } = params; + + // Sort the commits into those with and without associated PRs. + const pullRequests: AssociatedPullRequest[] = []; + const commitsWithoutPrs: GitHubCommit[] = []; + + console.info(`Finding PRs for ${commits.length} commits...`); + + for (const commit of commits) { + const pr = await getPrForCommit(client, owner, repo, commit); + if (!pr) { + commitsWithoutPrs.push(commit); + } else if (!pullRequests.some((p) => p.number === pr.number)) { + pullRequests.push(pr); + } + } + + console.log(`Found ${pullRequests.length} pull requests.`); + console.log( + `Found ${commitsWithoutPrs.length} commits not in a pull request.`, + ); + + // Sort PRs by number (ascending) and commits by date. + pullRequests.sort((a, b) => a.number - b.number); + commitsWithoutPrs.sort((a, b) => { + const dateA = a.commit.author?.date ?? ""; + const dateB = b.commit.author?.date ?? ""; + return dateA.localeCompare(dateB); + }); + + // Build the PR body. + const body: string[] = []; + body.push(`Merging ${sourceBranchShortSha} into \`${targetBranch}\`.`); + body.push(""); + body.push(`Conductor for this PR is @${conductor}.`); + + if (pullRequests.length > 0) { + body.push(""); + body.push("Contains the following pull requests:"); + for (const pr of pullRequests) { + const displayUser = + getPrAuthorIfStaff(pr) ?? + (await getMergerOfPr(client, owner, repo, pr)); + body.push(`- #${pr.number} (@${displayUser})`); + } + } + + if (commitsWithoutPrs.length > 0) { + body.push(""); + body.push("Contains the following commits not from a pull request:"); + for (const commit of commitsWithoutPrs) { + const authorDesc = commit.author ? ` (@${commit.author.login})` : ""; + body.push( + `- ${commit.sha} - ${getTruncatedCommitMessage(commit.commit.message)}${authorDesc}`, + ); + } + } + + body.push(""); + body.push("Please do the following:"); + if (conflictedFiles.length > 0) { + body.push( + " - [ ] Ensure `package.json` file contains the correct version.", + ); + body.push( + " - [ ] Add a commit to this branch to resolve the merge conflicts in the following files:", + ); + for (const file of conflictedFiles) { + body.push(` - \`${file}\``); + } + body.push( + ` - [ ] Rebuild the Action locally (\`npm run build\`) and push any changes to the built output in \`lib\` as a separate commit named exactly \`${REBUILD_COMMIT_MESSAGE}\`.`, + ); + body.push( + " - [ ] Ensure another maintainer has reviewed the additional commits you added to this branch to resolve the merge conflicts.", + ); + } + body.push( + " - [ ] Ensure the CHANGELOG displays the correct version and date.", + ); + body.push( + " - [ ] Ensure the CHANGELOG includes all relevant, user-facing changes since the last release.", + ); + body.push( + ` - [ ] Check that there are not any unexpected commits being merged into the \`${targetBranch}\` branch.`, + ); + body.push( + " - [ ] Ensure the docs team is aware of any documentation changes that need to be released.", + ); + body.push( + " - [ ] Approve running the full set of PR checks if you have not pushed any changes.", + ); + body.push( + " - [ ] Approve and merge this PR. Make sure `Create a merge commit` is selected rather than `Squash and merge` or `Rebase and merge`.", + ); + + if (isPrimaryRelease) { + body.push( + " - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.", + ); + body.push( + " - [ ] Merge all backport PRs to older release branches, that will automatically be created once this PR is merged.", + ); + } + + const title = `Merge ${sourceBranch} into ${targetBranch}`; + + if (dryRun) { + console.info(`[DRY RUN] Would create PR: "${title}" with body:`); + + for (const line of body) { + console.info(`[DRY RUN] > ${line}`); + } + + console.info(`[DRY RUN] and assign it to @${conductor}`); + + return; + } + + // Create the pull request. + const { data: pr } = await client.rest.pulls.create({ + owner, + repo, + title, + body: body.join("\n"), + head: newBranchName, + base: targetBranch, + }); + console.log(`Created PR #${pr.number}`); + + // Assign the conductor. + await client.rest.issues.addAssignees({ + owner, + repo, + issue_number: pr.number, + assignees: [conductor], + }); + console.log(`Assigned PR to ${conductor}`); +} + interface MainOptions { dryRun: boolean; repositoryNwo: string; @@ -427,7 +677,7 @@ export async function prepareNewBranch( newBranchName: string, targetBranchMajorVersion: string, version: string, -): Promise { +): Promise { // The process of creating the v{Older} release can run into merge conflicts. We commit the unresolved // conflicts so a maintainer can easily resolve them (vs erroring and requiring maintainers to // reconstruct the release manually) @@ -571,6 +821,8 @@ export async function prepareNewBranch( ); } } + + return conflictedFiles; } async function main(): Promise { @@ -657,7 +909,7 @@ async function main(): Promise { } // Prepare the update/backport branch. - await prepareNewBranch( + const conflictedFiles = await prepareNewBranch( options, newBranchName, targetBranchMajorVersion, @@ -667,6 +919,22 @@ async function main(): Promise { // Push the new branch to the remote. console.log(`Creating branch ${newBranchName}.`); runGit(["push", ORIGIN, newBranchName], { dryRun: options.dryRun }); + + // Open a PR to merge the new branch into the target branch. + await openPr({ + client, + owner, + repo, + commits, + sourceBranchShortSha, + newBranchName, + sourceBranch: options.sourceBranch, + targetBranch: options.targetBranch, + conductor: options.conductor, + isPrimaryRelease: options.isPrimaryRelease, + conflictedFiles, + dryRun: options.dryRun, + }); } // Only call `main` if this script was run directly. From 5c030f4a487d3f091a69dc4883ea986adef10e12 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 13:17:42 +0100 Subject: [PATCH 25/56] Add summary comment --- pr-checks/update-release-branch.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 6be88a3656..b80afee7bd 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -1,5 +1,23 @@ #!/usr/bin/env npx tsx +/** + * Creates a release preparation branch and opens a PR to merge changes from a + * source branch into a target release branch. + * + * For primary releases this merges `main` into the latest `releases/vN` branch. + * For backports this merges a newer release branch into an older one, handling + * version number and changelog migration automatically. + * + * Usage: + * update-release-branch.ts \ + * --repository-nwo github/codeql-action \ + * --source-branch main \ + * --target-branch releases/v4 \ + * --conductor username \ + * [--is-primary-release] \ + * [--dry-run] + */ + import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import * as fs from "node:fs"; import { parseArgs } from "node:util"; From 2c45c8158b2a3f94d4874429ed9d1d468796650a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 13:21:49 +0100 Subject: [PATCH 26/56] Add `DryRunOption` interface --- pr-checks/config.ts | 6 ++++++ pr-checks/update-release-branch.ts | 16 ++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index c51c56bffb..3c14ca1dbd 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -36,3 +36,9 @@ export const API_COMPATIBILITY_FILE = path.join( SOURCE_ROOT, "api-compatibility.json", ); + +/** A common interface for operations that support dry runs. */ +export interface DryRunOption { + /** A value indicating whether to perform operations with side effects. */ + dryRun?: boolean; +} diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index b80afee7bd..0fda12ad2f 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -23,7 +23,12 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; -import { CHANGELOG_FILE, PACKAGE_JSON, REPO_ROOT } from "./config"; +import { + CHANGELOG_FILE, + DryRunOption, + PACKAGE_JSON, + REPO_ROOT, +} from "./config"; /** Placeholder changelog content for a new release. */ const EMPTY_CHANGELOG = `# CodeQL Action Changelog @@ -70,10 +75,7 @@ export function getGitHubToken(): string { } /** Options for {@link runCommand}. */ -export interface RunCommandOptions { - /** A value indicating whether to just log the command, rather than run it. */ - dryRun?: boolean; - +export interface RunCommandOptions extends DryRunOption { /** Options for `execFileSync`. */ execOptions?: ExecFileSyncOptions; } @@ -107,11 +109,9 @@ export function runCommand( } /** Options for {@link runGit}. */ -export interface RunGitOptions { +export interface RunGitOptions extends DryRunOption { /** When true, non-zero exit codes will not throw. */ allowNonZeroExitCode?: boolean; - /** A value indicating whether to just log the command, rather than run it. */ - dryRun?: boolean; } /** From 4b861b89fc98771a9911dde74d32f1b9caacfcf1 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 14:47:54 +0100 Subject: [PATCH 27/56] Move changelog-related definitions into their own module --- pr-checks/changelog.ts | 124 +++++++++++++++++++++++++++ pr-checks/update-release-branch.ts | 130 +---------------------------- 2 files changed, 128 insertions(+), 126 deletions(-) create mode 100644 pr-checks/changelog.ts diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts new file mode 100644 index 0000000000..dcddac55f0 --- /dev/null +++ b/pr-checks/changelog.ts @@ -0,0 +1,124 @@ +import * as fs from "node:fs"; + +import { CHANGELOG_FILE, DryRunOption } from "./config"; + +/** Placeholder changelog content for a new release. */ +export const EMPTY_CHANGELOG = `# CodeQL Action Changelog + +## [UNRELEASED] + +No user facing changes. + +`; + +/** Returns today's date formatted as `DD Mon YYYY`. */ +export function getTodayString(): string { + const today = new Date(); + return today.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function setVersionAndDate( + options: DryRunOption, + version: string, +): void { + let content: string; + + if (fs.existsSync(CHANGELOG_FILE)) { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + } else { + content = EMPTY_CHANGELOG; + } + + const versionAndDate = `${version} - ${getTodayString()}`; + const newContent = content.replace("[UNRELEASED]", versionAndDate); + + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); + } else { + console.info( + `[DRY RUN] Would have replaced '[UNRELEASED]' in '${CHANGELOG_FILE}' with '${versionAndDate}'.`, + ); + } +} + +/** + * Processes changelog entries for a backport, converting version references + * from the source major version to the target major version and filtering + * entries that only apply to newer versions. + */ +export function processChangelogForBackports( + sourceBranchMajorVersion: string, + targetBranchMajorVersion: string, +): void { + const content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + const lines = content.split("\n"); + const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; + + let output = ""; + let i = 0; + + // Copy lines until we find the first section heading. + let foundFirstSection = false; + while (!foundFirstSection && i < lines.length) { + let line = lines[i]; + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + foundFirstSection = true; + } + output += `${line}\n`; + i++; + } + + if (!foundFirstSection) { + throw new Error("Could not find any change sections in CHANGELOG.md"); + } + + // Process remaining lines. + let foundContent = false; + output += "\n"; + + while (i < lines.length) { + let line = lines[i]; + i++; + + // Filter out changelog entries that only apply to newer versions. + const match = someVersionsOnlyRegex.exec(line); + if (match) { + if ( + Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) + ) { + continue; + } + } + + if (line.startsWith("## ")) { + line = line.replace( + `## ${sourceBranchMajorVersion}`, + `## ${targetBranchMajorVersion}`, + ); + if (!foundContent) { + output += "No user facing changes.\n"; + } + foundContent = false; + output += `\n${line}\n\n`; + } else { + if (line.trim() !== "") { + foundContent = true; + output += `${line}\n`; + } + } + } + + fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); +} diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 0fda12ad2f..0d69170ab6 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -23,21 +23,8 @@ import * as fs from "node:fs"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; -import { - CHANGELOG_FILE, - DryRunOption, - PACKAGE_JSON, - REPO_ROOT, -} from "./config"; - -/** Placeholder changelog content for a new release. */ -const EMPTY_CHANGELOG = `# CodeQL Action Changelog - -## [UNRELEASED] - -No user facing changes. - -`; +import * as changelog from "./changelog"; +import { DryRunOption, PACKAGE_JSON, REPO_ROOT } from "./config"; /** * NB: This exact commit message is used to find commits for reverting during backports. @@ -190,115 +177,6 @@ export function replaceVersionInPackageJson( fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); } -/** Returns today's date formatted as `DD Mon YYYY`. */ -export function getTodayString(): string { - const today = new Date(); - return today.toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }); -} - -/** - * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version - * and today's date. - */ -export function updateChangelog(options: MainOptions, version: string): void { - let content: string; - - if (fs.existsSync(CHANGELOG_FILE)) { - content = fs.readFileSync(CHANGELOG_FILE, "utf8"); - } else { - content = EMPTY_CHANGELOG; - } - - const versionAndDate = `${version} - ${getTodayString()}`; - const newContent = content.replace("[UNRELEASED]", versionAndDate); - - if (!options.dryRun) { - fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); - } else { - console.info( - `[DRY RUN] Would have replaced '[UNRELEASED]' in '${CHANGELOG_FILE}' with '${versionAndDate}'.`, - ); - } -} - -/** - * Processes changelog entries for a backport, converting version references - * from the source major version to the target major version and filtering - * entries that only apply to newer versions. - */ -export function processChangelogForBackports( - sourceBranchMajorVersion: string, - targetBranchMajorVersion: string, -): void { - const content = fs.readFileSync(CHANGELOG_FILE, "utf8"); - const lines = content.split("\n"); - const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; - - let output = ""; - let i = 0; - - // Copy lines until we find the first section heading. - let foundFirstSection = false; - while (!foundFirstSection && i < lines.length) { - let line = lines[i]; - if (line.startsWith("## ")) { - line = line.replace( - `## ${sourceBranchMajorVersion}`, - `## ${targetBranchMajorVersion}`, - ); - foundFirstSection = true; - } - output += `${line}\n`; - i++; - } - - if (!foundFirstSection) { - throw new Error("Could not find any change sections in CHANGELOG.md"); - } - - // Process remaining lines. - let foundContent = false; - output += "\n"; - - while (i < lines.length) { - let line = lines[i]; - i++; - - // Filter out changelog entries that only apply to newer versions. - const match = someVersionsOnlyRegex.exec(line); - if (match) { - if ( - Number.parseInt(targetBranchMajorVersion) < Number.parseInt(match[1]) - ) { - continue; - } - } - - if (line.startsWith("## ")) { - line = line.replace( - `## ${sourceBranchMajorVersion}`, - `## ${targetBranchMajorVersion}`, - ); - if (!foundContent) { - output += "No user facing changes.\n"; - } - foundContent = false; - output += `\n${line}\n\n`; - } else { - if (line.trim() !== "") { - foundContent = true; - output += `${line}\n`; - } - } - } - - fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); -} - /** Represents commits returned by the GitHub API (relevant fields only). */ export interface GitHubCommit { sha: string; @@ -795,7 +673,7 @@ export async function prepareNewBranch( console.log( `Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`, ); - processChangelogForBackports( + changelog.processChangelogForBackports( sourceBranchMajorVersion, targetBranchMajorVersion, ); @@ -818,7 +696,7 @@ export async function prepareNewBranch( ); console.log("Updating changelog"); - updateChangelog(options, version); + changelog.setVersionAndDate(options, version); runGit(["add", "CHANGELOG.md"], { dryRun: options.dryRun, From e2472fc5f96a36809cc5d9069a4f43f079717a2c Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 13:52:56 +0100 Subject: [PATCH 28/56] Make `processChangelogForBackports` dry-run-aware --- pr-checks/changelog.ts | 9 ++++++++- pr-checks/update-release-branch.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index dcddac55f0..f5f465e43a 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -55,6 +55,7 @@ export function setVersionAndDate( * entries that only apply to newer versions. */ export function processChangelogForBackports( + options: DryRunOption, sourceBranchMajorVersion: string, targetBranchMajorVersion: string, ): void { @@ -120,5 +121,11 @@ export function processChangelogForBackports( } } - fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); + if (!options.dryRun) { + fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); + } else { + console.info( + `[DRY RUN] Would have written updated changelog to replace 'v${sourceBranchMajorVersion}' with 'v${targetBranchMajorVersion}'.`, + ); + } } diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 0d69170ab6..58d5886a95 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -674,6 +674,7 @@ export async function prepareNewBranch( `Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`, ); changelog.processChangelogForBackports( + options, sourceBranchMajorVersion, targetBranchMajorVersion, ); From fd0ae66c1e21227ac6fed569465aaa4c40441242 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 13:59:38 +0100 Subject: [PATCH 29/56] Restore some comments --- pr-checks/update-release-branch.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 58d5886a95..d516b77620 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -156,6 +156,8 @@ export function getCurrentVersion(): string | undefined { /** * Replaces the version in `package.json` textually. Only updates the version * field that immediately follows the `"name": "codeql"` line. + * `npm version` doesn't always work because of merge conflicts, so we + * replace the version in package.json textually. */ export function replaceVersionInPackageJson( prevVersion: string, @@ -193,7 +195,8 @@ export function isPrMergeCommit(commit: GitHubCommit): boolean { /** * Gets a list of commits on the source branch that are not on the target branch, - * excluding automatic PR merge commits. + * excluding automatic PR merge commits. This will not include any commits that + * exist on the target branch that aren't on the source branch. * * Uses `git log` to find the SHAs, then fetches each commit from the GitHub API * to obtain full metadata (author, parents, associated PRs, etc.). @@ -289,6 +292,9 @@ export async function getPrForCommit( /** * Get the login of the person who merged a pull request. * Falls back to the commit author of the merge commit. + * For most cases this will be the same as the author, but for PRs opened + * by external contributors getting the merger will get us the GitHub + * employee who reviewed and merged the PR. */ export async function getMergerOfPr( client: ApiClient, @@ -543,6 +549,8 @@ function parseCliOptions(): MainOptions { * Rebuilds the action (npm ci + npm run build) and commits any changes. */ export function rebuildAction(options: MainOptions): void { + // For backports, the only source-level change vs the source branch is the new version number, + // so we just need to refresh the version embedded in `lib/`. runCommand("npm", ["ci"]); runCommand("npm", ["run", "build"]); @@ -596,10 +604,11 @@ export async function prepareNewBranch( { dryRun: options.dryRun }, ); - // Revert the commit that updated the version number and changelog to refer - // to older variants. This avoids merge conflicts when we merge in the newer - // release branch. The commit won't exist the first time we release a new - // major version, so we search for it conditionally. + // Revert the commit that we made as part of the last release that updated the version number and + // changelog to refer to {older}.x.x variants. This avoids merge conflicts in the changelog and + // package.json files when we merge in the v{latest} branch. + // This commit will not exist the first time we release the v{N-1} branch from the v{N} branch, so we + // use `git log --grep` to conditionally revert the commit. console.log( "Reverting the version number and changelog updates from the last release to avoid conflicts", ); From d4b332346338fb2808b1037ea31d8d36113c4430 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 14:06:10 +0100 Subject: [PATCH 30/56] Use TS script in workflow --- .github/workflows/update-release-branch.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml index 11e97eeca0..f690c3847e 100644 --- a/.github/workflows/update-release-branch.yml +++ b/.github/workflows/update-release-branch.yml @@ -69,7 +69,7 @@ jobs: run: | echo SOURCE_BRANCH=${REF_NAME} echo TARGET_BRANCH=releases/${MAJOR_VERSION} - python .github/update-release-branch.py \ + npx tsx ./pr-checks/update-release-branch.ts \ --repository-nwo ${{ github.repository }} \ --source-branch '${{ env.REF_NAME }}' \ --target-branch 'releases/${{ env.MAJOR_VERSION }}' \ @@ -113,7 +113,7 @@ jobs: run: | echo SOURCE_BRANCH=${SOURCE_BRANCH} echo TARGET_BRANCH=${TARGET_BRANCH} - python .github/update-release-branch.py \ + npx tsx ./pr-checks/update-release-branch.ts \ --repository-nwo ${{ github.repository }} \ --source-branch ${SOURCE_BRANCH} \ --target-branch ${TARGET_BRANCH} \ From f9a9f4862b598b09543f28d62c68bc2514d1fd56 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 14:10:10 +0100 Subject: [PATCH 31/56] Run `npm ci` in `release-initialise` workflow. Remove now unneeded `npm ci` from `release-branches` action --- .github/actions/release-branches/action.yml | 1 - .github/actions/release-initialise/action.yml | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-branches/action.yml b/.github/actions/release-branches/action.yml index 26be726205..7734411c73 100644 --- a/.github/actions/release-branches/action.yml +++ b/.github/actions/release-branches/action.yml @@ -22,7 +22,6 @@ runs: MAJOR_VERSION: ${{ inputs.major_version }} LATEST_TAG: ${{ inputs.latest_tag }} run: | - npm ci npx tsx ./pr-checks/release-branches.ts \ --major-version "$MAJOR_VERSION" \ --latest-tag "$LATEST_TAG" diff --git a/.github/actions/release-initialise/action.yml b/.github/actions/release-initialise/action.yml index 057d5a5b6d..be16f03950 100644 --- a/.github/actions/release-initialise/action.yml +++ b/.github/actions/release-initialise/action.yml @@ -21,6 +21,10 @@ runs: node-version: 24 cache: 'npm' + - name: Install JavaScript dependencies + shell: bash + run: npm ci + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: From 583bf3e8c5d99074aeacf325a017de374d92cfac Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 14:50:45 +0100 Subject: [PATCH 32/56] Fix comment typos --- pr-checks/config.ts | 2 +- pr-checks/update-release-branch.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pr-checks/config.ts b/pr-checks/config.ts index 3c14ca1dbd..94d34a931d 100644 --- a/pr-checks/config.ts +++ b/pr-checks/config.ts @@ -15,7 +15,7 @@ export const PR_CHECK_EXCLUDED_FILE = path.join(PR_CHECKS_DIR, "excluded.yml"); /** The path of the main `package.json`. */ export const PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); -/** THe path of the changelog. */ +/** The path of the changelog. */ export const CHANGELOG_FILE = path.join(REPO_ROOT, "CHANGELOG.md"); /** The path to the esbuild metadata file. */ diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index d516b77620..031aeb1993 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -72,7 +72,7 @@ export interface RunCommandOptions extends DryRunOption { * * @param command The name of the command to run. * @param args The arguments for the command. - * @throws When the process exists with a non-zero exit code. + * @throws When the process exits with a non-zero exit code. * @param options How to run the command. */ export function runCommand( From d694648fd8a2e6de4fd21dafe17212c723d0e1ca Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 15:27:30 +0100 Subject: [PATCH 33/56] Make `changelog.ts` more testable and add tests --- pr-checks/changelog.test.ts | 60 ++++++++++++++++++++++++++++ pr-checks/changelog.ts | 64 ++++++++++++++++-------------- pr-checks/update-release-branch.ts | 15 +++++-- 3 files changed, 105 insertions(+), 34 deletions(-) create mode 100755 pr-checks/changelog.test.ts diff --git a/pr-checks/changelog.test.ts b/pr-checks/changelog.test.ts new file mode 100755 index 0000000000..3eddc14591 --- /dev/null +++ b/pr-checks/changelog.test.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env npx tsx + +/** + * Tests for `changelog.ts`. + */ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + EMPTY_CHANGELOG, + getReleaseDateString, + processChangelogForBackports, + setVersionAndDate, +} from "./changelog"; + +const testDate = new Date(2026, 7, 14); + +describe("getReleaseDateString", async () => { + await it("formats dates as expected", async () => { + assert.equal(getReleaseDateString(testDate), "14 Aug 2026"); + }); +}); + +const emptyChangelogExpected = `# CodeQL Action Changelog + +## 9.99.9 - 14 Aug 2026 + +No user facing changes. + +`; + +describe("setVersionAndDate", async () => { + await it("replaces the placeholder", async () => { + const result = setVersionAndDate("9.99.9", EMPTY_CHANGELOG, testDate); + assert.equal(result, emptyChangelogExpected); + }); +}); + +const testChangelog = `# CodeQL Action Changelog + +## 4.12.3 - 14 Aug 2026 + +No user facing changes. +`; + +const testChangelogResult: string = `# CodeQL Action Changelog + +## 3.12.3 - 14 Aug 2026 + +No user facing changes. +`; + +describe("processChangelogForBackports", async () => { + await it("replaces major versions", async () => { + const result = processChangelogForBackports("4", "3", testChangelog); + + assert.deepEqual(result.split("\n"), testChangelogResult.split("\n")); + }); +}); diff --git a/pr-checks/changelog.ts b/pr-checks/changelog.ts index f5f465e43a..49011b0361 100644 --- a/pr-checks/changelog.ts +++ b/pr-checks/changelog.ts @@ -11,9 +11,8 @@ No user facing changes. `; -/** Returns today's date formatted as `DD Mon YYYY`. */ -export function getTodayString(): string { - const today = new Date(); +/** Returns `date` formatted as `DD Mon YYYY`. */ +export function getReleaseDateString(today: Date = new Date()): string { return today.toLocaleDateString("en-GB", { day: "2-digit", month: "short", @@ -21,46 +20,56 @@ export function getTodayString(): string { }); } -/** - * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version - * and today's date. - */ -export function setVersionAndDate( - options: DryRunOption, - version: string, +export interface OpenChangelogOptions { + initChangelog?: boolean; +} + +export function withChangelog( + transformer: (contents: string) => string, + options: DryRunOption & OpenChangelogOptions, ): void { let content: string; - if (fs.existsSync(CHANGELOG_FILE)) { - content = fs.readFileSync(CHANGELOG_FILE, "utf8"); - } else { + if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) { content = EMPTY_CHANGELOG; + } else { + content = fs.readFileSync(CHANGELOG_FILE, "utf8"); } - const versionAndDate = `${version} - ${getTodayString()}`; - const newContent = content.replace("[UNRELEASED]", versionAndDate); - if (!options.dryRun) { - fs.writeFileSync(CHANGELOG_FILE, newContent, "utf8"); + fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8"); } else { - console.info( - `[DRY RUN] Would have replaced '[UNRELEASED]' in '${CHANGELOG_FILE}' with '${versionAndDate}'.`, - ); + console.info(`[DRY RUN] Would have written updated changelog.`); } } +/** + * Updates the `[UNRELEASED]` marker in `CHANGELOG.md` with the given version + * and today's date. + */ +export function setVersionAndDate( + version: string, + content: string, + date: Date = new Date(), +): string { + const versionAndDate = `${version} - ${getReleaseDateString(date)}`; + return content.replace("[UNRELEASED]", versionAndDate); +} + /** * Processes changelog entries for a backport, converting version references * from the source major version to the target major version and filtering * entries that only apply to newer versions. */ export function processChangelogForBackports( - options: DryRunOption, sourceBranchMajorVersion: string, targetBranchMajorVersion: string, -): void { - const content = fs.readFileSync(CHANGELOG_FILE, "utf8"); + content: string, +): string { const lines = content.split("\n"); + + // Changelog entries can use the following format to indicate + // that they only apply to newer versions const someVersionsOnlyRegex = /\[v(\d+)\+ only\]/; let output = ""; @@ -86,6 +95,7 @@ export function processChangelogForBackports( } // Process remaining lines. + // `foundContent` tracks whether we hit two headings in a row let foundContent = false; output += "\n"; @@ -121,11 +131,5 @@ export function processChangelogForBackports( } } - if (!options.dryRun) { - fs.writeFileSync(CHANGELOG_FILE, output, "utf8"); - } else { - console.info( - `[DRY RUN] Would have written updated changelog to replace 'v${sourceBranchMajorVersion}' with 'v${targetBranchMajorVersion}'.`, - ); - } + return output; } diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 031aeb1993..552b20570e 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -682,10 +682,14 @@ export async function prepareNewBranch( console.log( `Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`, ); - changelog.processChangelogForBackports( + changelog.withChangelog( + (contents) => + changelog.processChangelogForBackports( + sourceBranchMajorVersion, + targetBranchMajorVersion, + contents, + ), options, - sourceBranchMajorVersion, - targetBranchMajorVersion, ); runGit(["add", "CHANGELOG.md"], { @@ -706,7 +710,10 @@ export async function prepareNewBranch( ); console.log("Updating changelog"); - changelog.setVersionAndDate(options, version); + changelog.withChangelog( + (contents) => changelog.setVersionAndDate(version, contents), + { ...options, initChangelog: true }, + ); runGit(["add", "CHANGELOG.md"], { dryRun: options.dryRun, From 9b314f43949e2c2046a366bf79f6f2b2c13d76a8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 15:30:26 +0100 Subject: [PATCH 34/56] Make `replaceVersionInPackageJson` dry-run-aware --- pr-checks/update-release-branch.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 552b20570e..501f9596f9 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -160,6 +160,7 @@ export function getCurrentVersion(): string | undefined { * replace the version in package.json textually. */ export function replaceVersionInPackageJson( + options: DryRunOption, prevVersion: string, newVersion: string, ): void { @@ -176,7 +177,13 @@ export function replaceVersionInPackageJson( prevLineIsCodeql = line.includes('"name": "codeql",'); } - fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); + if (!options.dryRun) { + fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); + } else { + console.info( + `[DRY RUN] Would have replaced '${prevVersion}' with '${newVersion}' in package.json`, + ); + } } /** Represents commits returned by the GitHub API (relevant fields only). */ @@ -672,7 +679,7 @@ export async function prepareNewBranch( console.log(`Setting version number to '${version}' in package.json`); const currentPkgVersion = getCurrentVersion(); if (currentPkgVersion) { - replaceVersionInPackageJson(currentPkgVersion, version); + replaceVersionInPackageJson(options, currentPkgVersion, version); } runGit(["add", "package.json"], { dryRun: options.dryRun, From a464bf19e9a8c325a313621f18b96db2d7668cde Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 15:33:11 +0100 Subject: [PATCH 35/56] Move `package.json` helpers into their own file --- pr-checks/update-release-branch.ts | 45 ++---------------------------- pr-checks/versions.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 43 deletions(-) create mode 100644 pr-checks/versions.ts diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 501f9596f9..d6cc7670aa 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -19,12 +19,12 @@ */ import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; -import * as fs from "node:fs"; import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; import * as changelog from "./changelog"; -import { DryRunOption, PACKAGE_JSON, REPO_ROOT } from "./config"; +import { DryRunOption, REPO_ROOT } from "./config"; +import { getCurrentVersion, replaceVersionInPackageJson } from "./versions"; /** * NB: This exact commit message is used to find commits for reverting during backports. @@ -145,47 +145,6 @@ export function branchExistsOnRemote(branchName: string): boolean { return result !== ""; } -/** Reads the current version from `package.json`. */ -export function getCurrentVersion(): string | undefined { - const pkg: { version: string } = JSON.parse( - fs.readFileSync(PACKAGE_JSON, "utf8"), - ); - return pkg.version; -} - -/** - * Replaces the version in `package.json` textually. Only updates the version - * field that immediately follows the `"name": "codeql"` line. - * `npm version` doesn't always work because of merge conflicts, so we - * replace the version in package.json textually. - */ -export function replaceVersionInPackageJson( - options: DryRunOption, - prevVersion: string, - newVersion: string, -): void { - const lines = fs.readFileSync(PACKAGE_JSON, "utf8").split("\n"); - let prevLineIsCodeql = false; - const output: string[] = []; - - for (const line of lines) { - if (prevLineIsCodeql && line.includes(`"version": "${prevVersion}"`)) { - output.push(line.replace(prevVersion, newVersion)); - } else { - output.push(line); - } - prevLineIsCodeql = line.includes('"name": "codeql",'); - } - - if (!options.dryRun) { - fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); - } else { - console.info( - `[DRY RUN] Would have replaced '${prevVersion}' with '${newVersion}' in package.json`, - ); - } -} - /** Represents commits returned by the GitHub API (relevant fields only). */ export interface GitHubCommit { sha: string; diff --git a/pr-checks/versions.ts b/pr-checks/versions.ts new file mode 100644 index 0000000000..359868350d --- /dev/null +++ b/pr-checks/versions.ts @@ -0,0 +1,44 @@ +import * as fs from "node:fs"; + +import { DryRunOption, PACKAGE_JSON } from "./config"; + +/** Reads the current version from `package.json`. */ +export function getCurrentVersion(): string | undefined { + const pkg: { version: string } = JSON.parse( + fs.readFileSync(PACKAGE_JSON, "utf8"), + ); + return pkg.version; +} + +/** + * Replaces the version in `package.json` textually. Only updates the version + * field that immediately follows the `"name": "codeql"` line. + * `npm version` doesn't always work because of merge conflicts, so we + * replace the version in package.json textually. + */ +export function replaceVersionInPackageJson( + options: DryRunOption, + prevVersion: string, + newVersion: string, +): void { + const lines = fs.readFileSync(PACKAGE_JSON, "utf8").split("\n"); + let prevLineIsCodeql = false; + const output: string[] = []; + + for (const line of lines) { + if (prevLineIsCodeql && line.includes(`"version": "${prevVersion}"`)) { + output.push(line.replace(prevVersion, newVersion)); + } else { + output.push(line); + } + prevLineIsCodeql = line.includes('"name": "codeql",'); + } + + if (!options.dryRun) { + fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); + } else { + console.info( + `[DRY RUN] Would have replaced '${prevVersion}' with '${newVersion}' in package.json`, + ); + } +} From ae48798f3bbaa8c5eac5968d60ae411c37dabbb5 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 15:48:57 +0100 Subject: [PATCH 36/56] Make `versions.ts` testable and add basic tests --- pr-checks/update-release-branch.ts | 28 +++++++++++++++---- pr-checks/versions.test.ts | 44 ++++++++++++++++++++++++++++++ pr-checks/versions.ts | 38 ++++++++++++++++---------- 3 files changed, 90 insertions(+), 20 deletions(-) create mode 100755 pr-checks/versions.test.ts diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index d6cc7670aa..56f3aef31c 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -24,7 +24,11 @@ import { parseArgs } from "node:util"; import { type ApiClient, getApiClient } from "./api-client"; import * as changelog from "./changelog"; import { DryRunOption, REPO_ROOT } from "./config"; -import { getCurrentVersion, replaceVersionInPackageJson } from "./versions"; +import { + getCurrentVersion, + replaceVersionInPackageJson, + withPackageJson, +} from "./versions"; /** * NB: This exact commit message is used to find commits for reverting during backports. @@ -636,10 +640,20 @@ export async function prepareNewBranch( // Migrate the package version number. console.log(`Setting version number to '${version}' in package.json`); - const currentPkgVersion = getCurrentVersion(); - if (currentPkgVersion) { - replaceVersionInPackageJson(options, currentPkgVersion, version); - } + withPackageJson((content) => { + const currentPkgVersion = getCurrentVersion(content); + if (currentPkgVersion) { + return { + content: replaceVersionInPackageJson( + currentPkgVersion, + version, + content, + ), + value: currentPkgVersion, + }; + } + return { value: currentPkgVersion }; + }, options); runGit(["add", "package.json"], { dryRun: options.dryRun, }); @@ -733,7 +747,9 @@ async function main(): Promise { "", ); - const currentVersion = getCurrentVersion(); + const currentVersion = withPackageJson((content) => { + return { value: getCurrentVersion(content) }; + }, options); if (!currentVersion) { throw new Error("Failed to read current version from package.json"); diff --git a/pr-checks/versions.test.ts b/pr-checks/versions.test.ts new file mode 100755 index 0000000000..6697710f83 --- /dev/null +++ b/pr-checks/versions.test.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env npx tsx + +/** + * Tests for `versions.ts`. + */ + +import * as assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { getCurrentVersion, replaceVersionInPackageJson } from "./versions"; + +describe("getCurrentVersion", async () => { + await it("reads versions", async () => { + const result = getCurrentVersion(`{ "version": "1.23.4" }`); + assert.deepEqual(result, "1.23.4"); + }); +}); + +const packageJsonContents = `{ + "name": "codeql", + "version": "1.23.4" +} +`; + +const packageJsonContentsExpected = `{ + "name": "codeql", + "version": "2.23.4" +} +`; + +describe("replaceVersionInPackageJson", async () => { + await it("replaces versions", async () => { + const result = replaceVersionInPackageJson( + "1.23.4", + "2.23.4", + packageJsonContents, + ); + assert.deepEqual( + result.split("\n"), + packageJsonContentsExpected.split("\n"), + ); + assert.deepEqual(JSON.parse(result), { name: "codeql", version: "2.23.4" }); + }); +}); diff --git a/pr-checks/versions.ts b/pr-checks/versions.ts index 359868350d..4abc7faa17 100644 --- a/pr-checks/versions.ts +++ b/pr-checks/versions.ts @@ -2,11 +2,27 @@ import * as fs from "node:fs"; import { DryRunOption, PACKAGE_JSON } from "./config"; +export function withPackageJson( + transformer: (content: string) => { value: T; content?: string }, + options: DryRunOption, +): T { + const content = fs.readFileSync(PACKAGE_JSON, "utf8"); + const result = transformer(content); + + if (result.content !== undefined) { + if (!options.dryRun) { + fs.writeFileSync(PACKAGE_JSON, result.content, "utf8"); + } else { + console.info(`[DRY RUN] Would have written an updated package.json`); + } + } + + return result.value; +} + /** Reads the current version from `package.json`. */ -export function getCurrentVersion(): string | undefined { - const pkg: { version: string } = JSON.parse( - fs.readFileSync(PACKAGE_JSON, "utf8"), - ); +export function getCurrentVersion(content: string): string | undefined { + const pkg: { version: string } = JSON.parse(content); return pkg.version; } @@ -17,11 +33,11 @@ export function getCurrentVersion(): string | undefined { * replace the version in package.json textually. */ export function replaceVersionInPackageJson( - options: DryRunOption, prevVersion: string, newVersion: string, -): void { - const lines = fs.readFileSync(PACKAGE_JSON, "utf8").split("\n"); + content: string, +): string { + const lines = content.split("\n"); let prevLineIsCodeql = false; const output: string[] = []; @@ -34,11 +50,5 @@ export function replaceVersionInPackageJson( prevLineIsCodeql = line.includes('"name": "codeql",'); } - if (!options.dryRun) { - fs.writeFileSync(PACKAGE_JSON, `${output.join("\n")}\n`, "utf8"); - } else { - console.info( - `[DRY RUN] Would have replaced '${prevVersion}' with '${newVersion}' in package.json`, - ); - } + return output.join("\n"); } From 80599cc5d9430d42a6d963e12d90ee141d4616c5 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 16:07:08 +0100 Subject: [PATCH 37/56] Allow proxy to be threaded into `createApiClientWithDetails` This will make it easier to gate the `getRegistryProxy` with a FF, without the significant refactoring that is needed to thread the FFs into `createApiClientWithDetails` --- lib/entry-points.js | 22 ++++++++++++---------- src/api-client.ts | 31 ++++++++++++++++++++++++++++--- src/config/file.ts | 3 ++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 5d9420a9f2..0bf6c44be6 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -145634,16 +145634,17 @@ function getRegistryProxy(env) { } return void 0; } -function getApiFetch(env) { - const dispatcher = getRegistryProxy(env); - const proxiedFetch = (req, init2) => { - return (0, import_undici.fetch)(req, { ...init2, dispatcher }); +function makeProxyRequestOptions(dispatcher) { + return { + fetch: (req, init2) => { + return (0, import_undici.fetch)(req, { ...init2, dispatcher }); + } }; - return proxiedFetch; } -function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) { +function createApiClientWithDetails(apiDetails, { allowExternal = false, proxy = void 0 } = {}) { const auth2 = allowExternal && apiDetails.externalRepoAuth || apiDetails.auth; const retryingOctokit = githubUtils.GitHub.plugin(retry); + const requestOptions = proxy === void 0 ? void 0 : makeProxyRequestOptions(proxy); return new retryingOctokit( githubUtils.getOctokitOptions(auth2, { baseUrl: apiDetails.apiURL, @@ -145654,7 +145655,7 @@ function createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) warn: core5.warning, error: core5.error }, - request: { fetch: getApiFetch(getEnv()) }, + request: requestOptions, retry: { doNotRetry: DO_NOT_RETRY_STATUSES } @@ -145671,8 +145672,8 @@ function getApiDetails(env = getEnv()) { function getApiClient(env = getEnv()) { return createApiClientWithDetails(getApiDetails(env)); } -function getApiClientWithExternalAuth(apiDetails) { - return createApiClientWithDetails(apiDetails, { allowExternal: true }); +function getApiClientWithExternalAuth(apiDetails, proxy) { + return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } function getAuthorizationHeaderFor(logger, apiDetails, url2) { if (url2.startsWith(`${apiDetails.url}/`) || apiDetails.apiURL && url2.startsWith(`${apiDetails.apiURL}/`)) { @@ -148082,7 +148083,8 @@ async function getConfigFileInput({ } async function getRemoteConfig(actionState, configFile, apiDetails) { const address = await parseRemoteFileAddress(actionState, configFile); - const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({ + const proxy = getRegistryProxy(actionState.env); + const response = await getApiClientWithExternalAuth(apiDetails, proxy).rest.repos.getContent({ owner: address.owner, repo: address.repo, path: address.path, diff --git a/src/api-client.ts b/src/api-client.ts index 70ac17c1d7..87e6416689 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,6 +1,7 @@ import * as core from "@actions/core"; import * as githubUtils from "@actions/github/lib/utils"; import * as retry from "@octokit/plugin-retry"; +import { RequestRequestOptions } from "@octokit/types"; import { ProxyAgent, RequestInfo, @@ -80,6 +81,22 @@ export function getRegistryProxy(env: ReadOnlyEnv): ProxyAgent | undefined { return undefined; } +/** + * Constructs a `RequestRequestOptions` with a custom `fetch` implementation + * that uses `dispatcher` as a proxy for requests. + * + * @param dispatcher The proxy to use. + */ +export function makeProxyRequestOptions( + dispatcher: ProxyAgent, +): RequestRequestOptions { + return { + fetch: (req: RequestInfo, init?: RequestInit) => { + return undiciFetch(req, { ...init, dispatcher }); + }, + }; +} + /** * Returns an implementation of `fetch` to use for API requests. * This will run API requests through the private registry authentication proxy @@ -96,13 +113,20 @@ export function getApiFetch(env: ReadOnlyEnv): typeof undiciFetch { return proxiedFetch; } +interface CreateApiClientOptions { + allowExternal?: boolean; + proxy?: ProxyAgent; +} + function createApiClientWithDetails( apiDetails: GitHubApiCombinedDetails, - { allowExternal = false } = {}, + { allowExternal = false, proxy = undefined }: CreateApiClientOptions = {}, ) { const auth = (allowExternal && apiDetails.externalRepoAuth) || apiDetails.auth; const retryingOctokit = githubUtils.GitHub.plugin(retry.retry); + const requestOptions = + proxy === undefined ? undefined : makeProxyRequestOptions(proxy); return new retryingOctokit( githubUtils.getOctokitOptions(auth, { baseUrl: apiDetails.apiURL, @@ -113,7 +137,7 @@ function createApiClientWithDetails( warn: core.warning, error: core.error, }, - request: { fetch: getApiFetch(getEnv()) }, + request: requestOptions, retry: { doNotRetry: DO_NOT_RETRY_STATUSES, }, @@ -135,8 +159,9 @@ export function getApiClient(env: ReadOnlyEnv = getEnv()) { export function getApiClientWithExternalAuth( apiDetails: GitHubApiCombinedDetails, + proxy?: ProxyAgent, ) { - return createApiClientWithDetails(apiDetails, { allowExternal: true }); + return createApiClientWithDetails(apiDetails, { allowExternal: true, proxy }); } /** diff --git a/src/config/file.ts b/src/config/file.ts index 5ff2dee082..b17e1c9d8b 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -68,9 +68,10 @@ export async function getRemoteConfig( apiDetails: api.GitHubApiCombinedDetails, ): Promise { const address = await parseRemoteFileAddress(actionState, configFile); + const proxy = api.getRegistryProxy(actionState.env); const response = await api - .getApiClientWithExternalAuth(apiDetails) + .getApiClientWithExternalAuth(apiDetails, proxy) .rest.repos.getContent({ owner: address.owner, repo: address.repo, From 6d70593fb72b91319b8274998372e3f12946935a Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 16:12:38 +0100 Subject: [PATCH 38/56] Gate proxy usage behind FF --- .github/workflows/__start-proxy.yml | 1 + lib/entry-points.js | 10 +++++++++- pr-checks/checks/start-proxy.yml | 2 ++ src/config/file.ts | 8 +++++++- src/feature-flags.ts | 7 +++++++ 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index 30ca74fd9c..9a782865bf 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -102,4 +102,5 @@ jobs: tools: ${{ steps.prepare-test.outputs.tools-url }} config-file: codeql-action@main:tests/multi-language-repo/.github/codeql/custom-queries.yml env: + CODEQL_ACTION_PROXY_API_REQUESTS: 'true' CODEQL_ACTION_TEST_MODE: true diff --git a/lib/entry-points.js b/lib/entry-points.js index 0bf6c44be6..bf8d274512 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -146762,6 +146762,11 @@ var featureConfig = { legacyApi: true, minimumVersion: void 0 }, + ["proxy_api_requests" /* ProxyApiRequests */]: { + defaultValue: false, + envVar: "CODEQL_ACTION_PROXY_API_REQUESTS", + minimumVersion: void 0 + }, ["skip_file_coverage_on_prs" /* SkipFileCoverageOnPrs */]: { defaultValue: false, envVar: "CODEQL_ACTION_SKIP_FILE_COVERAGE_ON_PRS", @@ -148083,7 +148088,10 @@ async function getConfigFileInput({ } async function getRemoteConfig(actionState, configFile, apiDetails) { const address = await parseRemoteFileAddress(actionState, configFile); - const proxy = getRegistryProxy(actionState.env); + const shouldProxyRequest = await actionState.features.getValue( + "proxy_api_requests" /* ProxyApiRequests */ + ); + const proxy = shouldProxyRequest ? getRegistryProxy(actionState.env) : void 0; const response = await getApiClientWithExternalAuth(apiDetails, proxy).rest.repos.getContent({ owner: address.owner, repo: address.repo, diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index dedf5633ea..c4bc02f736 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -6,6 +6,8 @@ operatingSystems: - windows versions: - linked +env: + CODEQL_ACTION_PROXY_API_REQUESTS: "true" steps: - name: Setup proxy for registries id: proxy diff --git a/src/config/file.ts b/src/config/file.ts index b17e1c9d8b..38f9b19fd4 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -68,7 +68,13 @@ export async function getRemoteConfig( apiDetails: api.GitHubApiCombinedDetails, ): Promise { const address = await parseRemoteFileAddress(actionState, configFile); - const proxy = api.getRegistryProxy(actionState.env); + + const shouldProxyRequest = await actionState.features.getValue( + Feature.ProxyApiRequests, + ); + const proxy = shouldProxyRequest + ? api.getRegistryProxy(actionState.env) + : undefined; const response = await api .getApiClientWithExternalAuth(apiDetails, proxy) diff --git a/src/feature-flags.ts b/src/feature-flags.ts index dcc5c9d7bd..7f689f2a44 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -136,6 +136,8 @@ export enum Feature { /** Controls whether overlay build failures on the default branch are stored in the Actions cache. */ OverlayAnalysisStatusSave = "overlay_analysis_status_save", QaTelemetryEnabled = "qa_telemetry_enabled", + /** Routes (some) API requests through the registry proxy. */ + ProxyApiRequests = "proxy_api_requests", /** Note that this currently only disables baseline file coverage information. */ SkipFileCoverageOnPrs = "skip_file_coverage_on_prs", StartProxyUseFeaturesRelease = "start_proxy_use_features_release", @@ -382,6 +384,11 @@ export const featureConfig = { legacyApi: true, minimumVersion: undefined, }, + [Feature.ProxyApiRequests]: { + defaultValue: false, + envVar: "CODEQL_ACTION_PROXY_API_REQUESTS", + minimumVersion: undefined, + }, [Feature.SkipFileCoverageOnPrs]: { defaultValue: false, envVar: "CODEQL_ACTION_SKIP_FILE_COVERAGE_ON_PRS", From 205b37b035c53a89e2f107054a65d024fcf0d6f2 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 16:17:51 +0100 Subject: [PATCH 39/56] Use `ActionState` for `getRegistryProxy` --- lib/entry-points.js | 10 +++++----- src/api-client.test.ts | 40 +++++++++++++++++++--------------------- src/api-client.ts | 19 +++++++++++-------- src/config/file.ts | 2 +- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index bf8d274512..0cf4dc2bc0 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -145620,10 +145620,10 @@ function parseRepositoryNwo(input) { // src/api-client.ts var GITHUB_ENTERPRISE_VERSION_HEADER = "x-github-enterprise-version"; var DO_NOT_RETRY_STATUSES = [400, 410, 422, 451]; -function getRegistryProxy(env) { - const host = env.getOptional("CODEQL_PROXY_HOST" /* PROXY_HOST */); - const port = env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */); - const cert = env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */); +function getRegistryProxy(action) { + const host = action.env.getOptional("CODEQL_PROXY_HOST" /* PROXY_HOST */); + const port = action.env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */); + const cert = action.env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */); if (host && port) { return new import_undici.ProxyAgent({ uri: `http://${host}:${port}`, @@ -148091,7 +148091,7 @@ async function getRemoteConfig(actionState, configFile, apiDetails) { const shouldProxyRequest = await actionState.features.getValue( "proxy_api_requests" /* ProxyApiRequests */ ); - const proxy = shouldProxyRequest ? getRegistryProxy(actionState.env) : void 0; + const proxy = shouldProxyRequest ? getRegistryProxy(actionState) : void 0; const response = await getApiClientWithExternalAuth(apiDetails, proxy).rest.repos.getContent({ owner: address.owner, repo: address.repo, diff --git a/src/api-client.test.ts b/src/api-client.test.ts index 4c8342691e..8282847145 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -7,7 +7,7 @@ import * as actionsUtil from "./actions-util"; import * as api from "./api-client"; import { DO_NOT_RETRY_STATUSES } from "./api-client"; import { ActionsEnvVars, RegistryProxyVars } from "./environment"; -import { getTestEnv, setupTests } from "./testing-utils"; +import { callee, getTestEnv, setupTests } from "./testing-utils"; import * as util from "./util"; setupTests(test); @@ -209,30 +209,28 @@ test.serial( ); test("getRegistryProxy - returns undefined if the proxy is not configured", async (t) => { + const target = callee(api.getRegistryProxy).withArgs(); + // Empty environment. - t.is(api.getRegistryProxy(getTestEnv()), undefined); + await target.passes(t.is, undefined); // Only the host. - t.is( - api.getRegistryProxy( - getTestEnv({ [RegistryProxyVars.PROXY_HOST]: "localhost" }), - ), - undefined, - ); + await target + .withEnv(getTestEnv({ [RegistryProxyVars.PROXY_HOST]: "localhost" })) + .passes(t.is, undefined); // Only the port. - t.is( - api.getRegistryProxy( - getTestEnv({ [RegistryProxyVars.PROXY_PORT]: "1234" }), - ), - undefined, - ); + await target + .withEnv(getTestEnv({ [RegistryProxyVars.PROXY_PORT]: "1234" })) + .passes(t.is, undefined); }); test("getRegistryProxy - returns value when both vars are set", async (t) => { - const proxy = api.getRegistryProxy( - getTestEnv({ - [RegistryProxyVars.PROXY_HOST]: "localhost", - [RegistryProxyVars.PROXY_PORT]: "1234", - }), - ); - t.truthy(proxy); + await callee(api.getRegistryProxy) + .withArgs() + .withEnv( + getTestEnv({ + [RegistryProxyVars.PROXY_HOST]: "localhost", + [RegistryProxyVars.PROXY_PORT]: "1234", + }), + ) + .passes(t.truthy); }); diff --git a/src/api-client.ts b/src/api-client.ts index 87e6416689..261e0a557e 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -9,6 +9,7 @@ import { fetch as undiciFetch, } from "undici"; +import type { ActionState } from "./action-common"; import { getActionVersion, getRequiredInput } from "./actions-util"; import { ActionsEnvVars, @@ -60,14 +61,16 @@ export interface GitHubApiExternalRepoDetails { * Gets the configuration for the private registry authentication proxy, * if it is available in the environment. * - * @param env The environment to query for the proxy host and port. + * @param action The required Action state. * @returns A `ProxyAgent` corresponding to the private registry proxy, * or `undefined` if we couldn't retrieve the host and port. */ -export function getRegistryProxy(env: ReadOnlyEnv): ProxyAgent | undefined { - const host = env.getOptional(RegistryProxyVars.PROXY_HOST); - const port = env.getOptional(RegistryProxyVars.PROXY_PORT); - const cert = env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE); +export function getRegistryProxy( + action: ActionState<["Env"]>, +): ProxyAgent | undefined { + const host = action.env.getOptional(RegistryProxyVars.PROXY_HOST); + const port = action.env.getOptional(RegistryProxyVars.PROXY_PORT); + const cert = action.env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE); if (host && port) { return new ProxyAgent({ @@ -102,10 +105,10 @@ export function makeProxyRequestOptions( * This will run API requests through the private registry authentication proxy * if it is configured. * - * @param env The environment to query for the proxy host and port. + * @param action The required Action state. */ -export function getApiFetch(env: ReadOnlyEnv): typeof undiciFetch { - const dispatcher = getRegistryProxy(env); +export function getApiFetch(action: ActionState<["Env"]>): typeof undiciFetch { + const dispatcher = getRegistryProxy(action); const proxiedFetch = (req: RequestInfo, init?: RequestInit) => { return undiciFetch(req, { ...init, dispatcher }); diff --git a/src/config/file.ts b/src/config/file.ts index 38f9b19fd4..5ca723a462 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -73,7 +73,7 @@ export async function getRemoteConfig( Feature.ProxyApiRequests, ); const proxy = shouldProxyRequest - ? api.getRegistryProxy(actionState.env) + ? api.getRegistryProxy(actionState) : undefined; const response = await api From b946565527528c534f210541ae538484e228a3ce Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 16:59:30 +0100 Subject: [PATCH 40/56] Add and use `clone` method for `ReadOnlyEnv` --- lib/entry-points.js | 4 ++++ src/environment.ts | 5 +++++ src/testing-utils.ts | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 0cf4dc2bc0..2fcf8c198c 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -141488,6 +141488,10 @@ var ReadOnlyEnv = class { this.vars = vars; } vars; + /** Clones the object while detaching the underlying environment from the original. */ + clone() { + return Object.create(this, { vars: { value: { ...this.vars } } }); + } /** Tries to get the value for `name` and throws if there isn't one. */ getRequired(name) { return getRequiredEnvVar(this.vars, name); diff --git a/src/environment.ts b/src/environment.ts index 9d52a903ff..1b00ab7cfb 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -265,6 +265,11 @@ export function getOptionalEnvVar(paramName: string): string | undefined { export class ReadOnlyEnv { constructor(protected readonly vars: Record) {} + /** Clones the object while detaching the underlying environment from the original. */ + public clone(): this { + return Object.create(this, { vars: { value: { ...this.vars } } }) as this; + } + /** Tries to get the value for `name` and throws if there isn't one. */ public getRequired(name: string): string { return getRequiredEnvVar(this.vars, name); diff --git a/src/testing-utils.ts b/src/testing-utils.ts index f630082be4..92fad068e1 100644 --- a/src/testing-utils.ts +++ b/src/testing-utils.ts @@ -240,7 +240,7 @@ abstract class BaseEnvBuilder< cloneFrom !== undefined ? ({ ...cloneFrom.state, - env: Object.create(cloneFrom.state.env), + env: cloneFrom.state.env.clone(), actions: Object.create(cloneFrom.state.actions), logger: this.logger, } satisfies ActionState) From 47e9c299985f55d8764baec1d5c86f4f2a0c8d50 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 17:02:07 +0100 Subject: [PATCH 41/56] Log whether the proxy is picked up on --- lib/entry-points.js | 6 +++++- src/api-client.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 2fcf8c198c..93f0143c53 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -145629,8 +145629,12 @@ function getRegistryProxy(action) { const port = action.env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */); const cert = action.env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */); if (host && port) { + const uri = `http://${host}:${port}`; + action.logger.debug( + `Using private registry proxy at '${uri}' for API client.` + ); return new import_undici.ProxyAgent({ - uri: `http://${host}:${port}`, + uri, keepAliveTimeout: 10, keepAliveMaxTimeout: 10, requestTls: cert ? { ca: cert } : void 0 diff --git a/src/api-client.ts b/src/api-client.ts index 261e0a557e..3782bc2cff 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -66,15 +66,19 @@ export interface GitHubApiExternalRepoDetails { * or `undefined` if we couldn't retrieve the host and port. */ export function getRegistryProxy( - action: ActionState<["Env"]>, + action: ActionState<["Logger", "Env"]>, ): ProxyAgent | undefined { const host = action.env.getOptional(RegistryProxyVars.PROXY_HOST); const port = action.env.getOptional(RegistryProxyVars.PROXY_PORT); const cert = action.env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE); if (host && port) { + const uri = `http://${host}:${port}`; + action.logger.debug( + `Using private registry proxy at '${uri}' for API client.`, + ); return new ProxyAgent({ - uri: `http://${host}:${port}`, + uri, keepAliveTimeout: 10, keepAliveMaxTimeout: 10, requestTls: cert ? { ca: cert } : undefined, @@ -107,7 +111,9 @@ export function makeProxyRequestOptions( * * @param action The required Action state. */ -export function getApiFetch(action: ActionState<["Env"]>): typeof undiciFetch { +export function getApiFetch( + action: ActionState<["Logger", "Env"]>, +): typeof undiciFetch { const dispatcher = getRegistryProxy(action); const proxiedFetch = (req: RequestInfo, init?: RequestInit) => { From a1c676d2f63f55655fd3e8b4ce2738bb06669e1b Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 17:02:22 +0100 Subject: [PATCH 42/56] Test proxy usage for `getRemoteConfig` --- src/config/file.test.ts | 55 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/config/file.test.ts b/src/config/file.test.ts index 4b7b88c9ca..b07ea71ba2 100644 --- a/src/config/file.test.ts +++ b/src/config/file.test.ts @@ -1,11 +1,18 @@ +import * as github from "@actions/github"; import test from "ava"; import sinon from "sinon"; +import * as api from "../api-client"; +import { RegistryProxyVars } from "../environment"; import { Feature } from "../feature-flags"; import { RepositoryPropertyName } from "../feature-flags/properties"; -import { callee, setupTests } from "../testing-utils"; +import { + callee, + SAMPLE_DOTCOM_API_DETAILS, + setupTests, +} from "../testing-utils"; -import { getConfigFileInput } from "./file"; +import { getConfigFileInput, getRemoteConfig } from "./file"; setupTests(test); @@ -67,3 +74,47 @@ test("getConfigFileInput ignores repository property value when FF is off", asyn ) .passes(t.is, undefined); }); + +test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { + const client = github.getOctokit("123"); + const response = { + data: { + content: Buffer.from("disable-default-queries: false").toString("base64"), + }, + }; + sinon + .stub(client.rest.repos, "getContent") + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .resolves(response as any); + sinon.stub(api, "getApiClientWithExternalAuth").value(() => client); + + const target = callee(getRemoteConfig) + .withDefaultActionsEnv() + .withArgs("file.yml", SAMPLE_DOTCOM_API_DETAILS); + + // Should use it when the FF is enabled and the environment variables are set. + await target + .withFeatures([Feature.ProxyApiRequests, Feature.NewRemoteFileAddresses]) + .withEnv((env) => { + env.set(RegistryProxyVars.PROXY_HOST, "localhost"); + env.set(RegistryProxyVars.PROXY_PORT, "1234"); + }) + .logs(t, "Using private registry proxy at 'http://localhost:1234'") + .passes(t.truthy); + + // But not when the FF is not enabled. + await target + .withFeatures([Feature.NewRemoteFileAddresses]) + .withEnv((env) => { + env.set(RegistryProxyVars.PROXY_HOST, "localhost"); + env.set(RegistryProxyVars.PROXY_PORT, "1234"); + }) + .notLogs(t, "Using private registry proxy at 'http://localhost:1234'") + .passes(t.truthy); + + // And not when the environment variables aren't set. + await target + .withFeatures([Feature.ProxyApiRequests, Feature.NewRemoteFileAddresses]) + .notLogs(t, "Using private registry proxy at 'http://localhost:1234'") + .passes(t.truthy); +}); From 1a0eca3555da206b41fe4b8abbba9ef343abcf0f Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 14 Jul 2026 19:33:01 +0100 Subject: [PATCH 43/56] Validate `proxy` argument in tests --- src/config/file.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/config/file.test.ts b/src/config/file.test.ts index b07ea71ba2..6f6b4a5d91 100644 --- a/src/config/file.test.ts +++ b/src/config/file.test.ts @@ -86,7 +86,22 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { .stub(client.rest.repos, "getContent") // eslint-disable-next-line @typescript-eslint/no-unsafe-argument .resolves(response as any); - sinon.stub(api, "getApiClientWithExternalAuth").value(() => client); + + // We stub `getApiClientWithExternalAuth` so that it throws if no + // proxy is provided and returns the client otherwise. This allows us + // to verify the result in the following test cases. + const errorMessage = "No `proxy` was provided by the caller."; + sinon + .stub(api, "getApiClientWithExternalAuth") + .callsFake((_details, proxy) => { + // Throw if proxy isn't defined. + if (proxy === undefined) { + throw new Error(errorMessage); + } + // Otherwise return the client object. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return client as unknown as any; + }); const target = callee(getRemoteConfig) .withDefaultActionsEnv() @@ -110,11 +125,11 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { env.set(RegistryProxyVars.PROXY_PORT, "1234"); }) .notLogs(t, "Using private registry proxy at 'http://localhost:1234'") - .passes(t.truthy); + .throws(t, { message: errorMessage }); // And not when the environment variables aren't set. await target .withFeatures([Feature.ProxyApiRequests, Feature.NewRemoteFileAddresses]) .notLogs(t, "Using private registry proxy at 'http://localhost:1234'") - .passes(t.truthy); + .throws(t, { message: errorMessage }); }); From 41c7af7cf027f8a837f639a6622fdc8268791d01 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 11:58:08 +0100 Subject: [PATCH 44/56] Use `paginate` --- pr-checks/update-release-branch.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pr-checks/update-release-branch.ts b/pr-checks/update-release-branch.ts index 56f3aef31c..088da59281 100755 --- a/pr-checks/update-release-branch.ts +++ b/pr-checks/update-release-branch.ts @@ -243,12 +243,14 @@ export async function getPrForCommit( repo: string, commit: GitHubCommit, ): Promise { - const { data: prs } = - await client.rest.repos.listPullRequestsAssociatedWithCommit({ + const prs = await client.paginate( + client.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: commit.sha, - }); + }, + ); if (prs.length === 0) { return undefined; From 423e3416b1f644707e97cd12376673b098644dc8 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Wed, 15 Jul 2026 11:58:26 +0100 Subject: [PATCH 45/56] Delete python version --- .github/update-release-branch.py | 474 ------------------------------- 1 file changed, 474 deletions(-) delete mode 100644 .github/update-release-branch.py diff --git a/.github/update-release-branch.py b/.github/update-release-branch.py deleted file mode 100644 index 2635126827..0000000000 --- a/.github/update-release-branch.py +++ /dev/null @@ -1,474 +0,0 @@ -import argparse -import datetime -import fileinput -import re -from github import Github -import json -import os -import subprocess - -EMPTY_CHANGELOG = """# CodeQL Action Changelog - -## [UNRELEASED] - -No user facing changes. - -""" - -# NB: This exact commit message is used to find commits for reverting during backports. -# Changing it requires a transition period where both old and new versions are supported. -BACKPORT_COMMIT_MESSAGE = 'Update version and changelog for v' - -# Commit message used for rebuild commits, both those produced by this script and those produced -# by the `Rebuild Action` workflow (`.github/workflows/rebuild.yml`). -REBUILD_COMMIT_MESSAGE = 'Rebuild' - -# Name of the remote -ORIGIN = 'origin' - -# Environment variables to check for a GitHub API token. -TOKEN_ENVIRONMENT_VARIABLES = ('GH_TOKEN', 'GITHUB_TOKEN') - -# Gets a GitHub API token from one of the supported environment variables. -def get_github_token(): - for variable_name in TOKEN_ENVIRONMENT_VARIABLES: - token = os.environ.get(variable_name, '').strip() - if token: - return token - raise Exception('Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN.') - -# Runs git with the given args and returns the stdout. -# Raises an error if git does not exit successfully (unless passed -# allow_non_zero_exit_code=True). -def run_git(*args, allow_non_zero_exit_code=False): - cmd = ['git', *args] - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if not allow_non_zero_exit_code and p.returncode != 0: - raise Exception(f'Call to {" ".join(cmd)} exited with code {p.returncode} stderr: {p.stderr.decode("ascii")}.') - return p.stdout.decode('ascii') - -# Runs the given command, streaming output to the console. -# Raises an error if the command does not exit successfully. -def run_command(*args): - cmd = list(args) - print(f'Running `{" ".join(cmd)}`.') - subprocess.run(cmd, check=True) - -# Rebuilds the action and commits any changes. -def rebuild_action(): - # For backports, the only source-level change vs the source branch is the new version number, - # so we just need to refresh the version embedded in `lib/`. - run_command('npm', 'ci') - run_command('npm', 'run', 'build') - - run_git('add', '--all') - # `git diff --cached --quiet` exits 0 if there are no staged changes, 1 if there are. - if subprocess.run(['git', 'diff', '--cached', '--quiet']).returncode == 0: - print('Rebuild produced no changes; skipping Rebuild commit.') - else: - run_git('commit', '-m', REBUILD_COMMIT_MESSAGE) - print('Created Rebuild commit.') - -# Returns true if the given branch exists on the origin remote -def branch_exists_on_remote(branch_name): - return run_git('ls-remote', '--heads', ORIGIN, branch_name).strip() != '' - -# Opens a PR from the given branch to the target branch -def open_pr( - repo, all_commits, source_branch_short_sha, new_branch_name, source_branch, target_branch, - conductor, is_primary_release, conflicted_files): - # Sort the commits into the pull requests that introduced them, - # and any commits that don't have a pull request - pull_requests = [] - commits_without_pull_requests = [] - for commit in all_commits: - pr = get_pr_for_commit(commit) - - if pr is None: - commits_without_pull_requests.append(commit) - elif not any(p for p in pull_requests if p.number == pr.number): - pull_requests.append(pr) - - print(f'Found {len(pull_requests)} pull requests.') - print(f'Found {len(commits_without_pull_requests)} commits not in a pull request.') - - # Sort PRs and commits by age - pull_requests = sorted(pull_requests, key=lambda pr: pr.number) - commits_without_pull_requests = sorted(commits_without_pull_requests, key=lambda c: c.commit.author.date) - - # Start constructing the body text - body = [] - body.append(f'Merging {source_branch_short_sha} into `{target_branch}`.') - - body.append('') - body.append(f'Conductor for this PR is @{conductor}.') - - # List all PRs merged - if len(pull_requests) > 0: - body.append('') - body.append('Contains the following pull requests:') - for pr in pull_requests: - # Use PR author if they are GitHub staff, otherwise use the merger - display_user = get_pr_author_if_staff(pr) or get_merger_of_pr(repo, pr) - body.append(f'- #{pr.number} (@{display_user})') - - # List all commits not part of a PR - if len(commits_without_pull_requests) > 0: - body.append('') - body.append('Contains the following commits not from a pull request:') - for commit in commits_without_pull_requests: - author_description = f' (@{commit.author.login})' if commit.author is not None else '' - body.append(f'- {commit.sha} - {get_truncated_commit_message(commit)}{author_description}') - - body.append('') - body.append('Please do the following:') - if len(conflicted_files) > 0: - body.append(' - [ ] Ensure `package.json` file contains the correct version.') - body.append(' - [ ] Add a commit to this branch to resolve the merge conflicts ' + - 'in the following files:') - body.extend([f' - `{file}`' for file in conflicted_files]) - body.append(' - [ ] Rebuild the Action locally (`npm run build`) and push any changes to the ' + - f'built output in `lib` as a separate commit named exactly `{REBUILD_COMMIT_MESSAGE}`.') - body.append(' - [ ] Ensure another maintainer has reviewed the additional commits you added to this ' + - 'branch to resolve the merge conflicts.') - body.append(' - [ ] Ensure the CHANGELOG displays the correct version and date.') - body.append(' - [ ] Ensure the CHANGELOG includes all relevant, user-facing changes since the last release.') - body.append(f' - [ ] Check that there are not any unexpected commits being merged into the `{target_branch}` branch.') - body.append(' - [ ] Ensure the docs team is aware of any documentation changes that need to be released.') - - body.append(' - [ ] Approve running the full set of PR checks if you have not pushed any changes.') - body.append(' - [ ] Approve and merge this PR. Make sure `Create a merge commit` is selected rather than `Squash and merge` or `Rebase and merge`.') - - if is_primary_release: - body.append(' - [ ] Merge the mergeback PR that will automatically be created once this PR is merged.') - body.append(' - [ ] Merge all backport PRs to older release branches, that will automatically be created once this PR is merged.') - - title = f'Merge {source_branch} into {target_branch}' - - # Create the pull request - pr = repo.create_pull(title=title, body='\n'.join(body), head=new_branch_name, base=target_branch) - print(f'Created PR #{str(pr.number)}') - - # Assign the conductor - pr.add_to_assignees(conductor) - print(f'Assigned PR to {conductor}') - -# Gets a list of the SHAs of all commits that have happened on the source branch -# since the last release to the target branch. -# This will not include any commits that exist on the target branch -# that aren't on the source branch. -def get_commit_difference(repo, source_branch, target_branch): - # Passing split nothing means that the empty string splits to nothing: compare `''.split() == []` - # to `''.split('\n') == ['']`. - commits = run_git('log', '--pretty=format:%H', f'{ORIGIN}/{target_branch}..{ORIGIN}/{source_branch}').strip().split() - - # Convert to full-fledged commit objects - commits = [repo.get_commit(c) for c in commits] - - # Filter out merge commits for PRs - return list(filter(lambda c: not is_pr_merge_commit(c), commits)) - -# Is the given commit the automatic merge commit from when merging a PR -def is_pr_merge_commit(commit): - return commit.committer is not None and commit.committer.login == 'web-flow' and len(commit.parents) > 1 - -# Gets a copy of the commit message that should display nicely -def get_truncated_commit_message(commit): - message = commit.commit.message.split('\n')[0] - if len(message) > 60: - return f'{message[:57]}...' - else: - return message - -# Converts a commit into the PR that introduced it to the source branch. -# Returns the PR object, or None if no PR could be found. -def get_pr_for_commit(commit): - prs = commit.get_pulls() - - if prs.totalCount > 0: - # In the case that there are multiple PRs, return the earliest one - prs = list(prs) - sorted_prs = sorted(prs, key=lambda pr: int(pr.number)) - return sorted_prs[0] - else: - return None - -# Get the person who merged the pull request. -# For most cases this will be the same as the author, but for PRs opened -# by external contributors getting the merger will get us the GitHub -# employee who reviewed and merged the PR. -def get_merger_of_pr(repo, pr): - return repo.get_commit(pr.merge_commit_sha).author.login - -# Get the PR author if they are GitHub staff, otherwise None. -def get_pr_author_if_staff(pr): - if pr.user is None: - return None - if getattr(pr.user, 'site_admin', False): - return pr.user.login - return None - -def get_current_version(): - with open('package.json', 'r') as f: - return json.load(f)['version'] - -# `npm version` doesn't always work because of merge conflicts, so we -# replace the version in package.json textually. -def replace_version_package_json(prev_version, new_version): - prev_line_is_codeql = False - for line in fileinput.input('package.json', inplace = True, encoding='utf-8'): - if prev_line_is_codeql and f'\"version\": \"{prev_version}\"' in line: - print(line.replace(prev_version, new_version), end='') - else: - prev_line_is_codeql = False - print(line, end='') - if '\"name\": \"codeql\",' in line: - prev_line_is_codeql = True - -def get_today_string(): - today = datetime.datetime.today() - return '{:%d %b %Y}'.format(today) - -def process_changelog_for_backports(source_branch_major_version, target_branch_major_version): - - # changelog entries can use the following format to indicate - # that they only apply to newer versions - some_versions_only_regex = re.compile(r'\[v(\d+)\+ only\]') - - output = '' - - with open('CHANGELOG.md', 'r') as f: - - # until we find the first section, just duplicate all lines - found_first_section = False - while not found_first_section: - line = f.readline() - if not line: - raise Exception('Could not find any change sections in CHANGELOG.md') # EOF - - if line.startswith('## '): - line = line.replace(f'## {source_branch_major_version}', f'## {target_branch_major_version}') - found_first_section = True - - output += line - - # found_content tracks whether we hit two headings in a row - found_content = False - output += '\n' - while True: - line = f.readline() - if not line: - break # EOF - line = line.rstrip('\n') - - # filter out changenote entries that apply only to newer versions - match = some_versions_only_regex.search(line) - if match: - if int(target_branch_major_version) < int(match.group(1)): - continue - - if line.startswith('## '): - line = line.replace(f'## {source_branch_major_version}', f'## {target_branch_major_version}') - if found_content == False: - # we have found two headings in a row, so we need to add the placeholder message. - output += 'No user facing changes.\n' - found_content = False - output += f'\n{line}\n\n' - else: - if line.strip() != '': - found_content = True - # we use the original line here, rather than the stripped version - # so that we preserve indentation - output += line + '\n' - - with open('CHANGELOG.md', 'w') as f: - f.write(output) - -def update_changelog(version): - if (os.path.exists('CHANGELOG.md')): - content = '' - with open('CHANGELOG.md', 'r') as f: - content = f.read() - else: - content = EMPTY_CHANGELOG - - newContent = content.replace('[UNRELEASED]', f'{version} - {get_today_string()}', 1) - - with open('CHANGELOG.md', 'w') as f: - f.write(newContent) - - -def main(): - parser = argparse.ArgumentParser('update-release-branch.py') - - parser.add_argument( - '--repository-nwo', - type=str, - required=True, - help='The nwo of the repository, for example github/codeql-action.' - ) - parser.add_argument( - '--source-branch', - type=str, - required=True, - help='Source branch for release branch update.' - ) - parser.add_argument( - '--target-branch', - type=str, - required=True, - help='Target branch for release branch update.' - ) - parser.add_argument( - '--is-primary-release', - action='store_true', - default=False, - help='Whether this update is the primary release for the current major version.' - ) - parser.add_argument( - '--conductor', - type=str, - required=True, - help='The GitHub handle of the person who is conducting the release process.' - ) - - args = parser.parse_args() - - source_branch = args.source_branch - target_branch = args.target_branch - is_primary_release = args.is_primary_release - - repo = Github(get_github_token()).get_repo(args.repository_nwo) - - # the target branch will be of the form releases/vN, where N is the major version number - target_branch_major_version = target_branch.strip('releases/v') - - # split version into major, minor, patch - _, v_minor, v_patch = get_current_version().split('.') - - version = f"{target_branch_major_version}.{v_minor}.{v_patch}" - - # Print what we intend to go - print(f'Considering difference between {source_branch} and {target_branch}...') - source_branch_short_sha = run_git('rev-parse', '--short', f'{ORIGIN}/{source_branch}').strip() - print(f'Current head of {source_branch} is {source_branch_short_sha}.') - - # See if there are any commits to merge in - commits = get_commit_difference(repo=repo, source_branch=source_branch, target_branch=target_branch) - if len(commits) == 0: - print(f'No commits to merge from {source_branch} to {target_branch}.') - return - - # define distinct prefix in order to support specific pr checks on backports - branch_prefix = 'update' if is_primary_release else 'backport' - - # The branch name is based off of the name of branch being merged into - # and the SHA of the branch being merged from. Thus if the branch already - # exists we can assume we don't need to recreate it. - new_branch_name = f'{branch_prefix}-v{version}-{source_branch_short_sha}' - print(f'Branch name is {new_branch_name}.') - - # Check if the branch already exists. If so we can abort as this script - # has already run on this combination of branches. - if branch_exists_on_remote(new_branch_name): - print(f'Branch {new_branch_name} already exists. Nothing to do.') - return - - # Create the new branch and push it to the remote - print(f'Creating branch {new_branch_name}.') - - # The process of creating the v{Older} release can run into merge conflicts. We commit the unresolved - # conflicts so a maintainer can easily resolve them (vs erroring and requiring maintainers to - # reconstruct the release manually) - conflicted_files = [] - - if not is_primary_release: - - # the source branch will be of the form releases/vN, where N is the major version number - source_branch_major_version = source_branch.strip('releases/v') - - # If we're performing a backport, start from the target branch - print(f'Creating {new_branch_name} from the {ORIGIN}/{target_branch} branch') - run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{target_branch}') - - # Revert the commit that we made as part of the last release that updated the version number and - # changelog to refer to {older}.x.x variants. This avoids merge conflicts in the changelog and - # package.json files when we merge in the v{latest} branch. - # This commit will not exist the first time we release the v{N-1} branch from the v{N} branch, so we - # use `git log --grep` to conditionally revert the commit. - print('Reverting the version number and changelog updates from the last release to avoid conflicts') - vOlder_update_commits = run_git('log', '--grep', f'^{BACKPORT_COMMIT_MESSAGE}', '--format=%H').split() - - if len(vOlder_update_commits) > 0: - print(f' Reverting {vOlder_update_commits[0]}') - # Only revert the newest commit as older ones will already have been reverted in previous - # releases. - run_git('revert', vOlder_update_commits[0], '--no-edit') - - # Also revert the "Rebuild" commit, whether created by this script or by the - # `Rebuild Action` workflow. - rebuild_commit = run_git('log', '--grep', f'^{REBUILD_COMMIT_MESSAGE}$', '--format=%H').split()[0] - print(f' Reverting {rebuild_commit}') - run_git('revert', rebuild_commit, '--no-edit') - - else: - print(' Nothing to revert.') - - print(f'Merging {ORIGIN}/{source_branch} into the release prep branch') - # Commit any conflicts (see the comment for `conflicted_files`) - run_git('merge', f'{ORIGIN}/{source_branch}', allow_non_zero_exit_code=True) - conflicted_files = run_git('diff', '--name-only', '--diff-filter', 'U').splitlines() - if len(conflicted_files) > 0: - run_git('add', '.') - run_git('commit', '--no-edit') - - # Migrate the package version number from a vLatest version number to a vOlder version number. - # `package-lock.json` is updated as part of the subsequent rebuild step (see `rebuild_action`). - print(f'Setting version number to {version} in package.json') - replace_version_package_json(get_current_version(), version) - run_git('add', 'package.json') - - # Migrate the changelog notes from vLatest version numbers to vOlder version numbers - print(f'Migrating changelog notes from v{source_branch_major_version} to v{target_branch_major_version}') - process_changelog_for_backports(source_branch_major_version, target_branch_major_version) - - # Amend the commit generated by `npm version` to update the CHANGELOG - run_git('add', 'CHANGELOG.md') - run_git('commit', '-m', f'{BACKPORT_COMMIT_MESSAGE}{version}') - else: - # If we're performing a standard release, there won't be any new commits on the target branch, - # as these will have already been merged back into the source branch. Therefore we can just - # start from the source branch. - run_git('checkout', '-b', new_branch_name, f'{ORIGIN}/{source_branch}') - - print('Updating changelog') - update_changelog(version) - - # Create a commit that updates the CHANGELOG - run_git('add', 'CHANGELOG.md') - run_git('commit', '-m', f'Update changelog for v{version}') - - if not is_primary_release: - if len(conflicted_files) == 0: - print('Rebuilding the Action.') - rebuild_action() - else: - print(f'Skipping automatic rebuild because the merge produced conflicts in {conflicted_files}.') - - run_git('push', ORIGIN, new_branch_name) - - # Open a PR to update the branch - open_pr( - repo, - commits, - source_branch_short_sha, - new_branch_name, - source_branch=source_branch, - target_branch=target_branch, - conductor=args.conductor, - is_primary_release=is_primary_release, - conflicted_files=conflicted_files - ) - -if __name__ == '__main__': - main() From 4292bd7215851dcd3c540380197130472bfef553 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 16 Jul 2026 12:54:34 +0100 Subject: [PATCH 46/56] Remove unneeded type assertion now that `ApiClient` exists --- src/config/file.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config/file.test.ts b/src/config/file.test.ts index 6f6b4a5d91..ae86613bc7 100644 --- a/src/config/file.test.ts +++ b/src/config/file.test.ts @@ -99,8 +99,7 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { throw new Error(errorMessage); } // Otherwise return the client object. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return client as unknown as any; + return client; }); const target = callee(getRemoteConfig) From 122630153770f68a9debafe90e2e4ff113f12a69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:35:09 +0000 Subject: [PATCH 47/56] Update changelog and version after v4.37.1 --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acd1dff8da..f1a24768d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ See the [releases page](https://github.com/github/codeql-action/releases) for the relevant changes to the CodeQL CLI and language packs. +## [UNRELEASED] + +No user facing changes. + ## 4.37.1 - 16 Jul 2026 - _Upcoming breaking change_: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. [#3956](https://github.com/github/codeql-action/pull/3956) diff --git a/package-lock.json b/package-lock.json index 38453f697c..a4e4407944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeql", - "version": "4.37.1", + "version": "4.37.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeql", - "version": "4.37.1", + "version": "4.37.2", "license": "MIT", "workspaces": [ "pr-checks" diff --git a/package.json b/package.json index 76bbbc0f07..b8fe51b5e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeql", - "version": "4.37.1", + "version": "4.37.2", "private": true, "description": "CodeQL action", "scripts": { From 0297913805a8763ef6c4547e40b15fae68705d8c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:35:21 +0000 Subject: [PATCH 48/56] Rebuild --- lib/entry-points.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index fca6ae2f04..e4f0877f9e 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -145327,7 +145327,7 @@ function getDiffRangesJsonFilePath(env = getEnv()) { return path2.join(getTemporaryDirectory(env), PR_DIFF_RANGE_JSON_FILENAME); } function getActionVersion() { - return "4.37.1"; + return "4.37.2"; } function getWorkflowEventName(env = getEnv()) { return env.getRequired("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */); From 8125f87336cabf8f5a801924212a6c4f43166ecf Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Thu, 16 Jul 2026 21:34:10 +0100 Subject: [PATCH 49/56] Remove unused `getApiFetch` --- src/api-client.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/api-client.ts b/src/api-client.ts index 48de34d8de..65b2af8ca6 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -107,24 +107,6 @@ export function makeProxyRequestOptions( }; } -/** - * Returns an implementation of `fetch` to use for API requests. - * This will run API requests through the private registry authentication proxy - * if it is configured. - * - * @param action The required Action state. - */ -export function getApiFetch( - action: ActionState<["Logger", "Env"]>, -): typeof undiciFetch { - const dispatcher = getRegistryProxy(action); - - const proxiedFetch = (req: RequestInfo, init?: RequestInit) => { - return undiciFetch(req, { ...init, dispatcher }); - }; - return proxiedFetch; -} - /** The type of GitHub API client we use. */ export type ApiClient = Octokit & Api & { paginate: PaginateInterface }; From 7db34ae6f4bcc6c2d0cd551d0d316db863392cd7 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Fri, 17 Jul 2026 13:48:49 +0100 Subject: [PATCH 50/56] Refactor looking up proxy env vars into `getRegistryProxyConfig`. This makes that function testable. --- lib/entry-points.js | 13 +++++++++---- src/api-client.test.ts | 17 +++++++++++++++++ src/api-client.ts | 23 ++++++++++++++++++----- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 760b1caacd..14a3dfdb58 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -145764,10 +145764,15 @@ function parseRepositoryNwo(input) { // src/api-client.ts var GITHUB_ENTERPRISE_VERSION_HEADER = "x-github-enterprise-version"; var DO_NOT_RETRY_STATUSES = [400, 410, 422, 451]; +function getRegistryProxyConfig(action) { + return { + host: action.env.getOptional("CODEQL_PROXY_HOST" /* PROXY_HOST */), + port: action.env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */), + ca: action.env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */) + }; +} function getRegistryProxy(action) { - const host = action.env.getOptional("CODEQL_PROXY_HOST" /* PROXY_HOST */); - const port = action.env.getOptional("CODEQL_PROXY_PORT" /* PROXY_PORT */); - const cert = action.env.getOptional("CODEQL_PROXY_CA_CERTIFICATE" /* PROXY_CA_CERTIFICATE */); + const { host, port, ca } = getRegistryProxyConfig(action); if (host && port) { const uri = `http://${host}:${port}`; action.logger.debug( @@ -145777,7 +145782,7 @@ function getRegistryProxy(action) { uri, keepAliveTimeout: 10, keepAliveMaxTimeout: 10, - requestTls: cert ? { ca: cert } : void 0 + requestTls: ca ? { ca } : void 0 }); } return void 0; diff --git a/src/api-client.test.ts b/src/api-client.test.ts index 8282847145..e43f16ef29 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -234,3 +234,20 @@ test("getRegistryProxy - returns value when both vars are set", async (t) => { ) .passes(t.truthy); }); + +test("getRegistryProxyConfig - gets the configuration from the env vars", async (t) => { + const host = "localhost"; + const port = "1234"; + const ca = "cert"; + + await callee(api.getRegistryProxyConfig) + .withArgs() + .withEnv( + getTestEnv({ + [RegistryProxyVars.PROXY_HOST]: host, + [RegistryProxyVars.PROXY_PORT]: port, + [RegistryProxyVars.PROXY_CA_CERTIFICATE]: ca, + }), + ) + .passes(t.like, { host, port, ca }); +}); diff --git a/src/api-client.ts b/src/api-client.ts index 65b2af8ca6..85141e482a 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -65,15 +65,28 @@ export interface GitHubApiExternalRepoDetails { * if it is available in the environment. * * @param action The required Action state. + * @returns The hostname, port, and CA retrieved from the corresponding environment variables. + */ +export function getRegistryProxyConfig(action: ActionState<["ReadOnlyEnv"]>) { + return { + host: action.env.getOptional(RegistryProxyVars.PROXY_HOST), + port: action.env.getOptional(RegistryProxyVars.PROXY_PORT), + ca: action.env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE), + }; +} + +/** + * Gets the configuration for the private registry authentication proxy, + * and uses it to initialise a corresponding `ProxyAgent`. + * + * @param action The required Action state. * @returns A `ProxyAgent` corresponding to the private registry proxy, * or `undefined` if we couldn't retrieve the host and port. */ export function getRegistryProxy( - action: ActionState<["Logger", "Env"]>, + action: ActionState<["Logger", "ReadOnlyEnv"]>, ): ProxyAgent | undefined { - const host = action.env.getOptional(RegistryProxyVars.PROXY_HOST); - const port = action.env.getOptional(RegistryProxyVars.PROXY_PORT); - const cert = action.env.getOptional(RegistryProxyVars.PROXY_CA_CERTIFICATE); + const { host, port, ca } = getRegistryProxyConfig(action); if (host && port) { const uri = `http://${host}:${port}`; @@ -84,7 +97,7 @@ export function getRegistryProxy( uri, keepAliveTimeout: 10, keepAliveMaxTimeout: 10, - requestTls: cert ? { ca: cert } : undefined, + requestTls: ca ? { ca } : undefined, }); } From da21ad6a712e73690867d847b8575160b4c53417 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 20 Jul 2026 11:22:34 +0100 Subject: [PATCH 51/56] Remove `NewRemoteFileAddresses` FF and default to its behaviour --- lib/entry-points.js | 29 ++------------- src/config-utils.test.ts | 4 +- src/config-utils.ts | 12 ++---- src/config/file.test.ts | 5 +-- src/config/remote-file.test.ts | 67 +++++++--------------------------- src/config/remote-file.ts | 11 ------ src/feature-flags.ts | 7 ---- 7 files changed, 25 insertions(+), 110 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 41af9350b6..10a7b72e25 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -146795,11 +146795,6 @@ var featureConfig = { envVar: "CODEQL_ACTION_JAVA_NETWORK_DEBUGGING", minimumVersion: void 0 }, - ["new_remote_file_addresses" /* NewRemoteFileAddresses */]: { - defaultValue: false, - envVar: "CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES", - minimumVersion: void 0 - }, ["overlay_analysis" /* OverlayAnalysis */]: { defaultValue: false, envVar: "CODEQL_ACTION_OVERLAY_ANALYSIS", @@ -147862,11 +147857,6 @@ function getInvalidConfigFileMessage(configFile, messages) { const andMore = messages.length > 10 ? `, and ${messages.length - 10} more.` : "."; return `The configuration file "${configFile}" is invalid: ${messages.slice(0, 10).join(", ")}${andMore}`; } -function getConfigFileRepoOldFormatInvalidMessage(configFile) { - let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; - error3 += " Expected format //@"; - return error3; -} function getConfigFileRepoFormatInvalidMessage(configFile) { let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`; error3 += " Expected format [/][@][:]"; @@ -148379,14 +148369,6 @@ async function parseRemoteFileAddress(actionState, configFile) { if (oldFormatAddressResult.isSuccess()) { return oldFormatAddressResult.value; } - const allowNewFormat = await actionState.features.getValue( - "new_remote_file_addresses" /* NewRemoteFileAddresses */ - ); - if (!allowNewFormat) { - throw new ConfigurationError( - getConfigFileRepoOldFormatInvalidMessage(configFile) - ); - } const newFormatAddressResult = parseNewRemoteFileAddress( actionState.env, configFile @@ -149299,10 +149281,7 @@ async function downloadCacheWithTime(codeQL, languages, logger) { return { trapCaches, trapCacheDownloadTime }; } async function loadUserConfig(actionState, configFile, workspacePath, apiDetails, tempDir) { - const allowNewFormat = await actionState.features.getValue( - "new_remote_file_addresses" /* NewRemoteFileAddresses */ - ); - if (isLocal(configFile, allowNewFormat)) { + if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { configFile = path10.resolve(workspacePath, configFile); if (!(configFile + path10.sep).startsWith(workspacePath + path10.sep)) { @@ -149316,7 +149295,7 @@ async function loadUserConfig(actionState, configFile, workspacePath, apiDetails ); return getLocalConfig(actionState.logger, configFile, validateConfig); } else { - if (allowNewFormat && isExplicitRemotePath(configFile)) { + if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -149787,11 +149766,11 @@ function isExplicitRemotePath(configPath) { function containsAtRef(configPath) { return configPath.includes("@"); } -function isLocal(configPath, allowNewFormat) { +function isLocal(configPath) { if (isExplicitLocalPath(configPath)) { return true; } - if (allowNewFormat && isExplicitRemotePath(configPath)) { + if (isExplicitRemotePath(configPath)) { return false; } return !containsAtRef(configPath); diff --git a/src/config-utils.test.ts b/src/config-utils.test.ts index 4a6fa739e2..84c709e72a 100644 --- a/src/config-utils.test.ts +++ b/src/config-utils.test.ts @@ -2607,9 +2607,7 @@ test.serial( const getRemoteConfig = sinon.stub(file, "getRemoteConfig").resolves({}); // Construct the basic test target. - const target = callee(configUtils.loadUserConfig) - .withDefaultActionsEnv() - .withFeatures([Feature.NewRemoteFileAddresses]); + const target = callee(configUtils.loadUserConfig).withDefaultActionsEnv(); // Utility function to assert that `targetWithArgs` has identified // the input as a remote file address. diff --git a/src/config-utils.ts b/src/config-utils.ts index 948494f531..040cdc7060 100644 --- a/src/config-utils.ts +++ b/src/config-utils.ts @@ -489,11 +489,7 @@ export async function loadUserConfig( apiDetails: api.GitHubApiCombinedDetails, tempDir: string, ): Promise { - const allowNewFormat = await actionState.features.getValue( - Feature.NewRemoteFileAddresses, - ); - - if (isLocal(configFile, allowNewFormat)) { + if (isLocal(configFile)) { if (configFile !== userConfigFromActionPath(tempDir)) { // If the config file is not generated by the Action, it should be relative to the workspace. configFile = path.resolve(workspacePath, configFile); @@ -512,7 +508,7 @@ export async function loadUserConfig( // Drop the explicit prefix if it is present. Since `REMOTE_PATH_PREFIX` is chosen // to not conflict with permissible characters in "owner" or "repo" components, // this does not risk removing valid parts of either component by accident. - if (allowNewFormat && isExplicitRemotePath(configFile)) { + if (isExplicitRemotePath(configFile)) { configFile = configFile.substring(REMOTE_PATH_PREFIX.length); } return await getRemoteConfig(actionState, configFile, apiDetails); @@ -1326,7 +1322,7 @@ function containsAtRef(configPath: string): boolean { * @param configPath The path to test. * @returns True if it is local, or false otherwise. */ -function isLocal(configPath: string, allowNewFormat: boolean): boolean { +function isLocal(configPath: string): boolean { // If the path starts with `LOCAL_PATH_PREFIX`, it is explicitly local. // This allows local paths that would otherwise contain '@' // to be used with a `LOCAL_PATH_PREFIX` prefix. @@ -1335,7 +1331,7 @@ function isLocal(configPath: string, allowNewFormat: boolean): boolean { } // If the path starts with `REMOTE_PATH_PREFIX`, it is explicitly remote. // This allows users to resolve ambiguity by specifying `REMOTE_PATH_PREFIX`. - if (allowNewFormat && isExplicitRemotePath(configPath)) { + if (isExplicitRemotePath(configPath)) { return false; } diff --git a/src/config/file.test.ts b/src/config/file.test.ts index ae86613bc7..22e2f795f8 100644 --- a/src/config/file.test.ts +++ b/src/config/file.test.ts @@ -108,7 +108,7 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { // Should use it when the FF is enabled and the environment variables are set. await target - .withFeatures([Feature.ProxyApiRequests, Feature.NewRemoteFileAddresses]) + .withFeatures([Feature.ProxyApiRequests]) .withEnv((env) => { env.set(RegistryProxyVars.PROXY_HOST, "localhost"); env.set(RegistryProxyVars.PROXY_PORT, "1234"); @@ -118,7 +118,6 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { // But not when the FF is not enabled. await target - .withFeatures([Feature.NewRemoteFileAddresses]) .withEnv((env) => { env.set(RegistryProxyVars.PROXY_HOST, "localhost"); env.set(RegistryProxyVars.PROXY_PORT, "1234"); @@ -128,7 +127,7 @@ test.serial("getRemoteConfig uses proxy when it is supposed to", async (t) => { // And not when the environment variables aren't set. await target - .withFeatures([Feature.ProxyApiRequests, Feature.NewRemoteFileAddresses]) + .withFeatures([Feature.ProxyApiRequests]) .notLogs(t, "Using private registry proxy at 'http://localhost:1234'") .throws(t, { message: errorMessage }); }); diff --git a/src/config/remote-file.test.ts b/src/config/remote-file.test.ts index fd1fcf3c17..e263e6d79a 100644 --- a/src/config/remote-file.test.ts +++ b/src/config/remote-file.test.ts @@ -2,8 +2,6 @@ import test from "ava"; import sinon from "sinon"; import { ActionsEnvVars } from "../environment"; -import * as errors from "../error-messages"; -import { Feature } from "../feature-flags"; import { callee } from "../testing-utils"; import { ConfigurationError } from "../util"; @@ -75,15 +73,7 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => { for (const newFormatInput of newFormatInputs) { const targetWithArgs = target.withArgs(newFormatInput.input); - // Should fail when the FF is not enabled. - await targetWithArgs - .withFeatures([]) - .throws(t, { instanceOf: ConfigurationError }); - - // And pass when the FF is enabled. - await targetWithArgs - .withFeatures([Feature.NewRemoteFileAddresses]) - .passes(t.deepEqual, newFormatInput.expected); + await targetWithArgs.passes(t.deepEqual, newFormatInput.expected); } }); @@ -138,15 +128,7 @@ test("parseRemoteFileAddress accepts remote address without an owner", async (t) for (const testCase of testCases) { const targetWithArgs = target.withArgs(testCase.input); - // Should fail when the FF is not enabled. - await targetWithArgs - .withFeatures([]) - .throws(t, { instanceOf: ConfigurationError }); - - // And pass when the FF is enabled. - await targetWithArgs - .withFeatures([Feature.NewRemoteFileAddresses]) - .passes(t.deepEqual, testCase.expected); + await targetWithArgs.passes(t.deepEqual, testCase.expected); } }); @@ -160,9 +142,7 @@ test("parseRemoteFileAddress throws for invalid `GITHUB_REPOSITORY`", async (t) sinon.define(env, "getRequired", getRequired); }); - await target - .withFeatures([Feature.NewRemoteFileAddresses]) - .throws(t, { instanceOf: Error }); + await target.throws(t, { instanceOf: Error }); t.assert(getRequired.calledOnceWith(ActionsEnvVars.GITHUB_REPOSITORY)); }); @@ -194,31 +174,19 @@ test("parseRemoteFileAddress accepts remote address without a path", async (t) = for (const testCase of testCases) { const targetWithArgs = target.withArgs(testCase.input); - // Should fail when the FF is not enabled. - await targetWithArgs - .withFeatures([]) - .throws(t, { instanceOf: ConfigurationError }); - - // And pass when the FF is enabled. - await targetWithArgs - .withFeatures([Feature.NewRemoteFileAddresses]) - .passes(t.deepEqual, testCase.expected); + await targetWithArgs.passes(t.deepEqual, testCase.expected); } }); test("parseRemoteFileAddress accepts remote address without a ref", async (t) => { const target = callee(parseRemoteFileAddress).withArgs("owner/repo:path"); - // Should only accept the input if the FF is enabled. - await target.withFeatures([]).throws(t); - await target - .withFeatures([Feature.NewRemoteFileAddresses]) - .passes(t.deepEqual, { - owner: "owner", - repo: "repo", - path: "path", - ref: DEFAULT_CONFIG_FILE_REF, - } satisfies RemoteFileAddress); + await target.passes(t.deepEqual, { + owner: "owner", + repo: "repo", + path: "path", + ref: DEFAULT_CONFIG_FILE_REF, + } satisfies RemoteFileAddress); }); test("parseRemoteFileAddress rejects invalid values", async (t) => { @@ -251,18 +219,11 @@ test("parseRemoteFileAddress rejects invalid values", async (t) => { for (const testInput of testInputs) { const targetWithArgs = target.withArgs(testInput); - // Should throw both when the new format is and isn't accepted. - await targetWithArgs.withFeatures([]).throws(t, { + await targetWithArgs.throws(t, { + // When the new format is accepted, there are some more specific + // errors in some cases. It is sufficient for us to check that + // an exception is thrown. instanceOf: ConfigurationError, - message: errors.getConfigFileRepoOldFormatInvalidMessage(testInput), }); - await targetWithArgs - .withFeatures([Feature.NewRemoteFileAddresses]) - .throws(t, { - // When the new format is accepted, there are some more specific - // errors in some cases. It is sufficient for us to check that - // an exception is thrown. - instanceOf: ConfigurationError, - }); } }); diff --git a/src/config/remote-file.ts b/src/config/remote-file.ts index 897b1e910d..236e178207 100644 --- a/src/config/remote-file.ts +++ b/src/config/remote-file.ts @@ -1,7 +1,6 @@ import { ActionState } from "../action-common"; import { ActionsEnvVars, ReadOnlyEnv } from "../environment"; import * as errorMessages from "../error-messages"; -import { Feature } from "../feature-flags"; import { ConfigurationError, Failure, Result, Success } from "../util"; /** Represents remote file addresses. */ @@ -126,16 +125,6 @@ export async function parseRemoteFileAddress( return oldFormatAddressResult.value; } - // If the FF for the new format is not enabled, throw the old format error. - const allowNewFormat = await actionState.features.getValue( - Feature.NewRemoteFileAddresses, - ); - if (!allowNewFormat) { - throw new ConfigurationError( - errorMessages.getConfigFileRepoOldFormatInvalidMessage(configFile), - ); - } - // retrieve the various parts of the config location, and ensure they're present const newFormatAddressResult = parseNewRemoteFileAddress( actionState.env, diff --git a/src/feature-flags.ts b/src/feature-flags.ts index 7a649349c2..0c92ac69af 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -94,8 +94,6 @@ export enum Feature { ForceNightly = "force_nightly", IgnoreGeneratedFiles = "ignore_generated_files", JavaNetworkDebugging = "java_network_debugging", - /** Allow the new remote file address format. */ - NewRemoteFileAddresses = "new_remote_file_addresses", OverlayAnalysis = "overlay_analysis", OverlayAnalysisCodeScanningCpp = "overlay_analysis_code_scanning_cpp", OverlayAnalysisCodeScanningCsharp = "overlay_analysis_code_scanning_csharp", @@ -266,11 +264,6 @@ export const featureConfig = { envVar: "CODEQL_ACTION_JAVA_NETWORK_DEBUGGING", minimumVersion: undefined, }, - [Feature.NewRemoteFileAddresses]: { - defaultValue: false, - envVar: "CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES", - minimumVersion: undefined, - }, [Feature.OverlayAnalysis]: { defaultValue: false, envVar: "CODEQL_ACTION_OVERLAY_ANALYSIS", From dd35309c87af7fda701495577af0aa03c876571e Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 20 Jul 2026 11:23:22 +0100 Subject: [PATCH 52/56] Remove explicit FF from `start-proxy` check --- .github/workflows/__start-proxy.yml | 1 - pr-checks/checks/start-proxy.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/__start-proxy.yml b/.github/workflows/__start-proxy.yml index 9a782865bf..51751680c3 100644 --- a/.github/workflows/__start-proxy.yml +++ b/.github/workflows/__start-proxy.yml @@ -96,7 +96,6 @@ jobs: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} - CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES: 'true' with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} diff --git a/pr-checks/checks/start-proxy.yml b/pr-checks/checks/start-proxy.yml index c4bc02f736..675fc013a2 100644 --- a/pr-checks/checks/start-proxy.yml +++ b/pr-checks/checks/start-proxy.yml @@ -48,7 +48,6 @@ steps: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} CODEQL_PROXY_CA_CERTIFICATE: ${{ steps.proxy.outputs.proxy_ca_certificate }} - CODEQL_ACTION_NEW_REMOTE_FILE_ADDRESSES: "true" with: languages: java tools: ${{ steps.prepare-test.outputs.tools-url }} From b85568788a5dbb01732f9d4e0d0882e5429bb804 Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Mon, 20 Jul 2026 19:54:32 +0100 Subject: [PATCH 53/56] Always make `docker_registry` registries available --- lib/entry-points.js | 5 ++++- src/start-proxy.test.ts | 7 +++++++ src/start-proxy.ts | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/entry-points.js b/lib/entry-points.js index 41af9350b6..665302a123 100644 --- a/lib/entry-points.js +++ b/lib/entry-points.js @@ -162056,7 +162056,10 @@ function isPAT(value) { GITHUB_PAT_FINE_GRAINED_PATTERN ]); } -var ALWAYS_ENABLED_REGISTRY_TYPE = ["git_source"]; +var ALWAYS_ENABLED_REGISTRY_TYPE = [ + "git_source", + "docker_registry" +]; var LANGUAGE_TO_REGISTRY_TYPE = { actions: [], cpp: [], diff --git a/src/start-proxy.test.ts b/src/start-proxy.test.ts index 178f7b4628..ee953798b8 100644 --- a/src/start-proxy.test.ts +++ b/src/start-proxy.test.ts @@ -128,6 +128,12 @@ const gitSourceCredential = { token: "mno", }; +const dockerRegistryCredential = { + type: "docker_registry", + host: "https://registry.example.com", + token: "pqr", +}; + test("getCredentials prefers registriesCredentials over registrySecrets", async (t) => { const registryCredentials = Buffer.from( JSON.stringify([ @@ -633,6 +639,7 @@ test("getCredentials returns only ALWAYS_ENABLED_REGISTRY_TYPE credentials for A const credentialsInput = toEncodedJSON([ ...mixedCredentials, gitSourceCredential, + dockerRegistryCredential, ]); const credentials = startProxyExports.getCredentials( diff --git a/src/start-proxy.ts b/src/start-proxy.ts index 03056ac4cb..74e0b498c4 100644 --- a/src/start-proxy.ts +++ b/src/start-proxy.ts @@ -192,7 +192,10 @@ function isPAT(value: string) { * enabled, because generic CodeQL workflow components may use them rather than just * language-specific components. */ -export const ALWAYS_ENABLED_REGISTRY_TYPE = ["git_source"] as const; +export const ALWAYS_ENABLED_REGISTRY_TYPE = [ + "git_source", + "docker_registry", +] as const; type RegistryMapping = Partial>; From dbdf0b0c7d63778e4b807cb4cef00f090e7dcd4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:39:14 +0000 Subject: [PATCH 54/56] Bump tar from 7.5.16 to 7.5.20 Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.16 to 7.5.20. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.16...v7.5.20) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.20 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cff3d60184..ab54e3d91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8918,9 +8918,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 73aad0eaa9df172668665a150d17b8bc5a650c20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:47:43 +0000 Subject: [PATCH 55/56] Update changelog for v4.37.2 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a24768d4..70c4a7694f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ See the [releases page](https://github.com/github/codeql-action/releases) for the relevant changes to the CodeQL CLI and language packs. -## [UNRELEASED] +## 4.37.2 - 21 Jul 2026 No user facing changes. From e0faed839190caa67a5cd42f1cc16246028ca3df Mon Sep 17 00:00:00 2001 From: "Michael B. Gale" Date: Tue, 21 Jul 2026 15:06:15 +0100 Subject: [PATCH 56/56] Add a couple of change notes --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c4a7694f..3694675753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ See the [releases page](https://github.com/github/codeql-action/releases) for th ## 4.37.2 - 21 Jul 2026 -No user facing changes. +- The new address format for the `config-file` input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the `remote=` prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. [#4023](https://github.com/github/codeql-action/pull/4023) +- The CodeQL Action can now make use of [configured private registries](https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries) in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. [#4007](https://github.com/github/codeql-action/pull/4007) ## 4.37.1 - 16 Jul 2026