Merge pull request #4029 from github/backport-v3.37.2-e0647621c

Merge releases/v4 into releases/v3
This commit is contained in:
Michael B. Gale
2026-07-21 16:06:02 +01:00
committed by GitHub
29 changed files with 1524 additions and 629 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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()

17
.github/workflows/__start-proxy.yml generated vendored
View File

@@ -57,15 +57,11 @@ 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
with:
language: java
registry_secrets: |
[
{
@@ -94,5 +90,16 @@ 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
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 }}
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

View File

@@ -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} \

View File

@@ -2,6 +2,11 @@
See the [releases page](https://github.com/github/codeql-action/releases) for the relevant changes to the CodeQL CLI and language packs.
## 3.37.2 - 21 Jul 2026
- 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)
## 3.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)

106
lib/entry-points.js generated
View File

@@ -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;
@@ -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);
@@ -145327,7 +145331,7 @@ function getDiffRangesJsonFilePath(env = getEnv()) {
return path2.join(getTemporaryDirectory(env), PR_DIFF_RANGE_JSON_FILENAME);
}
function getActionVersion() {
return "3.37.1";
return "3.37.2";
}
function getWorkflowEventName(env = getEnv()) {
return env.getRequired("GITHUB_EVENT_NAME" /* GITHUB_EVENT_NAME */);
@@ -145730,6 +145734,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");
@@ -145757,9 +145764,40 @@ 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 createApiClientWithDetails(apiDetails, { allowExternal = false } = {}) {
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, port, ca } = getRegistryProxyConfig(action);
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,
keepAliveTimeout: 10,
keepAliveMaxTimeout: 10,
requestTls: ca ? { ca } : void 0
});
}
return void 0;
}
function makeProxyRequestOptions(dispatcher) {
return {
fetch: (req, init2) => {
return (0, import_undici.fetch)(req, { ...init2, dispatcher });
}
};
}
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,
@@ -145770,6 +145808,7 @@ function createApiClientWithDetails(apiDetails, { allowExternal = false } = {})
warn: core5.warning,
error: core5.error
},
request: requestOptions,
retry: {
doNotRetry: DO_NOT_RETRY_STATUSES
}
@@ -145786,8 +145825,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}/`)) {
@@ -146756,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",
@@ -146881,6 +146915,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",
@@ -147818,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 <owner>/<repository>/<file-path>@<ref>";
return error3;
}
function getConfigFileRepoFormatInvalidMessage(configFile) {
let error3 = `The configuration file "${configFile}" is not a supported remote file reference.`;
error3 += " Expected format [<owner>/]<repository>[@<ref>][:<file-path>]";
@@ -148335,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
@@ -148394,7 +148420,11 @@ async function getConfigFileInput({
}
async function getRemoteConfig(actionState, configFile, apiDetails) {
const address = await parseRemoteFileAddress(actionState, configFile);
const response = await getApiClientWithExternalAuth(apiDetails).rest.repos.getContent({
const shouldProxyRequest = await actionState.features.getValue(
"proxy_api_requests" /* ProxyApiRequests */
);
const proxy = shouldProxyRequest ? getRegistryProxy(actionState) : void 0;
const response = await getApiClientWithExternalAuth(apiDetails, proxy).rest.repos.getContent({
owner: address.owner,
repo: address.repo,
path: address.path,
@@ -149251,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)) {
@@ -149268,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);
@@ -149739,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);
@@ -162008,7 +162035,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: [],

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codeql",
"version": "3.37.1",
"version": "3.37.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeql",
"version": "3.37.1",
"version": "3.37.2",
"license": "MIT",
"workspaces": [
"pr-checks"
@@ -36,6 +36,7 @@
"long": "^5.3.2",
"node-forge": "^1.4.0",
"semver": "^7.8.5",
"undici": "^6.24.0",
"uuid": "^14.0.1"
},
"devDependencies": {
@@ -8917,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": {
@@ -9305,7 +9306,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"
}

View File

@@ -1,6 +1,6 @@
{
"name": "codeql",
"version": "3.37.1",
"version": "3.37.2",
"private": true,
"description": "CodeQL action",
"scripts": {
@@ -44,7 +44,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",

60
pr-checks/changelog.test.ts Executable file
View File

@@ -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"));
});
});

135
pr-checks/changelog.ts Normal file
View File

@@ -0,0 +1,135 @@
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 `date` formatted as `DD Mon YYYY`. */
export function getReleaseDateString(today: Date = new Date()): string {
return today.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export interface OpenChangelogOptions {
initChangelog?: boolean;
}
export function withChangelog(
transformer: (contents: string) => string,
options: DryRunOption & OpenChangelogOptions,
): void {
let content: string;
if (options.initChangelog && !fs.existsSync(CHANGELOG_FILE)) {
content = EMPTY_CHANGELOG;
} else {
content = fs.readFileSync(CHANGELOG_FILE, "utf8");
}
if (!options.dryRun) {
fs.writeFileSync(CHANGELOG_FILE, transformer(content), "utf8");
} else {
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(
sourceBranchMajorVersion: string,
targetBranchMajorVersion: string,
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 = "";
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.
// `foundContent` tracks whether we hit two headings in a row
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`;
}
}
}
return output;
}

View File

@@ -6,16 +6,14 @@ operatingSystems:
- windows
versions:
- linked
env:
CODEQL_ACTION_PROXY_API_REQUESTS: "true"
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
with:
language: java
registry_secrets: |
[
{
@@ -44,3 +42,13 @@ 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
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 }}
config-file: codeql-action@main:tests/multi-language-repo/.github/codeql/custom-queries.yml

View File

@@ -12,6 +12,12 @@ 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 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");
@@ -30,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;
}

View File

@@ -0,0 +1,840 @@
#!/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 { 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,
withPackageJson,
} from "./versions";
/**
* 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;
/** 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.
*/
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.");
}
/** Options for {@link runCommand}. */
export interface RunCommandOptions extends DryRunOption {
/** 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 exits 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}. */
export interface RunGitOptions extends DryRunOption {
/** 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 = 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
// 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;
}
}
/** 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 !== "";
}
/** 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. 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.).
*
* @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<GitHubCommit[]> {
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));
}
/** 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<AssociatedPullRequest | undefined> {
const prs = await client.paginate(
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.
* 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,
owner: string,
repo: string,
pr: AssociatedPullRequest,
): Promise<string> {
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<void> {
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;
sourceBranch: string;
targetBranch: string;
isPrimaryRelease: boolean;
conductor: string;
}
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" },
"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 {
dryRun: values["dry-run"],
repositoryNwo: values["repository-nwo"],
sourceBranch: values["source-branch"],
targetBranch: values["target-branch"],
isPrimaryRelease: values["is-primary-release"] ?? false,
conductor: values["conductor"],
};
}
/**
* 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"]);
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.");
}
}
/**
* 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<string[]> {
// 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 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",
);
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`);
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,
});
// Migrate the changelog notes from the source major version to the target.
console.log(
`Migrating changelog notes from v${sourceBranchMajorVersion} to v${targetBranchMajorVersion}`,
);
changelog.withChangelog(
(contents) =>
changelog.processChangelogForBackports(
sourceBranchMajorVersion,
targetBranchMajorVersion,
contents,
),
options,
);
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");
changelog.withChangelog(
(contents) => changelog.setVersionAndDate(version, contents),
{ ...options, initChangelog: true },
);
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(", ")}`,
);
}
}
return conflictedFiles;
}
async function main(): Promise<void> {
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.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}'`,
);
}
const targetBranchMajorVersion = options.targetBranch.replace(
RELEASE_BRANCH_PREFIX,
"",
);
const currentVersion = withPackageJson((content) => {
return { value: getCurrentVersion(content) };
}, options);
if (!currentVersion) {
throw new Error("Failed to read current version from package.json");
}
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}.`,
);
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;
}
// 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;
}
// Prepare the update/backport branch.
const conflictedFiles = 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 });
// 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.
if (require.main === module) {
void main();
}

44
pr-checks/versions.test.ts Executable file
View File

@@ -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" });
});
});

54
pr-checks/versions.ts Normal file
View File

@@ -0,0 +1,54 @@
import * as fs from "node:fs";
import { DryRunOption, PACKAGE_JSON } from "./config";
export function withPackageJson<T>(
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(content: string): string | undefined {
const pkg: { version: string } = JSON.parse(content);
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(
prevVersion: string,
newVersion: string,
content: string,
): string {
const lines = content.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",');
}
return output.join("\n");
}

View File

@@ -6,8 +6,8 @@ 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 { getTestEnv, setupTests } from "./testing-utils";
import { ActionsEnvVars, RegistryProxyVars } from "./environment";
import { callee, getTestEnv, setupTests } from "./testing-utils";
import * as util from "./util";
setupTests(test);
@@ -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,47 @@ test.serial(
}
},
);
test("getRegistryProxy - returns undefined if the proxy is not configured", async (t) => {
const target = callee(api.getRegistryProxy).withArgs();
// Empty environment.
await target.passes(t.is, undefined);
// Only the host.
await target
.withEnv(getTestEnv({ [RegistryProxyVars.PROXY_HOST]: "localhost" }))
.passes(t.is, undefined);
// Only the port.
await target
.withEnv(getTestEnv({ [RegistryProxyVars.PROXY_PORT]: "1234" }))
.passes(t.is, undefined);
});
test("getRegistryProxy - returns value when both vars are set", async (t) => {
await callee(api.getRegistryProxy)
.withArgs()
.withEnv(
getTestEnv({
[RegistryProxyVars.PROXY_HOST]: "localhost",
[RegistryProxyVars.PROXY_PORT]: "1234",
}),
)
.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 });
});

View File

@@ -4,9 +4,23 @@ import { type Octokit } from "@octokit/core";
import { type PaginateInterface } from "@octokit/plugin-paginate-rest";
import { type Api } from "@octokit/plugin-rest-endpoint-methods";
import * as retry from "@octokit/plugin-retry";
import { RequestRequestOptions } from "@octokit/types";
import {
ProxyAgent,
RequestInfo,
RequestInit,
fetch as undiciFetch,
} from "undici";
import type { ActionState } from "./action-common";
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 {
@@ -46,16 +60,84 @@ export interface GitHubApiExternalRepoDetails {
apiURL: string | undefined;
}
/**
* Gets the configuration for the private registry authentication proxy,
* 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", "ReadOnlyEnv"]>,
): ProxyAgent | undefined {
const { host, port, ca } = getRegistryProxyConfig(action);
if (host && port) {
const uri = `http://${host}:${port}`;
action.logger.debug(
`Using private registry proxy at '${uri}' for API client.`,
);
return new ProxyAgent({
uri,
keepAliveTimeout: 10,
keepAliveMaxTimeout: 10,
requestTls: ca ? { ca } : 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 });
},
};
}
/** The type of GitHub API client we use. */
export type ApiClient = Octokit & Api & { paginate: PaginateInterface };
/** Options for `createApiClientWithDetails`. */
interface CreateApiClientOptions {
allowExternal?: boolean;
proxy?: ProxyAgent;
}
function createApiClientWithDetails(
apiDetails: GitHubApiCombinedDetails,
{ allowExternal = false } = {},
{ allowExternal = false, proxy = undefined }: CreateApiClientOptions = {},
): ApiClient {
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,
@@ -66,6 +148,7 @@ function createApiClientWithDetails(
warn: core.warning,
error: core.error,
},
request: requestOptions,
retry: {
doNotRetry: DO_NOT_RETRY_STATUSES,
},
@@ -87,8 +170,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 });
}
/**

View File

@@ -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.

View File

@@ -489,11 +489,7 @@ export async function loadUserConfig(
apiDetails: api.GitHubApiCombinedDetails,
tempDir: string,
): Promise<UserConfig> {
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;
}

View File

@@ -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,60 @@ 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);
// 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.
return 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])
.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
.withEnv((env) => {
env.set(RegistryProxyVars.PROXY_HOST, "localhost");
env.set(RegistryProxyVars.PROXY_PORT, "1234");
})
.notLogs(t, "Using private registry proxy at 'http://localhost:1234'")
.throws(t, { message: errorMessage });
// And not when the environment variables aren't set.
await target
.withFeatures([Feature.ProxyApiRequests])
.notLogs(t, "Using private registry proxy at 'http://localhost:1234'")
.throws(t, { message: errorMessage });
});

View File

@@ -82,8 +82,15 @@ export async function getRemoteConfig(
): Promise<UserConfig> {
const address = await parseRemoteFileAddress(actionState, configFile);
const shouldProxyRequest = await actionState.features.getValue(
Feature.ProxyApiRequests,
);
const proxy = shouldProxyRequest
? api.getRegistryProxy(actionState)
: undefined;
const response = await api
.getApiClientWithExternalAuth(apiDetails)
.getApiClientWithExternalAuth(apiDetails, proxy)
.rest.repos.getContent({
owner: address.owner,
repo: address.repo,

View File

@@ -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,
});
}
});

View File

@@ -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,

View File

@@ -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.
@@ -255,6 +265,11 @@ export function getOptionalEnvVar(paramName: string): string | undefined {
export class ReadOnlyEnv<T extends string | undefined = string | undefined> {
constructor(protected readonly vars: Record<string, T>) {}
/** 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);

View File

@@ -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",
@@ -138,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",
@@ -264,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",
@@ -389,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",

View File

@@ -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(

View File

@@ -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<Record<BuiltInLanguage, string[]>>;

View File

@@ -178,8 +178,7 @@ export function makeMacro<Args extends unknown[]>(
return wrapper;
}
export function getTestEnv(): Env {
const testEnv: NodeJS.ProcessEnv = {};
export function getTestEnv(testEnv: NodeJS.ProcessEnv = {}): Env {
return getEnv(testEnv);
}
@@ -250,7 +249,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<AllState>)