Compare commits

..

26 Commits

Author SHA1 Message Date
Michael B. Gale
18420e3271 Merge pull request #4043 from github/mbg/ts/changelog
Convert last remaining Python CI scripts to TypeScript
2026-07-28 15:27:26 +00:00
Michael B. Gale
7e8d8970f0 Merge pull request #4046 from github/mbg/repo-prop/code-quality
Ignore configuration file repo property for unsupported analysis kinds
2026-07-28 11:16:26 +00:00
Michael B. Gale
2d4c474c2c Log !analysisKindSupported case 2026-07-28 12:02:41 +01:00
Michael B. Gale
98c05a17d3 Fix argument validation in rollback-changelog.ts
Co-authored-by: Mads Navntoft <navntoft@github.com>
2026-07-28 11:51:13 +01:00
Michael B. Gale
8289a49271 Ignore repository property for unsupported analysis kinds 2026-07-27 13:23:39 +01:00
Michael B. Gale
2a8731cc06 Move config-file computation after determining the analysisKinds 2026-07-27 13:10:34 +01:00
Michael B. Gale
3434fbbc53 Merge pull request #4044 from github/mbg/ff/promote-toolcache
Promote `AllowToolcacheInput` feature
2026-07-24 16:29:59 +00:00
Michael B. Gale
3013ac07bd Promote AllowToolcacheInput feature 2026-07-24 16:28:21 +01:00
Michael B. Gale
74b15aa2c6 Install JS deps if needed in post-release-mergeback workflow 2026-07-24 16:19:45 +01:00
Michael B. Gale
f00f809405 Fix checking keys rather than values 2026-07-24 16:17:04 +01:00
Michael B. Gale
0953dc00da Add getErrorMessage to pr-checks-local util.ts to avoid pulling in src/util.ts dependencies 2026-07-24 16:08:18 +01:00
Michael B. Gale
cbad145443 Remove Python-specific steps from workflows that no longer need them 2026-07-24 16:05:55 +01:00
Michael B. Gale
ab44eb939d Remove python from CodeQL workflow
There is no more (non-test) Python code left to analyse, so CodeQL analysis would fail now
2026-07-24 16:05:10 +01:00
Michael B. Gale
961b583f9a Use rollback-changelog.ts and remove Python version 2026-07-24 16:05:10 +01:00
Michael B. Gale
d71461774b Add rollback-changelog.ts with tests 2026-07-24 16:05:09 +01:00
Michael B. Gale
027ac05d3b Use bundle-changelog.ts and remove Python version 2026-07-24 16:05:09 +01:00
Michael B. Gale
66a6f42f0a Add bundle-changelog.ts with tests 2026-07-24 16:05:09 +01:00
Michael B. Gale
c5d621238d Update extractChangelogSnippet to use parseChangelog 2026-07-24 16:05:09 +01:00
Michael B. Gale
adba0868a4 Update processChangelogForBackports to use parseChangelog 2026-07-24 16:05:09 +01:00
Michael B. Gale
916098aa8d Add parseChangelog and renderChangelog 2026-07-24 16:05:08 +01:00
Michael B. Gale
093dce6cc2 Add extractChangelogSnippet test for the case where there is no first section 2026-07-24 16:05:08 +01:00
Michael B. Gale
57eb44123f Add constant for unreleased placeholder 2026-07-24 16:05:08 +01:00
Michael B. Gale
5901394530 Remove prepare_changelog.py 2026-07-24 16:05:08 +01:00
Michael B. Gale
b69467ce8b Update workflows to use prepare-changelog.ts 2026-07-24 16:05:08 +01:00
Michael B. Gale
85d157095f Add prepare-changelog.ts with tests 2026-07-24 16:05:08 +01:00
Michael B. Gale
2d14f71964 Add NO_CHANGES_STR constant 2026-07-24 16:05:07 +01:00
30 changed files with 911 additions and 485 deletions

View File

@@ -25,17 +25,6 @@ runs:
shell: bash
run: npm ci
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install PyGithub==2.3.0 requests
shell: bash
- name: Update git config
run: |
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

View File

@@ -113,7 +113,6 @@ jobs:
matrix:
include:
- language: actions
- language: python
permissions:
contents: read

View File

@@ -51,9 +51,9 @@ jobs:
with:
node-version: 24
cache: 'npm'
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install JavaScript dependencies
run: npm ci
- name: Update git config
run: |
@@ -127,7 +127,7 @@ jobs:
env:
PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md"
run: |
python .github/workflows/script/prepare_changelog.py CHANGELOG.md > $PARTIAL_CHANGELOG
npx tsx pr-checks/prepare-changelog.ts --output="$PARTIAL_CHANGELOG"
echo "::group::Partial CHANGELOG"
cat $PARTIAL_CHANGELOG

View File

@@ -93,7 +93,7 @@ jobs:
LATEST_TAG: ${{ needs.prepare.outputs.latest_tag }}
VERSION: "${{ needs.prepare.outputs.version }}"
run: |
python .github/workflows/script/rollback_changelog.py \
npx tsx pr-checks/rollback-changelog.ts \
--target-version "${ROLLBACK_TAG:1}" \
--rollback-version "${LATEST_TAG:1}" \
--new-version "$VERSION" > $NEW_CHANGELOG
@@ -128,7 +128,9 @@ jobs:
NEW_CHANGELOG: "${{ runner.temp }}/new_changelog.md"
PARTIAL_CHANGELOG: "${{ runner.temp }}/partial_changelog.md"
run: |
python .github/workflows/script/prepare_changelog.py $NEW_CHANGELOG > $PARTIAL_CHANGELOG
npx tsx pr-checks/prepare-changelog.ts \
--changelog="$NEW_CHANGELOG" \
--output="$PARTIAL_CHANGELOG"
echo "::group::Partial CHANGELOG"
cat $PARTIAL_CHANGELOG

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env python3
import os
import re
cli_version = os.environ['CLI_VERSION']
# The GitHub Release for the new bundle version.
bundle_release_url = f"https://github.com/github/codeql-action/releases/tag/codeql-bundle-v{cli_version}"
# Get the PR number from the PR URL.
pr_number = os.environ['PR_URL'].split('/')[-1]
changelog_note = f"- Update default CodeQL bundle version to [{cli_version}]({bundle_release_url}). [#{pr_number}]({os.environ['PR_URL']})"
# If the "[UNRELEASED]" section starts with "no user facing changes", remove that line.
with open('CHANGELOG.md', 'r') as f:
changelog = f.read()
changelog = changelog.replace('## [UNRELEASED]\n\nNo user facing changes.', '## [UNRELEASED]\n')
# Add the changelog note to the bottom of the "[UNRELEASED]" section.
changelog = re.sub(r'\n## (\d+\.\d+\.\d+)', f'{changelog_note}\n\n## \\1', changelog, count=1)
with open('CHANGELOG.md', 'w') as f:
f.write(changelog)

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import os
import sys
EMPTY_CHANGELOG = 'No changes.\n\n'
# Prepare the changelog for the new release
# This function will extract the part of the changelog that
# we want to include in the new release.
def extract_changelog_snippet(changelog_file):
output = ''
if (not os.path.exists(changelog_file)):
output = EMPTY_CHANGELOG
else:
with open(changelog_file, 'r') as f:
lines = f.readlines()
# Include only the contents of the first section
found_first_section = False
for line in lines:
if line.startswith('## '):
if found_first_section:
break
found_first_section = True
elif found_first_section:
output += line
return output.strip()
if len(sys.argv) < 2:
raise Exception('Expecting argument: changelog_file')
changelog_file = sys.argv[1]
print(extract_changelog_snippet(changelog_file))

View File

@@ -1,62 +0,0 @@
import datetime
import os
import argparse
EMPTY_CHANGELOG = """# CodeQL Action Changelog
"""
def get_today_string():
today = datetime.datetime.today()
return '{:%d %b %Y}'.format(today)
# Include everything up to and after the first heading,
# but not the first heading and body.
def drop_unreleased_section(lines: list[str]):
before_first_section = ''
after_first_section = ''
found_first_section = False
skipped_first_section = False
for i, line in enumerate(lines):
if line.startswith('## ') and not found_first_section:
found_first_section = True
elif line.startswith('## ') and found_first_section:
skipped_first_section = True
if not found_first_section:
before_first_section += line
if skipped_first_section:
after_first_section += line
return (before_first_section, after_first_section)
def update_changelog(target_version, rollback_version, new_version):
before_first_section = EMPTY_CHANGELOG
after_first_section = ''
if (os.path.exists('CHANGELOG.md')):
with open('CHANGELOG.md', 'r') as f:
(before_first_section, after_first_section) = drop_unreleased_section(f.readlines())
newHeader = f'## {new_version} - {get_today_string()}\n'
print(before_first_section, end="")
print(newHeader)
print(f"This release rolls back {rollback_version} due to issues with that release. It is identical to {target_version}.\n")
print(after_first_section)
# We expect three version strings as input:
#
# - target_version: the version that we are re-releasing as `new_version`
# - rollback_version: the version that we are rolling back, typically the one that followed `target_version`
# - new_version: the new version that we are releasing `target_version` as, typically the one that follows `rollback_version`
#
# Example: python3 .github/workflows/script/rollback_changelog.py --target-version "1.2.3" --rollback-version "1.2.4" --new-version "1.2.5"
parser = argparse.ArgumentParser(description="Update CHANGELOG.md for a rollback release.")
parser.add_argument("--target-version", "-t", required=True, help="Version to re-release as new_version.")
parser.add_argument("--rollback-version", "-r", required=True, help="Version being rolled back.")
parser.add_argument("--new-version", "-n", required=True, help="New version to publish for target_version.")
args = parser.parse_args()
update_changelog(args.target_version, args.rollback_version, args.new_version)

View File

@@ -40,11 +40,6 @@ jobs:
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -120,7 +115,7 @@ jobs:
- name: Create changelog note
run: |
python .github/workflows/script/bundle_changelog.py
npx tsx pr-checks/bundle-changelog.ts
- name: Push changelog note
run: |

View File

@@ -22,11 +22,6 @@ jobs:
pull-requests: write # needed to create pull request
steps:
- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Checkout CodeQL Action
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

181
lib/entry-points.js generated
View File

@@ -146550,7 +146550,7 @@ async function sendStatusReport(statusReport) {
);
}
}
async function createInitWithConfigStatusReport(config, initStatusReport, configFileInput, totalCacheSize, overlayBaseDatabaseStats, dependencyCachingResults) {
async function createInitWithConfigStatusReport(config, initStatusReport, configFile, totalCacheSize, overlayBaseDatabaseStats, dependencyCachingResults) {
const languages = config.languages.join(",");
const paths = (config.originalUserInput.paths || []).join(",");
const pathsIgnore = (config.originalUserInput["paths-ignore"] || []).join(
@@ -146576,7 +146576,7 @@ async function createInitWithConfigStatusReport(config, initStatusReport, config
}
return {
...initStatusReport,
config_file: configFileInput?.value ?? "",
config_file: configFile ?? "",
disable_default_queries: disableDefaultQueries,
paths,
paths_ignore: pathsIgnore,
@@ -146794,11 +146794,6 @@ var featureConfig = {
envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS",
minimumVersion: void 0
},
["allow_toolcache_input" /* AllowToolcacheInput */]: {
defaultValue: false,
envVar: "CODEQL_ACTION_ALLOW_TOOLCACHE_INPUT",
minimumVersion: void 0
},
["cleanup_trap_caches" /* CleanupTrapCaches */]: {
defaultValue: false,
envVar: "CODEQL_ACTION_CLEANUP_TRAP_CACHES",
@@ -147996,17 +147991,13 @@ var stringProperty = {
validate: isString2,
parse: parseStringRepositoryProperty
};
var nonEmptyStringProperty = {
...stringProperty,
parse: parseNonEmptyStringRepositoryProperty
};
var booleanProperty = {
// The value from the API should come as a string, which we then parse into a boolean.
validate: isString2,
parse: parseBooleanRepositoryProperty
};
var repositoryPropertyParsers = {
["github-codeql-config-file" /* CONFIG_FILE */]: nonEmptyStringProperty,
["github-codeql-config-file" /* CONFIG_FILE */]: stringProperty,
["github-codeql-disable-overlay" /* DISABLE_OVERLAY */]: booleanProperty,
["github-codeql-extra-queries" /* EXTRA_QUERIES */]: stringProperty,
["github-codeql-file-coverage-on-prs" /* FILE_COVERAGE_ON_PRS */]: booleanProperty,
@@ -148084,12 +148075,6 @@ function parseBooleanRepositoryProperty(name, value, logger) {
function parseStringRepositoryProperty(_name, value) {
return value;
}
function parseNonEmptyStringRepositoryProperty(_name, value) {
if (value.trim().length === 0) {
return void 0;
}
return value;
}
var KNOWN_REPOSITORY_PROPERTY_NAMES = new Set(
Object.values(RepositoryPropertyName)
);
@@ -148433,50 +148418,6 @@ function parseUserConfig(logger, pathInput, contents, validateConfig) {
}
}
// src/config/inputs.ts
async function getComputedInput(action, repositoryProperties, name, options) {
const input = action.actions.getOptionalInput(name);
const propertyValue = repositoryProperties[options.repositoryPropertyName];
if (options.repositoryPropertyFeatureEnabled && options.allowForcedRepositoryPropertyValue && propertyValue?.startsWith("!")) {
action.logger.info(
`Using ${name} input from repository property (enforced): ${propertyValue}`
);
return {
// Drop the '!' from the value.
value: propertyValue.substring(1),
source: "repository-property" /* RepositoryProperty */
};
}
if (input !== void 0) {
action.logger.info(`Using ${name} input from workflow: ${input}`);
return { value: input, source: "workflow" /* Workflow */ };
}
if (options.repositoryPropertyFeatureEnabled && propertyValue !== void 0) {
action.logger.info(
`Using ${name} input from repository property: ${propertyValue}`
);
return {
value: propertyValue,
source: "repository-property" /* RepositoryProperty */
};
} else if (propertyValue !== void 0) {
action.logger.info(
`Ignoring ${name} input from repository property, because the corresponding feature flag is disabled.`
);
}
return void 0;
}
async function getToolsInput(action, repositoryProperties) {
const allowRepositoryProperty = await action.features.getValue(
"tools_repository_property" /* ToolsRepositoryProperty */
);
return getComputedInput(action, repositoryProperties, "tools" /* Tools */, {
repositoryPropertyFeatureEnabled: allowRepositoryProperty,
allowForcedRepositoryPropertyValue: true,
repositoryPropertyName: "github-codeql-tools" /* TOOLS */
});
}
// src/config/remote-file.ts
var DEFAULT_CONFIG_FILE_NAME = ".github/codeql-action.yaml";
var DEFAULT_CONFIG_FILE_REF = "main";
@@ -148550,15 +148491,38 @@ async function parseRemoteFileAddress(actionState, configFile) {
// src/config/file.ts
var LOCAL_PATH_PREFIX = "./";
var REMOTE_PATH_PREFIX = "remote=";
async function getConfigFileInput(action, repositoryProperties) {
const useRepositoryProperty = await action.features.getValue(
"config_file_repository_property" /* ConfigFileRepositoryProperty */
);
return getComputedInput(action, repositoryProperties, "config-file" /* ConfigFile */, {
repositoryPropertyFeatureEnabled: useRepositoryProperty,
allowForcedRepositoryPropertyValue: false,
repositoryPropertyName: "github-codeql-config-file" /* CONFIG_FILE */
});
async function getConfigFileInput({
logger,
actions,
features
}, repositoryProperties, analysisKinds) {
const input = actions.getOptionalInput("config-file");
if (input !== void 0) {
logger.info(`Using configuration file input from workflow: ${input}`);
return input;
}
const propertyValue = repositoryProperties["github-codeql-config-file" /* CONFIG_FILE */];
const analysisKindSupported = analysisKinds === void 0 || analysisKinds.includes("code-scanning" /* CodeScanning */) && analysisKinds.length === 1;
if (propertyValue !== void 0 && propertyValue.trim().length > 0) {
const useRepositoryProperty = await features.getValue(
"config_file_repository_property" /* ConfigFileRepositoryProperty */
);
if (analysisKindSupported && useRepositoryProperty) {
logger.info(
`Using configuration file input from repository property: ${propertyValue}`
);
return propertyValue;
} else if (!analysisKindSupported) {
logger.info(
"Ignoring configuration file input from repository property, because it is unsupported for the current analysis kind."
);
} else {
logger.info(
"Ignoring configuration file input from repository property, because the corresponding feature flag is disabled."
);
}
}
return void 0;
}
async function getRemoteConfig(actionState, configFile, apiDetails) {
const address = await parseRemoteFileAddress(actionState, configFile);
@@ -151016,10 +150980,7 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO
}
} else if (toolsInput !== void 0 && toolsInput === CODEQL_TOOLCACHE_INPUT) {
let latestToolcacheVersion;
const allowToolcacheValueFF = await features.getValue(
"allow_toolcache_input" /* AllowToolcacheInput */
);
const allowToolcacheValue = allowToolcacheValueFF && (isDynamicWorkflow() || isInTestMode());
const allowToolcacheValue = isDynamicWorkflow() || isInTestMode();
if (allowToolcacheValue) {
logger.info(
`Attempting to use the latest CodeQL CLI version in the toolcache, as requested by 'tools: ${toolsInput}'.`
@@ -151035,15 +150996,9 @@ async function getCodeQLSource(toolsInput, defaultCliVersion, rawLanguages, useO
`Found no CodeQL CLI in the toolcache, ignoring 'tools: ${toolsInput}'...`
);
} else {
if (allowToolcacheValueFF) {
logger.warning(
`Ignoring 'tools: ${toolsInput}' because the workflow was not triggered dynamically.`
);
} else {
logger.info(
`Ignoring 'tools: ${toolsInput}' because the feature is not enabled.`
);
}
logger.warning(
`Ignoring 'tools: ${toolsInput}' because the workflow was not triggered dynamically.`
);
}
const version = await resolveDefaultCliVersion(
defaultCliVersion,
@@ -160383,6 +160338,40 @@ var core21 = __toESM(require_core());
var io7 = __toESM(require_io());
var semver10 = __toESM(require_semver2());
// src/config/inputs.ts
async function getToolsInput(action, repositoryProperties) {
const name = "tools" /* Tools */;
const input = action.actions.getOptionalInput(name);
const propertyValue = repositoryProperties["github-codeql-tools" /* TOOLS */];
const allowRepositoryProperty = await action.features.getValue(
"tools_repository_property" /* ToolsRepositoryProperty */
);
if (allowRepositoryProperty && propertyValue?.startsWith("!")) {
action.logger.info(
`Using ${name} input from repository property (enforced): ${propertyValue}`
);
return {
// Drop the '!' from the value.
value: propertyValue.substring(1),
source: "repository-property" /* RepositoryProperty */
};
}
if (input !== void 0) {
action.logger.info(`Using ${name} input from workflow: ${input}`);
return { value: input, source: "workflow" /* Workflow */ };
}
if (allowRepositoryProperty && propertyValue !== void 0) {
action.logger.info(
`Using ${name} input from repository property: ${propertyValue}`
);
return {
value: propertyValue,
source: "repository-property" /* RepositoryProperty */
};
}
return void 0;
}
// src/workflow.ts
var fs27 = __toESM(require("fs"));
var path23 = __toESM(require("path"));
@@ -160673,7 +160662,7 @@ async function sendStartingStatusReport(startedAt, config, logger) {
await sendStatusReport(statusReportBase);
}
}
async function sendCompletedStatusReport2(startedAt, config, configFileInput, toolsInput, toolsDownloadStatusReport, toolsFeatureFlagsValid, toolsSource, toolsVersion, overlayBaseDatabaseStats, dependencyCachingResults, logger, error3) {
async function sendCompletedStatusReport2(startedAt, config, configFile, toolsInput, toolsDownloadStatusReport, toolsFeatureFlagsValid, toolsSource, toolsVersion, overlayBaseDatabaseStats, dependencyCachingResults, logger, error3) {
const statusReportBase = await createStatusReportBase(
"init" /* Init */,
getActionsStatus(error3),
@@ -160698,9 +160687,6 @@ async function sendCompletedStatusReport2(startedAt, config, configFileInput, to
if (toolsInput !== void 0) {
initStatusReport.computed_inputs.tools = toolsInput;
}
if (configFileInput !== void 0) {
initStatusReport.computed_inputs["config-file"] = configFileInput;
}
const initToolsDownloadFields = {};
if (toolsDownloadStatusReport?.downloadDurationMs !== void 0) {
initToolsDownloadFields.tools_download_duration_ms = toolsDownloadStatusReport.downloadDurationMs;
@@ -160712,7 +160698,7 @@ async function sendCompletedStatusReport2(startedAt, config, configFileInput, to
const initWithConfigStatusReport = await createInitWithConfigStatusReport(
config,
initStatusReport,
configFileInput,
configFile,
Math.round(
await getTotalCacheSize(Object.values(config.trapCaches), logger)
),
@@ -160732,7 +160718,7 @@ async function run3(actionState) {
const logger = actionState.logger;
let apiDetails;
let config;
let configFileInput;
let configFile;
let codeql;
let features;
let sourceRoot;
@@ -160769,11 +160755,6 @@ async function run3(actionState) {
logger.info(`Job run UUID is ${jobRunUuid}.`);
core21.exportVariable("JOB_RUN_UUID" /* JOB_RUN_UUID */, jobRunUuid);
core21.exportVariable("CODEQL_ACTION_INIT_HAS_RUN" /* INIT_ACTION_HAS_RUN */, "true");
const actionStateWithFeatures = { ...actionState, features };
configFileInput = await getConfigFileInput(
actionStateWithFeatures,
repositoryProperties
);
sourceRoot = path24.resolve(
getRequiredEnvParam("GITHUB_WORKSPACE"),
getOptionalInput("source-root") || ""
@@ -160786,6 +160767,12 @@ async function run3(actionState) {
`Failed to parse analysis kinds for 'starting' status report: ${getErrorMessage(err)}`
);
}
const actionStateWithFeatures = { ...actionState, features };
configFile = await getConfigFileInput(
actionStateWithFeatures,
repositoryProperties,
analysisKinds
);
await sendStartingStatusReport(startedAt, { analysisKinds }, logger);
if (process.env["CODEQL_ACTION_SETUP_CODEQL_HAS_RUN" /* SETUP_CODEQL_ACTION_HAS_RUN */] === "true") {
throw new ConfigurationError(
@@ -160851,7 +160838,7 @@ async function run3(actionState) {
packsInput: getOptionalInput("packs"),
buildModeInput: getOptionalInput("build-mode"),
ramInput: getOptionalInput("ram"),
configFile: configFileInput?.value,
configFile,
dbLocation: getOptionalInput("db-location"),
configInput: getOptionalInput("config"),
dependencyCachingEnabled: getDependencyCachingEnabled(),
@@ -161138,7 +161125,7 @@ exec ${goBinaryPath} "$@"`
await sendCompletedStatusReport2(
startedAt,
config,
configFileInput,
configFile,
toolsInput,
toolsDownloadStatusReport,
toolsFeatureFlagsValid,

View File

@@ -0,0 +1,142 @@
/**
* Tests for `bundle-changelog.ts`.
*/
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
CLI_VERSION_ENV_VAR,
getCLIVersion,
getPRNumber,
getPRUrl,
PR_URL_ENV_VAR,
updateChangelog,
} from "./bundle-changelog";
import {
EMPTY_CHANGELOG,
NO_CHANGES_STR,
UNRELEASED_PLACEHOLDER,
} from "./changelog";
let testDir: string;
beforeEach(() => {
// Set up a temporary directory for testing
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-changelog-test-"));
});
afterEach(() => {
/** Clean up temporary directories. */
fs.rmSync(testDir, { recursive: true, force: true });
});
describe("getCLIVersion", async () => {
await it("throws if the environment variable is not set", async () => {
delete process.env[CLI_VERSION_ENV_VAR];
assert.throws(() => getCLIVersion());
});
await it("throws if the environment variable is empty", async () => {
process.env[CLI_VERSION_ENV_VAR] = " ";
assert.throws(() => getCLIVersion());
});
await it("returns value of the environment variable if set", async () => {
const testValue = "1.2.3";
process.env[CLI_VERSION_ENV_VAR] = testValue;
assert.deepEqual(getCLIVersion(), testValue);
});
});
const testPrUrl = "https://github.com/github/codeql-action/pulls/42";
describe("getPRUrl", async () => {
await it("throws if the environment variable is not set", async () => {
delete process.env[PR_URL_ENV_VAR];
assert.throws(() => getPRUrl());
});
await it("throws if the environment variable is empty", async () => {
process.env[PR_URL_ENV_VAR] = " ";
assert.throws(() => getPRUrl());
});
await it("returns value of the environment variable if set", async () => {
process.env[PR_URL_ENV_VAR] = testPrUrl;
assert.deepEqual(getPRUrl(), testPrUrl);
});
});
describe("getPRNumber", async () => {
await it("throws if the last part of the input is not a number", async () => {
assert.throws(() => getPRNumber(`${testPrUrl}/foo`));
});
await it("throws if the last part of the input is not a positive number", async () => {
assert.throws(() => getPRNumber(`${testPrUrl}/-100`));
});
await it("returns the PR number from an URL", async () => {
assert.equal(getPRNumber(testPrUrl), 42);
});
});
const testChangelog = `${EMPTY_CHANGELOG.trimEnd()}
## 4.23.7
- Other change
## 4.23.6
${NO_CHANGES_STR}`;
const expectedChangelog = `# CodeQL Action Changelog
## ${UNRELEASED_PLACEHOLDER}
- Update default CodeQL bundle version to
## 4.23.7
- Other change
## 4.23.6
${NO_CHANGES_STR}`;
describe("updateChangelog", async () => {
await it("removes `NO_CHANGES_STR` if present in [UNRELEASED] section", async () => {
const result = updateChangelog(EMPTY_CHANGELOG, "");
assert.ok(!result.includes(NO_CHANGES_STR.trim()));
});
await it("doesn't remove `NO_CHANGES_STR` if present in versioned section", async () => {
const result = updateChangelog(
EMPTY_CHANGELOG.replace(UNRELEASED_PLACEHOLDER, "1.2.3"),
"",
);
assert.ok(result.includes(NO_CHANGES_STR.trim()));
});
await it("throws if there are no sections", async () => {
assert.throws(() => {
updateChangelog(
"# CodeQL Action Changelog",
"- Update default CodeQL bundle version to",
);
});
});
await it("adds note at the end of the first section", async () => {
const result = updateChangelog(
testChangelog,
"- Update default CodeQL bundle version to",
);
assert.deepEqual(result, expectedChangelog);
});
});

127
pr-checks/bundle-changelog.ts Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env npx tsx
/**
* Updates the changelog with a change note for an updated CodeQL CLI bundle.
*/
import * as fs from "node:fs";
import {
parseChangelog,
renderChangelog,
UNRELEASED_PLACEHOLDER,
} from "./changelog";
import { CHANGELOG_FILE, CLI_BUNDLE_RELEASE_URL_PREFIX } from "./config";
import { getErrorMessage } from "./util";
export const CLI_VERSION_ENV_VAR = "CLI_VERSION";
export const PR_URL_ENV_VAR = "PR_URL";
/** Gets the CLI version from the environment. */
export function getCLIVersion() {
const cliVersion = process.env[CLI_VERSION_ENV_VAR];
if (cliVersion === undefined || cliVersion.trim() === "") {
throw new Error(`No CLI version was set in '${CLI_VERSION_ENV_VAR}'.`);
}
return cliVersion;
}
/** Gets the PR URL from the environment. */
export function getPRUrl() {
const prUrl = process.env[PR_URL_ENV_VAR];
if (prUrl === undefined || prUrl.trim() === "") {
throw new Error(`No PR URL was set in '${PR_URL_ENV_VAR}'.`);
}
return prUrl;
}
/**
* Gets the PR number from something like a PR URL.
*/
export function getPRNumber(prUrl: string) {
const prUrlParts = prUrl.split("/");
const prNumberStr = prUrlParts[prUrlParts.length - 1];
const prNumber = Number.parseInt(prNumberStr, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error(
`Invalid PR URL '${prUrl}': last part is not a positive number`,
);
}
return prNumber;
}
/**
* Updates `changelog` by adding `changelogNote` to the first section.
*
* @param contents The existing changelog contents.
* @param changelogNote The note to add to the first section.
*/
export function updateChangelog(contents: string, changelogNote: string) {
// If the "[UNRELEASED]" section starts with "no user facing changes", remove that line.
contents = contents.replace(
`## ${UNRELEASED_PLACEHOLDER}\n\nNo user facing changes.`,
`## ${UNRELEASED_PLACEHOLDER}\n`,
);
const changelog = parseChangelog(contents);
if (changelog.sections.length === 0) {
throw new Error("The changelog contains no existing sections.");
}
// Add the changelog note to the bottom of the first section.
const firstSection = changelog.sections[0];
const lastLine = firstSection.bodyLines.pop();
if (lastLine !== undefined && lastLine.trim() !== "") {
// We expect the last line to be empty. If it isn't for some reason,
// add it back.
firstSection.bodyLines.push(lastLine);
}
firstSection.bodyLines.push(changelogNote);
// If the last line is empty as expected, then add it back in after the new note.
if (lastLine?.trim() === "") {
firstSection.bodyLines.push(lastLine);
}
return renderChangelog(changelog);
}
function main() {
try {
const cliVersion = getCLIVersion();
const prUrl = getPRUrl();
// The GitHub Release for the new bundle version.
const bundleReleaseUrl = `${CLI_BUNDLE_RELEASE_URL_PREFIX}${cliVersion}`;
// Get the PR number from the PR URL.
const prNumber = getPRNumber(prUrl);
const changelogNote = `- Update default CodeQL bundle version to [${cliVersion}](${bundleReleaseUrl}). [#${prNumber}](${prUrl})`;
let changelog = fs.readFileSync(CHANGELOG_FILE, "utf-8");
changelog = updateChangelog(changelog, changelogNote);
fs.writeFileSync(CHANGELOG_FILE, changelog);
return 0;
} catch (err) {
console.error(`Failed to bundle changelog: ${getErrorMessage(err)}`);
return -1;
}
}
// Only call `main` if this script was run directly.
if (require.main === module) {
process.exit(main());
}

View File

@@ -5,14 +5,18 @@
*/
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import { describe, it } from "node:test";
import {
EMPTY_CHANGELOG,
getReleaseDateString,
parseChangelog,
processChangelogForBackports,
renderChangelog,
setVersionAndDate,
} from "./changelog";
import { CHANGELOG_FILE } from "./config";
const testDate = new Date(2026, 7, 14);
@@ -37,6 +41,14 @@ describe("setVersionAndDate", async () => {
});
});
describe("parseChangelog + renderChangelog", async () => {
await it("renderChangelog(parseChangelog(c)) == c", async () => {
const actualChangelog = fs.readFileSync(CHANGELOG_FILE, "utf-8");
const roundtrip = renderChangelog(parseChangelog(actualChangelog));
assert.deepEqual(roundtrip.split("\n"), actualChangelog.split("\n"));
});
});
const testChangelog = `# CodeQL Action Changelog
## 4.12.3 - 14 Aug 2026

View File

@@ -2,14 +2,34 @@ import * as fs from "node:fs";
import { CHANGELOG_FILE, DryRunOption } from "./config";
/** The placeholder in the header for unreleased changes. */
export const UNRELEASED_PLACEHOLDER = "[UNRELEASED]";
/** The default contents for a section in the changelog. */
export const NO_CHANGES_STR = "No user facing changes.\n\n";
/** Placeholder changelog content for a new release. */
export const EMPTY_CHANGELOG = `# CodeQL Action Changelog
## [UNRELEASED]
## ${UNRELEASED_PLACEHOLDER}
No user facing changes.
${NO_CHANGES_STR}`;
`;
/**
* Represents sections in a changelog.
*/
export interface ChangelogSection {
headerLine: string;
bodyLines: string[];
}
/**
* Represents a changelog.
*/
export interface Changelog {
preamble: string[];
sections: ChangelogSection[];
}
/** Returns `date` formatted as `DD Mon YYYY`. */
export function getReleaseDateString(today: Date = new Date()): string {
@@ -53,7 +73,76 @@ export function setVersionAndDate(
date: Date = new Date(),
): string {
const versionAndDate = `${version} - ${getReleaseDateString(date)}`;
return content.replace("[UNRELEASED]", versionAndDate);
return content.replace(UNRELEASED_PLACEHOLDER, versionAndDate);
}
/**
* Parses `content` into a structured representation of a changelog.
*
* @param content The contents of the changelog file.
*/
export function parseChangelog(content: string): Changelog {
const lines = content.split("\n");
let i = 0;
const preamble: string[] = [];
const sections: ChangelogSection[] = [];
let currentSection: ChangelogSection | undefined = undefined;
// Process all lines of the input file.
while (i < lines.length) {
const line = lines[i];
// Sections of the changelog start with `## `.
if (line.startsWith("## ")) {
// We have discovered a new section. If `currentSection` is already defined,
// then this marks the end of that section. Push it to the array of sections
// in the changelog.
if (currentSection !== undefined) {
sections.push(currentSection);
}
// Initialise the new section.
currentSection = { headerLine: line, bodyLines: [] };
} else if (currentSection !== undefined) {
// Add lines between the section header and the next to the current section.
currentSection.bodyLines.push(line);
} else {
// This is neither a section header nor are we in a section already,
// so this line is part of the preamble.
preamble.push(line);
}
i++;
}
// Push the current section to the array of completed sections, if there is
// still one unfinished.
if (currentSection !== undefined) {
sections.push(currentSection);
}
return { preamble, sections };
}
/**
* Combines an array of lines into a single string by adding line breaks.
*/
export function unlines(lines: string[]): string {
return `${lines.join("\n")}`;
}
/**
* Renders a given changelog to a string.
*/
export function renderChangelog(changelog: Changelog): string {
let result = unlines(changelog.preamble);
for (const section of changelog.sections) {
result += `\n${section.headerLine}\n${unlines(section.bodyLines)}`;
}
return result;
}
/**
@@ -66,70 +155,58 @@ export function processChangelogForBackports(
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;
// Parse the changelog.
const changelog = parseChangelog(content);
// 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) {
if (changelog.sections.length === 0) {
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";
// Filter out changelog entries that only apply to newer versions and
// update the section headings with the backport major version for
// sections we keep.
for (const section of changelog.sections) {
// Update the section headings with the backport major version.
section.headerLine = section.headerLine.replace(
`## ${sourceBranchMajorVersion}`,
`## ${targetBranchMajorVersion}`,
);
while (i < lines.length) {
let line = lines[i];
i++;
const filteredEntries: string[] = [];
let foundContent = false;
// Filter out changelog entries that only apply to newer versions.
const match = someVersionsOnlyRegex.exec(line);
if (match) {
for (const line of section.bodyLines) {
// Skip the entry if `someVersionsOnlyRegex` matches and the major version
// of the target branch is smaller than the required version.
const match = someVersionsOnlyRegex.exec(line);
if (
match &&
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 {
// Keep the line.
filteredEntries.push(line);
// Set `foundContent` to `true` if the line is not empty.
if (line.trim() !== "") {
foundContent = true;
output += `${line}\n`;
}
}
// Update the section with the retained entries.
section.bodyLines = filteredEntries;
// Add an entry if we didn't keep any.
if (!foundContent) {
section.bodyLines.push(NO_CHANGES_STR.trim());
}
}
return output;
return renderChangelog(changelog);
}

View File

@@ -37,6 +37,10 @@ export const API_COMPATIBILITY_FILE = path.join(
"api-compatibility.json",
);
/** The prefix of CodeQL CLI bundle release URLs. */
export const CLI_BUNDLE_RELEASE_URL_PREFIX =
"https://github.com/github/codeql-action/releases/tag/codeql-bundle-v";
/** A common interface for operations that support dry runs. */
export interface DryRunOption {
/** A value indicating whether to perform operations with side effects. */

View File

@@ -0,0 +1,54 @@
/**
* Tests for `prepare-changelog.ts`.
*/
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { EMPTY_CHANGELOG, NO_CHANGES_STR } from "./changelog";
import { extractChangelogSnippet } from "./prepare-changelog";
let testDir: string;
beforeEach(() => {
// Set up a temporary directory for testing
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "prepare-changelog-test-"));
});
afterEach(() => {
/** Clean up temporary directories. */
fs.rmSync(testDir, { recursive: true, force: true });
});
const testBody = `- Test change`;
const testChangelog = `${EMPTY_CHANGELOG.replace(NO_CHANGES_STR, testBody)}
## Another section
- Other change`;
describe("extractChangelogSnippet", async () => {
await it("returns the default body if the input doesn't exist", async () => {
const result = extractChangelogSnippet(path.join(testDir, "not-here.md"));
assert.deepEqual(result, NO_CHANGES_STR);
});
await it("returns the first section if the input exists", async () => {
const changelogPath = path.join(testDir, "test-readme.md");
fs.writeFileSync(changelogPath, testChangelog);
const result = extractChangelogSnippet(changelogPath);
assert.deepEqual(result, testBody);
});
await it("returns an empty string if there is no first section", async () => {
const changelogPath = path.join(testDir, "test-readme.md");
fs.writeFileSync(changelogPath, "# CodeQL Action Changelog\n");
const result = extractChangelogSnippet(changelogPath);
assert.deepEqual(result, "");
});
});

82
pr-checks/prepare-changelog.ts Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env npx tsx
/**
* Extracts the body of the first changelog section and outputs it to either
* stdout or a file.
*/
import * as fs from "node:fs";
import { parseArgs } from "node:util";
import { NO_CHANGES_STR, parseChangelog } from "./changelog";
import { CHANGELOG_FILE } from "./config";
import { getErrorMessage } from "./util";
/**
* Prepare the changelog for the new release
* This function will extract the part of the changelog that
* we want to include in the new release.
*
* @param changelogPath The path to the changelog file.
*/
export function extractChangelogSnippet(changelogPath: string) {
try {
const content = fs.readFileSync(changelogPath, "utf-8");
const changelog = parseChangelog(content);
// Return an empty string if we couldn't find the first section.
if (changelog.sections.length === 0) {
return "";
}
return changelog.sections[0].bodyLines.join("\n").trim();
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
console.error(`Changelog file at '${changelogPath}' does not exist.`);
return NO_CHANGES_STR;
} else {
throw Error(
`Failed to open changelog file at '${changelogPath}': ${getErrorMessage(err)}`,
);
}
}
}
function main() {
try {
const { values } = parseArgs({
options: {
changelog: {
type: "string",
short: "f",
default: CHANGELOG_FILE,
},
output: {
type: "string",
short: "o",
},
},
strict: true,
});
const body = extractChangelogSnippet(values.changelog);
// If no `output` argument was provided, output to stdout. Otherwise,
// write a file to the specified path.
if (values.output === undefined) {
console.info(body);
} else {
fs.writeFileSync(values.output, body);
}
return 0;
} catch (err) {
console.error(`Failed to prepare changelog: ${getErrorMessage(err)}`);
return -1;
}
}
// Only call `main` if this script was run directly.
if (require.main === module) {
process.exit(main());
}

View File

@@ -0,0 +1,45 @@
/**
* Tests for `rollback-changelog.ts`.
*/
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import { describe, it } from "node:test";
import { getReleaseDateString, parseChangelog } from "./changelog";
import { CHANGELOG_FILE } from "./config";
import { updateChangelog } from "./rollback-changelog";
describe("updateChangelog", async () => {
await it("replaces the first section with one for the rollback release", async () => {
const actualChangelog = parseChangelog(
fs.readFileSync(CHANGELOG_FILE, "utf-8"),
);
const existingFirstSection = actualChangelog.sections[0];
const today = new Date();
updateChangelog(actualChangelog, {
"new-version": "Test.1.3",
"rollback-version": "Test.1.2",
"target-version": "Test.1.1",
today,
});
// Check that the old, first section is gone.
for (const section of actualChangelog.sections) {
assert.notDeepEqual(section, existingFirstSection);
}
// Check that the new, first section matches our expectations.
const newFirstSection = actualChangelog.sections[0];
assert.deepEqual(
newFirstSection.headerLine,
`## Test.1.3 - ${getReleaseDateString(today)}`,
);
assert.equal(newFirstSection.bodyLines.length, 3);
assert.deepEqual(
newFirstSection.bodyLines[1],
`This release rolls back Test.1.2 due to issues with that release. It is identical to Test.1.1.`,
);
});
});

84
pr-checks/rollback-changelog.ts Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env npx tsx
/**
* Replaces the current, first section of the changelog with a new one for the rollback release.
*/
import * as fs from "node:fs";
import { parseArgs } from "node:util";
import {
Changelog,
ChangelogSection,
getReleaseDateString,
parseChangelog,
renderChangelog,
} from "./changelog";
import { CHANGELOG_FILE } from "./config";
import { getErrorMessage } from "./util";
export interface RollbackChangelogInputs {
"target-version": string;
"rollback-version": string;
"new-version": string;
today?: Date;
}
/**
* Replaces the current, first section of the changelog with a new one for the rollback release.
*/
export function updateChangelog(
changelog: Changelog,
versions: RollbackChangelogInputs,
) {
// Drop the existing first section.
changelog.sections.shift();
// Construct the section for the rollback version.
const newSection: ChangelogSection = {
headerLine: `## ${versions["new-version"]} - ${getReleaseDateString(versions.today)}`,
bodyLines: [
"",
`This release rolls back ${versions["rollback-version"]} due to issues with that release. It is identical to ${versions["target-version"]}.`,
"",
],
};
// Add the new section at the top of the changelog.
changelog.sections.unshift(newSection);
}
function main() {
try {
const options = {
"target-version": { type: "string", short: "t" },
"rollback-version": { type: "string", short: "r" },
"new-version": { type: "string", short: "n" },
} as const;
const { values } = parseArgs({ options, strict: true });
for (const key of Object.keys(options)) {
const val = values[key as keyof typeof values];
if (val === undefined || val.trim() === "") {
throw new Error(`Argument '--${key}' is required.`);
}
}
const changelog = parseChangelog(fs.readFileSync(CHANGELOG_FILE, "utf-8"));
updateChangelog(changelog, values as RollbackChangelogInputs);
console.info(renderChangelog(changelog));
return 0;
} catch (err) {
console.error(
`Failed to prepare rollback changelog: ${getErrorMessage(err)}`,
);
return -1;
}
}
// Only call `main` if this script was run directly.
if (require.main === module) {
process.exit(main());
}

9
pr-checks/util.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Returns an appropriate message for the error.
*
* If the error is an `Error` instance, this returns the error message without
* an `Error: ` prefix.
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View File

@@ -2,6 +2,7 @@ import * as github from "@actions/github";
import test from "ava";
import sinon from "sinon";
import { AnalysisKind } from "../analyses";
import * as api from "../api-client";
import { RegistryProxyVars } from "../environment";
import { Feature } from "../feature-flags";
@@ -13,13 +14,12 @@ import {
} from "../testing-utils";
import { getConfigFileInput, getRemoteConfig } from "./file";
import { InputSource } from "./inputs";
setupTests(test);
test("getConfigFileInput returns undefined by default", async (t) => {
await callee(getConfigFileInput)
.withArgs({})
.withArgs({}, undefined)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.passes(t.is, undefined);
});
@@ -41,32 +41,69 @@ test("getConfigFileInput returns input value", async (t) => {
.withArgs("config-file")
.returns(testInput);
})
.withArgs(repositoryProperties)
.logs(t, "Using config-file input from workflow")
.passes(t.deepEqual, { value: testInput, source: InputSource.Workflow });
.withArgs(repositoryProperties, undefined)
.logs(t, "Using configuration file input from workflow")
.passes(t.is, testInput);
});
test("getConfigFileInput returns repository property value", async (t) => {
// Since there is no direct input, we should use the repository property.
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withArgs(repositoryProperties)
.logs(t, "Using config-file input from repository property")
.passes(t.deepEqual, {
value: repositoryProperties[RepositoryPropertyName.CONFIG_FILE],
source: InputSource.RepositoryProperty,
});
.withArgs(repositoryProperties, undefined)
.logs(t, "Using configuration file input from repository property")
.passes(t.is, repositoryProperties[RepositoryPropertyName.CONFIG_FILE]);
});
test("getConfigFileInput returns repository property value for Code Scanning", async (t) => {
// Since there is no direct input, we should use the repository property.
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withArgs(repositoryProperties, [AnalysisKind.CodeScanning])
.logs(t, "Using configuration file input from repository property")
.passes(t.is, repositoryProperties[RepositoryPropertyName.CONFIG_FILE]);
});
test("getConfigFileInput ignores repository property for other analysis kinds", async (t) => {
const unsupportedCases = [
[AnalysisKind.CodeQuality],
[AnalysisKind.RiskAssessment],
[AnalysisKind.CodeScanning, AnalysisKind.CodeQuality],
];
const target = callee(getConfigFileInput).withFeatures([
Feature.ConfigFileRepositoryProperty,
]);
for (const unsupportedCase of unsupportedCases) {
// Since the analysis kind is unsupported, we should ignore the repository property.
await target
.withArgs(repositoryProperties, unsupportedCase)
.logs(
t,
"Ignoring configuration file input from repository property, because it is unsupported for the current analysis kind.",
)
.passes(t.is, undefined);
}
});
test("getConfigFileInput ignores empty repository property value", async (t) => {
// Since the repository property value is an empty/whitespace string, we should ignore it.
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withArgs({ [RepositoryPropertyName.CONFIG_FILE]: " " }, undefined)
.passes(t.is, undefined);
});
test("getConfigFileInput ignores repository property value when FF is off", async (t) => {
// Since the FF is off, we should ignore the repository property value.
await callee(getConfigFileInput)
.withFeatures([])
.withArgs(repositoryProperties)
.notLogs(t, "Using config-file input from repository property")
.withArgs(repositoryProperties, undefined)
.notLogs(t, "Using configuration file input from repository property")
.logs(
t,
"Ignoring config-file input from repository property, because the corresponding feature flag is disabled.",
"Ignoring configuration file input from repository property, because the corresponding feature flag is disabled.",
)
.passes(t.is, undefined);
});

View File

@@ -1,4 +1,5 @@
import { ActionState } from "../action-common";
import { AnalysisKind } from "../analyses";
import * as api from "../api-client";
import * as errorMessages from "../error-messages";
import { Feature } from "../feature-flags";
@@ -9,7 +10,6 @@ import {
import { ConfigurationError } from "../util";
import { parseUserConfig, UserConfig } from "./db-config";
import { getComputedInput, InputName, type ComputedInput } from "./inputs";
import { parseRemoteFileAddress } from "./remote-file";
/**
@@ -29,19 +29,55 @@ export const REMOTE_PATH_PREFIX = "remote=";
* Gets the value that is configured for the configuration file, if any.
*/
export async function getConfigFileInput(
action: ActionState<["Logger", "Actions", "FeatureFlags"]>,
{
logger,
actions,
features,
}: ActionState<["Logger", "Actions", "FeatureFlags"]>,
repositoryProperties: Partial<RepositoryProperties>,
): Promise<ComputedInput | undefined> {
// Only use the repository property value if the FF is enabled.
const useRepositoryProperty = await action.features.getValue(
Feature.ConfigFileRepositoryProperty,
);
analysisKinds: AnalysisKind[] | undefined,
): Promise<string | undefined> {
const input = actions.getOptionalInput("config-file");
return getComputedInput(action, repositoryProperties, InputName.ConfigFile, {
repositoryPropertyFeatureEnabled: useRepositoryProperty,
allowForcedRepositoryPropertyValue: false,
repositoryPropertyName: RepositoryPropertyName.CONFIG_FILE,
});
if (input !== undefined) {
logger.info(`Using configuration file input from workflow: ${input}`);
return input;
}
const propertyValue =
repositoryProperties[RepositoryPropertyName.CONFIG_FILE];
// Only allow the repository property to be used for standard Code Scanning analyses,
// since we don't currently support some customisation options for Code Quality.
// We don't expect customisations for Risk Assessments either.
const analysisKindSupported =
analysisKinds === undefined ||
(analysisKinds.includes(AnalysisKind.CodeScanning) &&
analysisKinds.length === 1);
if (propertyValue !== undefined && propertyValue.trim().length > 0) {
// Only use the repository property value if the FF is enabled.
const useRepositoryProperty = await features.getValue(
Feature.ConfigFileRepositoryProperty,
);
if (analysisKindSupported && useRepositoryProperty) {
logger.info(
`Using configuration file input from repository property: ${propertyValue}`,
);
return propertyValue;
} else if (!analysisKindSupported) {
logger.info(
"Ignoring configuration file input from repository property, because it is unsupported for the current analysis kind.",
);
} else {
logger.info(
"Ignoring configuration file input from repository property, because the corresponding feature flag is disabled.",
);
}
}
return undefined;
}
/**

View File

@@ -3,12 +3,10 @@ import { Feature } from "../feature-flags";
import {
RepositoryProperties,
RepositoryPropertyName,
StringRepositoryPropertyNames,
} from "../feature-flags/properties";
/** Enumerates input names. */
export enum InputName {
ConfigFile = "config-file",
Tools = "tools",
}
@@ -30,50 +28,26 @@ export type ComputedInput = {
};
/**
* Represents options for how to compute an input.
*/
export interface ComputedInputOptions {
/**
* Whether the FF for the repository property (if any) is enabled.
*/
repositoryPropertyFeatureEnabled: boolean;
/**
* The name of the repository property to try and get the input value from.
*/
repositoryPropertyName: StringRepositoryPropertyNames;
/**
* Whether the repository property value may start with `!` to take precedence
* over any input value provided in the workflow file.
*/
allowForcedRepositoryPropertyValue: boolean;
}
/**
* Gets the computed input for `name`. This comes from either the workflow or
* Gets the computed `tools` input. This comes from either the workflow or
* the repository property.
*
* @param action The Action state.
* @param repositoryProperties The values of known repository properties.
* @param name The name of the input to compute.
* @param options Options for how to compute the input value.
*
* @returns The computed input or `undefined` if there is no input.
*/
export async function getComputedInput(
export async function getToolsInput(
action: ActionState<["Logger", "Actions", "FeatureFlags"]>,
repositoryProperties: RepositoryProperties,
name: InputName,
options: ComputedInputOptions,
repositoryProperties: Partial<RepositoryProperties>,
): Promise<ComputedInput | undefined> {
const name = InputName.Tools;
const input = action.actions.getOptionalInput(name);
const propertyValue = repositoryProperties[options.repositoryPropertyName];
const propertyValue = repositoryProperties[RepositoryPropertyName.TOOLS];
const allowRepositoryProperty = await action.features.getValue(
Feature.ToolsRepositoryProperty,
);
// The repository property takes precedence if it starts with an '!'.
if (
options.repositoryPropertyFeatureEnabled &&
options.allowForcedRepositoryPropertyValue &&
propertyValue?.startsWith("!")
) {
if (allowRepositoryProperty && propertyValue?.startsWith("!")) {
action.logger.info(
`Using ${name} input from repository property (enforced): ${propertyValue}`,
);
@@ -91,7 +65,7 @@ export async function getComputedInput(
}
// Use the repository property if there's no workflow input.
if (options.repositoryPropertyFeatureEnabled && propertyValue !== undefined) {
if (allowRepositoryProperty && propertyValue !== undefined) {
action.logger.info(
`Using ${name} input from repository property: ${propertyValue}`,
);
@@ -99,34 +73,8 @@ export async function getComputedInput(
value: propertyValue,
source: InputSource.RepositoryProperty,
};
} else if (propertyValue !== undefined) {
action.logger.info(
`Ignoring ${name} input from repository property, because the corresponding feature flag is disabled.`,
);
}
// There's no input.
return undefined;
}
/**
* Gets the computed `tools` input. This comes from either the workflow or
* the repository property.
*
* @param action The Action state.
* @param repositoryProperties The values of known repository properties.
* @returns The computed input or `undefined` if there is no input.
*/
export async function getToolsInput(
action: ActionState<["Logger", "Actions", "FeatureFlags"]>,
repositoryProperties: Partial<RepositoryProperties>,
): Promise<ComputedInput | undefined> {
const allowRepositoryProperty = await action.features.getValue(
Feature.ToolsRepositoryProperty,
);
return getComputedInput(action, repositoryProperties, InputName.Tools, {
repositoryPropertyFeatureEnabled: allowRepositoryProperty,
allowForcedRepositoryPropertyValue: true,
repositoryPropertyName: RepositoryPropertyName.TOOLS,
});
}

View File

@@ -74,7 +74,6 @@ export enum Feature {
AllowMergeConfigFiles = "allow_merge_config_files",
/** Controls whether we allow multiple values for the `analysis-kinds` input. */
AllowMultipleAnalysisKinds = "allow_multiple_analysis_kinds",
AllowToolcacheInput = "allow_toolcache_input",
CleanupTrapCaches = "cleanup_trap_caches",
/** Whether to allow the `config-file` input to be specified via a repository property. */
ConfigFileRepositoryProperty = "config_file_repository_property",
@@ -187,11 +186,6 @@ export const featureConfig = {
envVar: "CODEQL_ACTION_ALLOW_MULTIPLE_ANALYSIS_KINDS",
minimumVersion: undefined,
},
[Feature.AllowToolcacheInput]: {
defaultValue: false,
envVar: "CODEQL_ACTION_ALLOW_TOOLCACHE_INPUT",
minimumVersion: undefined,
},
[Feature.CleanupTrapCaches]: {
defaultValue: false,
envVar: "CODEQL_ACTION_CLEANUP_TRAP_CACHES",

View File

@@ -176,30 +176,6 @@ test.serial(
},
);
test.serial(
"loadPropertiesFromApi returns undefined if non-empty string property is empty",
async (t) => {
sinon.stub(api, "getRepositoryProperties").resolves({
headers: {},
status: 200,
url: "",
data: [{ property_name: "github-codeql-config-file", value: "" }],
});
const logger = getRunnerLogger(true);
const mockRepositoryNwo = parseRepositoryNwo("owner/repo");
// `github-codeql-config-file` is a `nonEmptyStringProperty`, so we expect to
// get `undefined` instead of the empty string
const response = await properties.loadPropertiesFromApi(
logger,
mockRepositoryNwo,
);
t.deepEqual(response, {
"github-codeql-config-file": undefined,
});
},
);
test.serial(
"loadPropertiesFromApi warns if boolean property has unexpected value",
async (t) => {

View File

@@ -22,25 +22,13 @@ export enum RepositoryPropertyName {
/** Parsed types of the known repository properties. */
export type AllRepositoryProperties = {
[RepositoryPropertyName.CONFIG_FILE]: string | undefined;
[RepositoryPropertyName.CONFIG_FILE]: string;
[RepositoryPropertyName.DISABLE_OVERLAY]: boolean;
[RepositoryPropertyName.EXTRA_QUERIES]: string;
[RepositoryPropertyName.FILE_COVERAGE_ON_PRS]: boolean;
[RepositoryPropertyName.TOOLS]: string;
};
/**
* The subset of known repository properties which are of type `string`.
* We tolerate `undefined` for `string`-typed properties that are empty.
*/
export type StringRepositoryPropertyNames = keyof {
[K in keyof AllRepositoryProperties as AllRepositoryProperties[K] extends
| string
| undefined
? K
: never]: AllRepositoryProperties[K];
};
/** Parsed repository properties. */
export type RepositoryProperties = Partial<AllRepositoryProperties>;
@@ -84,12 +72,6 @@ const stringProperty = {
parse: parseStringRepositoryProperty,
};
/** A repository property that we expect to contain a non-empty string value. */
const nonEmptyStringProperty = {
...stringProperty,
parse: parseNonEmptyStringRepositoryProperty,
};
/** A repository property that we expect to contain a boolean value. */
const booleanProperty = {
// The value from the API should come as a string, which we then parse into a boolean.
@@ -101,7 +83,7 @@ const booleanProperty = {
const repositoryPropertyParsers: {
[K in RepositoryPropertyName]: PropertyInfo<K>;
} = {
[RepositoryPropertyName.CONFIG_FILE]: nonEmptyStringProperty,
[RepositoryPropertyName.CONFIG_FILE]: stringProperty,
[RepositoryPropertyName.DISABLE_OVERLAY]: booleanProperty,
[RepositoryPropertyName.EXTRA_QUERIES]: stringProperty,
[RepositoryPropertyName.FILE_COVERAGE_ON_PRS]: booleanProperty,
@@ -246,17 +228,6 @@ function parseStringRepositoryProperty(_name: string, value: string): string {
return value;
}
/** Parse a non-empty string repository property. */
function parseNonEmptyStringRepositoryProperty(
_name: string,
value: string,
): string | undefined {
if (value.trim().length === 0) {
return undefined;
}
return value;
}
/** Set of known repository property names, for fast lookups. */
const KNOWN_REPOSITORY_PROPERTY_NAMES = new Set<string>(
Object.values(RepositoryPropertyName),

View File

@@ -129,7 +129,7 @@ async function sendStartingStatusReport(
async function sendCompletedStatusReport(
startedAt: Date,
config: configUtils.Config | undefined,
configFileInput: ComputedInput | undefined,
configFile: string | undefined,
toolsInput: ComputedInput | undefined,
toolsDownloadStatusReport: ToolsDownloadStatusReport | undefined,
toolsFeatureFlagsValid: boolean | undefined,
@@ -168,9 +168,6 @@ async function sendCompletedStatusReport(
if (toolsInput !== undefined) {
initStatusReport.computed_inputs.tools = toolsInput;
}
if (configFileInput !== undefined) {
initStatusReport.computed_inputs["config-file"] = configFileInput;
}
const initToolsDownloadFields: InitToolsDownloadFields = {};
@@ -188,7 +185,7 @@ async function sendCompletedStatusReport(
await createInitWithConfigStatusReport(
config,
initStatusReport,
configFileInput,
configFile,
Math.round(
await getTotalCacheSize(Object.values(config.trapCaches), logger),
),
@@ -215,7 +212,7 @@ async function run(
let apiDetails: GitHubApiCombinedDetails;
let config: configUtils.Config | undefined;
let configFileInput: ComputedInput | undefined;
let configFile: string | undefined;
let codeql: CodeQL;
let features: FeatureEnablement;
let sourceRoot: string;
@@ -265,12 +262,6 @@ async function run(
core.exportVariable(EnvVar.INIT_ACTION_HAS_RUN, "true");
const actionStateWithFeatures = { ...actionState, features };
configFileInput = await getConfigFileInput(
actionStateWithFeatures,
repositoryProperties,
);
// path.resolve() respects the intended semantics of source-root. If
// source-root is relative, it is relative to the GITHUB_WORKSPACE. If
// source-root is absolute, it is used as given.
@@ -293,6 +284,14 @@ async function run(
);
}
// Compute the value of the `config-file` input.
const actionStateWithFeatures = { ...actionState, features };
configFile = await getConfigFileInput(
actionStateWithFeatures,
repositoryProperties,
analysisKinds,
);
// Send a status report indicating that an analysis is starting.
await sendStartingStatusReport(startedAt, { analysisKinds }, logger);
@@ -379,7 +378,7 @@ async function run(
packsInput: getOptionalInput("packs"),
buildModeInput: getOptionalInput("build-mode"),
ramInput: getOptionalInput("ram"),
configFile: configFileInput?.value,
configFile,
dbLocation: getOptionalInput("db-location"),
configInput: getOptionalInput("config"),
dependencyCachingEnabled: getDependencyCachingEnabled(),
@@ -785,7 +784,7 @@ async function run(
await sendCompletedStatusReport(
startedAt,
config,
configFileInput,
configFile,
toolsInput,
toolsDownloadStatusReport,
toolsFeatureFlagsValid,

View File

@@ -451,7 +451,7 @@ test.serial(
async (t) => {
const loggedMessages: LoggedMessage[] = [];
const logger = getRecordingLogger(loggedMessages);
const features = createFeatures([Feature.AllowToolcacheInput]);
const features = createFeatures([]);
const latestToolcacheVersion = "3.2.1";
const latestVersionPath = "/path/to/latest";
@@ -580,7 +580,7 @@ const toolcacheInputFallbackMacro = makeMacro({
toolcacheInputFallbackMacro.serial(
"the toolcache doesn't have a CodeQL CLI when tools == toolcache",
[Feature.AllowToolcacheInput],
[],
{ GITHUB_EVENT_NAME: "dynamic" },
[],
[
@@ -591,7 +591,7 @@ toolcacheInputFallbackMacro.serial(
toolcacheInputFallbackMacro.serial(
"the workflow trigger is not `dynamic`",
[Feature.AllowToolcacheInput],
[],
{ GITHUB_EVENT_NAME: "pull_request" },
[],
[
@@ -599,14 +599,6 @@ toolcacheInputFallbackMacro.serial(
],
);
toolcacheInputFallbackMacro.serial(
"the feature flag is not enabled",
[],
{ GITHUB_EVENT_NAME: "dynamic" },
[],
[`Ignoring 'tools: toolcache' because the feature is not enabled.`],
);
test.serial(
'tryGetTagNameFromUrl extracts the right tag name for a repo name containing "codeql-bundle"',
(t) => {

View File

@@ -533,11 +533,7 @@ export async function getCodeQLSource(
// We only allow `toolsInput === "toolcache"` for `dynamic` events. In general, using `toolsInput === "toolcache"`
// can lead to alert wobble and so it shouldn't be used for an analysis where results are intended to be uploaded.
// We also allow this in test mode.
const allowToolcacheValueFF = await features.getValue(
Feature.AllowToolcacheInput,
);
const allowToolcacheValue =
allowToolcacheValueFF && (isDynamicWorkflow() || util.isInTestMode());
const allowToolcacheValue = isDynamicWorkflow() || util.isInTestMode();
if (allowToolcacheValue) {
// If `toolsInput === "toolcache"`, try to find the latest version of the CLI that's available in the toolcache
// and use that. We perform this check here since we can set `cliVersion` directly and don't want to default to
@@ -558,15 +554,9 @@ export async function getCodeQLSource(
`Found no CodeQL CLI in the toolcache, ignoring 'tools: ${toolsInput}'...`,
);
} else {
if (allowToolcacheValueFF) {
logger.warning(
`Ignoring 'tools: ${toolsInput}' because the workflow was not triggered dynamically.`,
);
} else {
logger.info(
`Ignoring 'tools: ${toolsInput}' because the feature is not enabled.`,
);
}
logger.warning(
`Ignoring 'tools: ${toolsInput}' because the workflow was not triggered dynamically.`,
);
}
const version = await resolveDefaultCliVersion(

View File

@@ -554,7 +554,7 @@ export interface InitToolsDownloadFields {
*
* @param config The CodeQL Action configuration whose values should be added to the base status report.
* @param initStatusReport The base status report.
* @param configFileInput Optionally, the filename of the configuration file that was read.
* @param configFile Optionally, the filename of the configuration file that was read.
* @param totalCacheSize The computed total TRAP cache size.
* @param overlayBaseDatabaseStats Statistics about the overlay database, if any.
* @returns
@@ -562,7 +562,7 @@ export interface InitToolsDownloadFields {
export async function createInitWithConfigStatusReport(
config: Config,
initStatusReport: InitStatusReport,
configFileInput: ComputedInput | undefined,
configFile: string | undefined,
totalCacheSize: number,
overlayBaseDatabaseStats: OverlayBaseDatabaseDownloadStats | undefined,
dependencyCachingResults: DependencyCacheRestoreStatusReport | undefined,
@@ -601,7 +601,7 @@ export async function createInitWithConfigStatusReport(
return {
...initStatusReport,
config_file: configFileInput?.value ?? "",
config_file: configFile ?? "",
disable_default_queries: disableDefaultQueries,
paths,
paths_ignore: pathsIgnore,