5
0
mirror of https://gitea.com/actions/setup-java.git synced 2026-08-05 02:31:18 +00:00

Optimize Maven configuration warm path (#1182)

* Optimize Maven configuration warm path

Avoid eager Maven XML initialization on warm JDK runs by using deterministic serializers for new Maven settings/toolchains files, lazy-loading xmlbuilder2 for existing toolchains merges, and deferring Maven configuration modules until after Java setup.

Add targeted tests for XML escaping, lazy xmlbuilder2 loading, concurrent Maven configuration, and a manual benchmark workflow for warm-path validation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b

* Address Maven optimization PR feedback

Make the toolchain XML generator consistently async, remove redundant Maven configuration await handling, and reuse the existing XML test helper.

Configure CodeQL to skip generated dist output so newly split vendored chunks do not report duplicate generated-code alerts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b

* Apply rubber duck review suggestions

Document XML attribute escaping, simplify Maven configuration awaiting, and add a regression test that feeds fast-path toolchains output into the merge path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b

* Delete .github/codeql/codeql-config.yml

* Update codeql-analysis.yml

* Replace xmlbuilder2 in Maven toolchain merge

Use fast-xml-parser for existing toolchains.xml parsing and serialize merged Maven toolchains deterministically. This removes the bundled xmlbuilder2 DOM/XML builder chunk from dist while preserving merge behavior for custom attributes, custom toolchains, partial entries, duplicate filtering, and escaping.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b

* Move Maven benchmark out of setup-java

Remove the Maven warm-path benchmark workflow and helper script from setup-java. Benchmark coverage is being moved to actions/setup-java-benchmarks so this action repository only carries the runtime optimization, tests, and generated distribution artifacts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b

---------

Copilot-Session: 4dc58426-5e20-44cb-af16-8da0965fac3b
This commit is contained in:
Bruno Borges
2026-07-30 15:09:35 -04:00
committed by GitHub
parent 3cc3643700
commit 5827477733
24 changed files with 64238 additions and 31704 deletions
+34 -40
View File
@@ -4,11 +4,10 @@ import * as io from '@actions/io';
import * as fs from 'fs';
import * as os from 'os';
import {create as xmlCreate} from 'xmlbuilder2';
import * as constants from './constants.js';
import * as gpg from './gpg.js';
import {getBooleanInput} from './util.js';
import {escapeXmlText} from './xml.js';
export async function configureAuthentication() {
const id = core.getInput(constants.INPUT_SERVER_ID);
@@ -101,53 +100,48 @@ export function generate(
passwordEnvVar: string,
gpgPassphraseEnvVar?: string | undefined
) {
const xmlObj: {[key: string]: any} = {
settings: {
'@xmlns': 'http://maven.apache.org/SETTINGS/1.0.0',
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'@xsi:schemaLocation':
'http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd',
interactiveMode: false,
servers: {
server: [
{
id: id,
username: `\${env.${usernameEnvVar}}`,
password: `\${env.${passwordEnvVar}}`
}
]
}
}
};
// The maven-gpg-plugin reads the passphrase from the environment variable
// named by the `gpg.passphraseEnvName` property (default MAVEN_GPG_PASSPHRASE).
// Only configure it when the requested env var name differs from that default;
// otherwise the plugin already reads the right variable and no extra settings
// are needed. Writing `gpg.passphrase` to settings.xml is deprecated and fails
// when the plugin's `bestPractices` mode is enabled.
if (
const includeGpgPassphraseProfile =
gpgPassphraseEnvVar &&
gpgPassphraseEnvVar !== constants.MAVEN_GPG_PASSPHRASE_DEFAULT_ENV
) {
xmlObj.settings.profiles = {
profile: {
id: constants.GPG_PASSPHRASE_PROFILE_ID,
properties: {
'gpg.passphraseEnvName': gpgPassphraseEnvVar
}
}
};
xmlObj.settings.activeProfiles = {
activeProfile: constants.GPG_PASSPHRASE_PROFILE_ID
};
gpgPassphraseEnvVar !== constants.MAVEN_GPG_PASSPHRASE_DEFAULT_ENV;
const lines = [
'<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd">',
' <interactiveMode>false</interactiveMode>',
' <servers>',
' <server>',
` <id>${escapeXmlText(id)}</id>`,
` <username>${escapeXmlText(`\${env.${usernameEnvVar}}`)}</username>`,
` <password>${escapeXmlText(`\${env.${passwordEnvVar}}`)}</password>`,
' </server>',
' </servers>'
];
if (includeGpgPassphraseProfile) {
lines.push(
' <profiles>',
' <profile>',
` <id>${constants.GPG_PASSPHRASE_PROFILE_ID}</id>`,
' <properties>',
` <gpg.passphraseEnvName>${escapeXmlText(gpgPassphraseEnvVar)}</gpg.passphraseEnvName>`,
' </properties>',
' </profile>',
' </profiles>',
' <activeProfiles>',
` <activeProfile>${constants.GPG_PASSPHRASE_PROFILE_ID}</activeProfile>`,
' </activeProfiles>'
);
}
return xmlCreate(xmlObj).end({
headless: true,
prettyPrint: true,
width: 80
});
lines.push('</settings>');
return lines.join('\n');
}
async function write(
+59 -17
View File
@@ -1,15 +1,13 @@
import fs from 'fs';
import * as core from '@actions/core';
import * as auth from './auth.js';
import {getBooleanInput, getVersionFromFileContent} from './util.js';
import * as toolchains from './toolchains.js';
import * as constants from './constants.js';
import * as path from 'path';
import {fileURLToPath} from 'url';
import {getJavaDistribution} from './distributions/distribution-factory.js';
import {JavaInstallerOptions} from './distributions/base-models.js';
import {configureMavenArgs} from './maven-args.js';
import {configureProblemMatcher} from './problem-matcher.js';
import {validateToolchainIds} from './toolchain-ids.js';
export async function run() {
const versions = core.getMultilineInput(constants.INPUT_JAVA_VERSION);
@@ -33,9 +31,9 @@ export async function run() {
const verifySignaturePublicKey =
core.getInput(constants.INPUT_VERIFY_SIGNATURE_PUBLIC_KEY) || undefined;
const toolchainIds = core.getMultilineInput(constants.INPUT_MVN_TOOLCHAIN_ID);
let actionError: Error | undefined;
let cacheRestore: Promise<void> | undefined;
const toolchainConfigurations: ToolchainConfiguration[] = [];
try {
core.startGroup('Installed distributions');
@@ -44,7 +42,7 @@ export async function run() {
throw new Error('java-version or java-version-file input expected');
}
toolchains.validateToolchainIds(versions, versionFile, toolchainIds);
validateToolchainIds(versions, versionFile, toolchainIds);
if (!versions.length) {
core.debug(
@@ -93,7 +91,9 @@ export async function run() {
cacheRestore = cache
? startCacheRestore(cache, cacheDependencyPath, cachePath)
: undefined;
await installVersion(versionInfo.version, installerInputsOptions);
toolchainConfigurations.push(
await installVersion(versionInfo.version, installerInputsOptions)
);
} else {
// When using java-version input, distribution is still required
if (!distributionName) {
@@ -117,7 +117,9 @@ export async function run() {
? startCacheRestore(cache, cacheDependencyPath, cachePath)
: undefined;
for (const [index, version] of versions.entries()) {
await installVersion(version, installerInputsOptions, index);
toolchainConfigurations.push(
await installVersion(version, installerInputsOptions, index)
);
}
}
core.endGroup();
@@ -129,8 +131,7 @@ export async function run() {
);
configureProblemMatcher(path.join(matchersPath, 'java.json'));
await auth.configureAuthentication();
configureMavenArgs();
await configureMaven(toolchainConfigurations);
} catch (error) {
actionError = error as Error;
}
@@ -174,7 +175,7 @@ async function installVersion(
version: string,
options: installerInputsOptions,
toolchainId = 0
) {
): Promise<ToolchainConfiguration> {
const {
distributionName,
jdkFile,
@@ -217,19 +218,19 @@ async function installVersion(
const isLatest = version.trim().toLowerCase() === 'latest';
const toolchainVersion = isLatest ? result.version : version;
await toolchains.configureToolchains(
toolchainVersion,
distributionName,
result.path,
toolchainIds[toolchainId]
);
core.info('');
core.info('Java configuration:');
core.info(` Distribution: ${distributionName}`);
core.info(` Version: ${result.version}`);
core.info(` Path: ${result.path}`);
core.info('');
return {
version: toolchainVersion,
distributionName,
path: result.path,
toolchainId: toolchainIds[toolchainId]
};
}
interface installerInputsOptions {
@@ -245,6 +246,47 @@ interface installerInputsOptions {
toolchainIds: Array<string>;
}
interface ToolchainConfiguration {
version: string;
distributionName: string;
path: string;
toolchainId?: string;
}
async function configureMaven(
toolchainConfigurations: ToolchainConfiguration[]
): Promise<void> {
const authentication = import('./auth.js').then(auth =>
auth.configureAuthentication()
);
const toolchains = configureInstalledToolchains(toolchainConfigurations);
const results = await Promise.allSettled([authentication, toolchains]);
const failure = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failure) {
throw failure.reason;
}
const {configureMavenArgs} = await import('./maven-args.js');
configureMavenArgs();
}
async function configureInstalledToolchains(
toolchainConfigurations: ToolchainConfiguration[]
): Promise<void> {
const toolchains = await import('./toolchains.js');
for (const configuration of toolchainConfigurations) {
await toolchains.configureToolchains(
configuration.version,
configuration.distributionName,
configuration.path,
configuration.toolchainId
);
}
}
async function startCacheRestore(
cache: string,
cacheDependencyPath: string,
+16
View File
@@ -0,0 +1,16 @@
export function validateToolchainIds(
versions: string[],
versionFile: string,
toolchainIds: string[]
) {
if (!toolchainIds.length) {
return;
}
const versionCount = versions.length || (versionFile ? 1 : 0);
if (versionCount !== toolchainIds.length) {
throw new Error(
`The number of Maven toolchain IDs (${toolchainIds.length}) must match the number of Java versions (${versionCount})`
);
}
}
+213 -86
View File
@@ -4,8 +4,8 @@ import * as path from 'path';
import * as core from '@actions/core';
import * as io from '@actions/io';
import * as constants from './constants.js';
import {create as xmlCreate} from 'xmlbuilder2';
export {validateToolchainIds} from './toolchain-ids.js';
import {escapeXmlAttribute, escapeXmlText} from './xml.js';
interface JdkInfo {
version: string;
@@ -14,23 +14,6 @@ interface JdkInfo {
jdkHome: string;
}
export function validateToolchainIds(
versions: string[],
versionFile: string,
toolchainIds: string[]
) {
if (!toolchainIds.length) {
return;
}
const versionCount = versions.length || (versionFile ? 1 : 0);
if (versionCount !== toolchainIds.length) {
throw new Error(
`The number of Maven toolchain IDs (${toolchainIds.length}) must match the number of Java versions (${versionCount})`
);
}
}
export async function configureToolchains(
version: string,
distributionName: string,
@@ -70,7 +53,7 @@ export async function createToolchainsSettings({
await io.mkdirP(settingsDirectory);
const originalToolchains =
await readExistingToolchainsFile(settingsDirectory);
const updatedToolchains = generateToolchainDefinition(
const updatedToolchains = await generateToolchainDefinition(
originalToolchains,
jdkInfo.version,
jdkInfo.vendor,
@@ -81,7 +64,27 @@ export async function createToolchainsSettings({
}
// only exported for testing purposes
export function generateToolchainDefinition(
export async function generateToolchainDefinition(
original: string,
version: string,
vendor: string,
id: string,
jdkHome: string
) {
if (!original?.length) {
return generateNewToolchainDefinition(version, vendor, id, jdkHome);
}
return generateMergedToolchainDefinition(
original,
version,
vendor,
id,
jdkHome
);
}
async function generateMergedToolchainDefinition(
original: string,
version: string,
vendor: string,
@@ -108,62 +111,75 @@ export function generateToolchainDefinition(
'@xsi:schemaLocation':
'http://maven.apache.org/TOOLCHAINS/1.1.0 https://maven.apache.org/xsd/toolchains-1.1.0.xsd'
};
if (original?.length) {
// convert existing toolchains into TS native objects for better handling
// xmlbuilder2 will convert the document into a `{toolchains: { toolchain: [] | {} }}` structure
// instead of the desired `toolchains: [{}]` one or simply `[{}]`
const jsObj = xmlCreate(original)
.root()
.toObject() as unknown as ExtractedToolchains;
if (jsObj.toolchains) {
// preserve the existing root attributes (xmlns, schemaLocation, …) so we don't
// silently rewrite user-managed metadata or change the effective XML namespace;
// xmlbuilder2 exposes attributes as `@`-prefixed keys on the element object
const existingAttributes = Object.fromEntries(
Object.entries(jsObj.toolchains).filter(([key]) => key.startsWith('@'))
) as Record<string, string>;
// fall back to the defaults only for attributes the existing file is missing
rootAttributes = {...rootAttributes, ...existingAttributes};
const {XMLParser} = await import('fast-xml-parser');
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@',
parseAttributeValue: false,
parseTagValue: false,
trimValues: true,
isArray: tagName => tagName === 'toolchain'
});
const jsObj = parser.parse(original) as ExtractedToolchains;
if (isToolchainsRoot(jsObj.toolchains)) {
// preserve the existing root attributes (xmlns, schemaLocation, …) so we don't
// silently rewrite user-managed metadata or change the effective XML namespace;
// fast-xml-parser exposes attributes as `@`-prefixed keys on the element object
const existingAttributes = Object.fromEntries(
Object.entries(jsObj.toolchains).filter(
([key, value]) => key.startsWith('@') && typeof value === 'string'
)
) as Record<string, string>;
// fall back to the defaults only for attributes the existing file is missing
rootAttributes = {...rootAttributes, ...existingAttributes};
if (jsObj.toolchains.toolchain) {
// in case only a single child exists xmlbuilder2 will not create an array and using verbose = true equally doesn't work here
// See https://oozcitak.github.io/xmlbuilder2/serialization.html#js-object-and-map-serializers for details
if (Array.isArray(jsObj.toolchains.toolchain)) {
jsToolchains.push(...jsObj.toolchains.toolchain);
} else {
jsToolchains.push(jsObj.toolchains.toolchain);
}
}
if (jsObj.toolchains.toolchain) {
jsToolchains.push(...jsObj.toolchains.toolchain);
}
// remove potential duplicates based on type & id (which should be a unique combination);
// self.findIndex will only return the first occurrence, ensuring duplicates are skipped
jsToolchains = jsToolchains.filter(
(value, index, self) =>
// ensure non-jdk toolchains are kept in the results, we must not touch them because they belong to the user
value.type !== 'jdk' ||
// keep toolchains that lack a usable string id (e.g. partially-formed user files);
// we cannot safely deduplicate them and must not crash while reading them
typeof value.provides?.id !== 'string' ||
index ===
self.findIndex(
t => t.type === value.type && t.provides?.id === value.provides?.id
)
);
}
return xmlCreate({
toolchains: {
...rootAttributes,
toolchain: jsToolchains
}
}).end({
format: 'xml',
wellFormed: false,
headless: false,
prettyPrint: true,
width: 80
});
// remove potential duplicates based on type & id (which should be a unique combination);
// self.findIndex will only return the first occurrence, ensuring duplicates are skipped
jsToolchains = jsToolchains.filter(
(value, index, self) =>
// ensure non-jdk toolchains are kept in the results, we must not touch them because they belong to the user
value.type !== 'jdk' ||
// keep toolchains that lack a usable string id (e.g. partially-formed user files);
// we cannot safely deduplicate them and must not crash while reading them
typeof value.provides?.id !== 'string' ||
index ===
self.findIndex(
t => t.type === value.type && t.provides?.id === value.provides?.id
)
);
return serializeToolchains(rootAttributes, jsToolchains);
}
export function generateNewToolchainDefinition(
version: string,
vendor: string,
id: string,
jdkHome: string
) {
return [
'<?xml version="1.0"?>',
'<toolchains xmlns="http://maven.apache.org/TOOLCHAINS/1.1.0"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://maven.apache.org/TOOLCHAINS/1.1.0 https://maven.apache.org/xsd/toolchains-1.1.0.xsd">',
' <toolchain>',
' <type>jdk</type>',
' <provides>',
` <version>${escapeXmlText(version)}</version>`,
` <vendor>${escapeXmlText(vendor)}</vendor>`,
` <id>${escapeXmlText(id)}</id>`,
' </provides>',
' <configuration>',
` <jdkHome>${escapeXmlText(jdkHome)}</jdkHome>`,
' </configuration>',
' </toolchain>',
'</toolchains>'
].join('\n');
}
async function readExistingToolchainsFile(directory: string) {
@@ -198,23 +214,134 @@ async function writeToolchainsFileToDisk(directory: string, settings: string) {
});
}
function serializeToolchains(
rootAttributes: Record<string, string>,
toolchains: Toolchain[]
) {
return [
'<?xml version="1.0"?>',
serializeOpeningTag('toolchains', rootAttributes, 0),
...toolchains.flatMap(toolchain =>
serializeXmlElement('toolchain', toolchain, 1)
),
'</toolchains>'
].join('\n');
}
function serializeOpeningTag(
name: string,
attributes: Record<string, string>,
depth: number
) {
const indent = ' '.repeat(depth);
const attributeEntries = Object.entries(attributes);
if (!attributeEntries.length) {
return `${indent}<${name}>`;
}
const [firstAttribute, ...restAttributes] = attributeEntries;
const lines = [
`${indent}<${name} ${formatXmlAttribute(firstAttribute)}`,
...restAttributes.map(([attributeName, value]) => {
return `${indent} ${formatXmlAttribute([attributeName, value])}`;
})
];
lines[lines.length - 1] += '>';
return lines.join('\n');
}
function serializeXmlElement(
name: string,
value: XmlElementValue,
depth: number
): string[] {
const indent = ' '.repeat(depth);
if (Array.isArray(value)) {
return value.flatMap(item => serializeXmlElement(name, item, depth));
}
if (!isXmlElementObject(value)) {
return [
`${indent}<${name}>${escapeXmlText(String(value ?? ''))}</${name}>`
];
}
const attributes = Object.fromEntries(
Object.entries(value)
.filter(([key, attributeValue]) => {
return key.startsWith('@') && typeof attributeValue === 'string';
})
.map(([key, attributeValue]) => [key, attributeValue as string])
);
const childEntries = Object.entries(value).filter(
([key]) => !key.startsWith('@') && key !== '#text'
);
const textValue = value['#text'];
if (!childEntries.length) {
if (textValue !== undefined) {
return [
`${serializeOpeningTag(name, attributes, depth)}${escapeXmlText(
String(textValue ?? '')
)}</${name}>`
];
}
return [`${serializeOpeningTag(name, attributes, depth)}</${name}>`];
}
return [
serializeOpeningTag(name, attributes, depth),
...(textValue === undefined
? []
: [`${' '.repeat(depth + 1)}${escapeXmlText(String(textValue ?? ''))}`]),
...childEntries.flatMap(([childName, childValue]) =>
serializeXmlElement(childName, childValue, depth + 1)
),
`${indent}</${name}>`
];
}
function formatXmlAttribute([name, value]: [string, string]) {
return `${name.slice(1)}="${escapeXmlAttribute(value)}"`;
}
function isToolchainsRoot(value: ExtractedToolchains['toolchains']) {
return isXmlElementObject(value);
}
function isXmlElementObject(
value: unknown
): value is Record<string, XmlElementValue> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
interface ExtractedToolchains {
toolchains: {
// root attributes such as xmlns / schemaLocation are exposed as `@`-prefixed keys
[attribute: `@${string}`]: string;
toolchain?: Toolchain[] | Toolchain;
};
toolchains: ToolchainsRoot | string;
}
interface ToolchainsRoot extends XmlElementObject {
// root attributes such as xmlns / schemaLocation are exposed as `@`-prefixed keys
[attribute: `@${string}`]: string;
toolchain?: Toolchain[];
}
// Toolchain type definition according to Maven Toolchains XSD 1.1.0
interface Toolchain {
type: string;
provides:
| {
version: string;
vendor: string;
id: string;
}
| any;
configuration: any;
provides?: XmlElementObject;
configuration?: XmlElementObject;
[customElement: string]: XmlElementValue;
}
interface XmlElementObject {
[name: string]: XmlElementValue;
}
type XmlElementValue =
| string
| number
| boolean
| null
| undefined
| XmlElementObject
| XmlElementValue[];
+12
View File
@@ -0,0 +1,12 @@
export function escapeXmlText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Use for user-controlled values written into XML attributes. Text nodes should
// use escapeXmlText so quotes remain byte-compatible with previous output.
export function escapeXmlAttribute(value: string): string {
return escapeXmlText(value).replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}