mirror of
https://github.com/github/codeql-action.git
synced 2026-10-03 17:41:28 +00:00
Merge pull request #4029 from github/backport-v3.37.2-e0647621c
Merge releases/v4 into releases/v3
This commit is contained in:
1
.github/actions/release-branches/action.yml
vendored
1
.github/actions/release-branches/action.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
474
.github/update-release-branch.py
vendored
474
.github/update-release-branch.py
vendored
@@ -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
17
.github/workflows/__start-proxy.yml
generated
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/update-release-branch.yml
vendored
4
.github/workflows/update-release-branch.yml
vendored
@@ -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} \
|
||||
|
||||
@@ -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
106
lib/entry-points.js
generated
@@ -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
12
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
60
pr-checks/changelog.test.ts
Executable 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
135
pr-checks/changelog.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
840
pr-checks/update-release-branch.ts
Executable file
840
pr-checks/update-release-branch.ts
Executable 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
44
pr-checks/versions.test.ts
Executable 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
54
pr-checks/versions.ts
Normal 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");
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[]>>;
|
||||
|
||||
|
||||
@@ -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>)
|
||||
|
||||
Reference in New Issue
Block a user