Skip to content

Commit 0203918

Browse files
committed
Update gradle properties versions
1 parent d378bdb commit 0203918

3 files changed

Lines changed: 341 additions & 34 deletions

File tree

‎.github/actions/update-project-versions/__tests__/index.test.js‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
hasVersionCheckOff,
1515
updateGradlePropertiesContent,
1616
updateBuildGradleContent,
17+
projectForArtifact,
1718
camelToKebab,
1819
artifactIdToProjectName,
1920
isChildOfRoot,
@@ -232,6 +233,45 @@ describe('updateGradlePropertiesContent', () => {
232233
});
233234
});
234235

236+
// ── projectForArtifact ────────────────────────────────────────────────────────
237+
238+
describe('projectForArtifact', () => {
239+
const versions = {
240+
'spring-boot': '4.2.0-M2',
241+
'spring-cloud-function': '5.1.0-M1',
242+
'spring-cloud-config': '5.1.0-M1',
243+
};
244+
245+
it('resolves an exact project name', () => {
246+
expect(projectForArtifact('spring-cloud-function', versions)).toBe('spring-cloud-function');
247+
});
248+
249+
// Spring Cloud publishes a project's modules at the project's own version.
250+
it('resolves a module to the project that releases it', () => {
251+
expect(projectForArtifact('spring-cloud-function-adapter-azure', versions))
252+
.toBe('spring-cloud-function');
253+
expect(projectForArtifact('spring-cloud-config-server', versions))
254+
.toBe('spring-cloud-config');
255+
expect(projectForArtifact('spring-boot-starter-web', versions)).toBe('spring-boot');
256+
});
257+
258+
// The boundary is what stops spring-cloud-configuration resolving to spring-cloud-config.
259+
it('only matches on a dash boundary', () => {
260+
expect(projectForArtifact('spring-cloud-configuration', versions)).toBeNull();
261+
});
262+
263+
it('prefers the longest match', () => {
264+
const nested = { 'spring-cloud': 'a', 'spring-cloud-function': 'b' };
265+
expect(projectForArtifact('spring-cloud-function-adapter-azure', nested))
266+
.toBe('spring-cloud-function');
267+
});
268+
269+
it('returns null for an artifact no project releases', () => {
270+
expect(projectForArtifact('some-other-lib', versions)).toBeNull();
271+
expect(projectForArtifact('spring-cloud-starter-function-web', versions)).toBeNull();
272+
});
273+
});
274+
235275
// ── updateBuildGradleContent ──────────────────────────────────────────────────
236276

237277
describe('updateBuildGradleContent', () => {
@@ -254,6 +294,107 @@ describe('updateBuildGradleContent', () => {
254294
expect(updated).toContain(`description = 'My project'`);
255295
});
256296

297+
// spring-cloud-function's Gradle samples declare these in an ext block rather than in
298+
// gradle.properties, so a release used to leave them at whatever they had been pinned at
299+
// - Boot 2.1.0.BUILD-SNAPSHOT in a train releasing against Boot 4.
300+
describe('version properties in an ext block', () => {
301+
const versions = {
302+
'spring-boot': '4.2.0-M2',
303+
'spring-cloud-function': '5.1.0-M1',
304+
};
305+
306+
it('updates a property inside buildscript { ext { } }', () => {
307+
const content = [
308+
'buildscript {',
309+
'\text {',
310+
"\t\tspringBootVersion = '2.1.0.BUILD-SNAPSHOT'",
311+
'\t}',
312+
'}',
313+
].join('\n');
314+
const { updated, updatedProperties } =
315+
updateBuildGradleContent(content, '5.1.0-M1', versions);
316+
expect(updated).toContain("springBootVersion = '4.2.0-M2'");
317+
expect(updatedProperties).toEqual(['springBootVersion: 4.2.0-M2']);
318+
});
319+
320+
it('updates a double-quoted property and preserves the quote style', () => {
321+
const content = 'ext {\n\tspringCloudFunctionVersion = "2.0.0.BUILD-SNAPSHOT"\n}';
322+
const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions);
323+
expect(updated).toContain('springCloudFunctionVersion = "5.1.0-M1"');
324+
});
325+
326+
it('preserves indentation', () => {
327+
const content = " springBootVersion = '2.1.0.BUILD-SNAPSHOT'";
328+
const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions);
329+
expect(updated).toBe(" springBootVersion = '4.2.0-M2'");
330+
});
331+
332+
it('leaves a property that resolves to no project alone', () => {
333+
const content = "ext {\n\tjavaVersion = '11'\n}";
334+
const { updated, updatedProperties } =
335+
updateBuildGradleContent(content, '5.1.0-M1', versions);
336+
expect(updated).toBe(content);
337+
expect(updatedProperties).toEqual([]);
338+
});
339+
340+
it('leaves an unquoted value alone', () => {
341+
const content = 'languageVersion = JavaLanguageVersion.of(17)';
342+
expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content);
343+
});
344+
345+
it('does not treat the project version line as a property', () => {
346+
const content = "version = '5.0.0'\n";
347+
const { updated, updatedProperties } =
348+
updateBuildGradleContent(content, '5.1.0-M1', versions);
349+
expect(updated).toBe("version = '5.1.0-M1'\n");
350+
expect(updatedProperties).toEqual([]);
351+
});
352+
353+
it('updates the project version and its properties together', () => {
354+
const content = [
355+
"version = '5.1.0-INTERNAL-SNAPSHOT'",
356+
'ext {',
357+
"\tspringCloudFunctionVersion = '2.0.0.BUILD-SNAPSHOT'",
358+
'}',
359+
].join('\n');
360+
const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions);
361+
expect(updated).toContain("version = '5.1.0-M1'");
362+
expect(updated).toContain("springCloudFunctionVersion = '5.1.0-M1'");
363+
});
364+
365+
it('rewrites an inline dependency coordinate for a released artifact', () => {
366+
const content =
367+
'implementation "org.springframework.cloud:spring-cloud-function-adapter-azure:4.1.0-SNAPSHOT"';
368+
const { updated, updatedProperties } =
369+
updateBuildGradleContent(content, '5.1.0-M1', versions);
370+
expect(updated).toContain(
371+
'"org.springframework.cloud:spring-cloud-function-adapter-azure:5.1.0-M1"');
372+
expect(updatedProperties).toEqual(['spring-cloud-function-adapter-azure: 5.1.0-M1']);
373+
});
374+
375+
// Replacing the reference with a literal would break the indirection the build uses.
376+
it('leaves an interpolated coordinate version alone', () => {
377+
const content =
378+
'mavenBom "org.springframework.cloud:spring-cloud-function-dependencies:${springCloudFunctionVersion}"';
379+
expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content);
380+
});
381+
382+
it('leaves a non-Spring group alone even when the artifact name matches', () => {
383+
const content = "implementation 'com.example:spring-cloud-function-fork:1.0.0-SNAPSHOT'";
384+
expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content);
385+
});
386+
387+
it('leaves a coordinate with no version alone', () => {
388+
const content = "implementation 'org.springframework.cloud:spring-cloud-function-context'";
389+
expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content);
390+
});
391+
392+
it('is a no-op when no versions map is supplied', () => {
393+
const content = "ext {\n\tspringBootVersion = '2.1.0.BUILD-SNAPSHOT'\n}";
394+
expect(updateBuildGradleContent(content, '5.1.0-M1').updated).toBe(content);
395+
});
396+
});
397+
257398
it('returns unchanged content when no version declaration is present', () => {
258399
const content = `group = 'org.example'\n`;
259400
expect(updateBuildGradleContent(content, '3.1.1').updated).toBe(content);

‎.github/actions/update-project-versions/dist/index.js‎

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28374,20 +28374,25 @@ async function run() {
2837428374
}
2837528375

2837628376
// ── build.gradle / build.gradle.kts ────────────────────────────────────
28377-
// Only the project version declaration is updated (version = '...' / version = "...").
28378-
// Dependency versions are managed exclusively via gradle.properties in Spring Cloud.
28377+
// The project version declaration, plus any `{prefix}Version` properties declared in an
28378+
// ext block rather than in gradle.properties.
2837928379
const buildGradleFiles = [
2838028380
...findFiles(directory, 'build.gradle'),
2838128381
...findFiles(directory, 'build.gradle.kts'),
2838228382
];
2838328383
if (buildGradleFiles.length > 0) {
2838428384
core.info(`Found ${buildGradleFiles.length} build.gradle file(s)`);
2838528385
for (const file of buildGradleFiles) {
28386-
const { changed } = updateBuildGradleVersion(file, projectVersion);
28386+
const { changed, updatedProperties } = updateBuildGradleVersion(
28387+
file,
28388+
projectVersion,
28389+
versions
28390+
);
2838728391
if (changed) {
28388-
core.info(`Updated ${path.relative(directory, file)}: version`);
28392+
core.info(`Updated ${path.relative(directory, file)}: ` +
28393+
['version', ...updatedProperties].join(', '));
2838928394
} else {
28390-
core.info(`No changes to ${path.relative(directory, file)}: version`);
28395+
core.info(`No changes to ${path.relative(directory, file)}`);
2839128396
}
2839228397
}
2839328398
}
@@ -28774,27 +28779,29 @@ function updateGradlePropertiesContent(content, projectVersion, versions) {
2877428779
// ── build.gradle / build.gradle.kts ───────────────────────────────────────
2877528780

2877628781
/**
28777-
* Updates the project version declaration in a build.gradle or build.gradle.kts file.
28778-
* Handles both single-quoted and double-quoted versions:
28782+
* Updates the project version declaration and any version properties in a build.gradle
28783+
* or build.gradle.kts file. Handles both single-quoted and double-quoted versions:
2877928784
* version = '4.1.0'
28780-
* version = "4.1.0"
28781-
*
28782-
* Only the project-level version line is updated; dependency version properties
28783-
* are managed via gradle.properties in Spring Cloud projects.
28785+
* springCloudFunctionVersion = "4.1.0"
2878428786
*
2878528787
* Exported for unit testing.
2878628788
*
2878728789
* @param {string} filePath
2878828790
* @param {string} projectVersion
28791+
* @param {Record<string, string>} versions
2878928792
*/
28790-
function updateBuildGradleVersion(filePath, projectVersion) {
28793+
function updateBuildGradleVersion(filePath, projectVersion, versions) {
2879128794
const content = fs.readFileSync(filePath, 'utf-8');
28792-
const { updated } = updateBuildGradleContent(content, projectVersion);
28795+
const { updated, updatedProperties } = updateBuildGradleContent(
28796+
content,
28797+
projectVersion,
28798+
versions
28799+
);
2879328800
const changed = updated !== content;
2879428801
if (changed) {
2879528802
fs.writeFileSync(filePath, updated, 'utf-8');
2879628803
}
28797-
return { changed };
28804+
return { changed, updatedProperties };
2879828805
}
2879928806

2880028807
/**
@@ -28803,14 +28810,64 @@ function updateBuildGradleVersion(filePath, projectVersion) {
2880328810
*
2880428811
* Exported for unit testing.
2880528812
*/
28806-
function updateBuildGradleContent(content, projectVersion) {
28813+
function updateBuildGradleContent(content, projectVersion, versions = {}) {
2880728814
// Match: version = '...' or version = "..." at the start of a line (with optional spaces)
28808-
const updated = content.replace(
28815+
const withProjectVersion = content.replace(
2880928816
/^(version\s*=\s*)(['"])([^'"]+)(['"])/m,
2881028817
(_, prefix, openQuote, _oldVersion, closeQuote) =>
2881128818
`${prefix}${openQuote}${projectVersion}${closeQuote}`
2881228819
);
28813-
return { updated };
28820+
28821+
// `{prefix}Version` assignments, resolved exactly as they are in gradle.properties:
28822+
// camelCase prefix -> kebab-case project name -> the train's version for it. These live
28823+
// in an `ext { }` or `buildscript { ext { } }` block rather than at the start of a line,
28824+
// so leading whitespace is part of the match and is preserved.
28825+
//
28826+
// Spring Cloud projects mostly declare these in gradle.properties, which is why this
28827+
// file only ever rewrote the project version. spring-cloud-function's Gradle samples
28828+
// declare them here instead, so a release left them at whatever they had been pinned at.
28829+
//
28830+
// A key that resolves to no project is left alone, which is what keeps `javaVersion` and
28831+
// similar build settings out of it.
28832+
const updatedProperties = [];
28833+
const updated = withProjectVersion.split('\n').map((line) => {
28834+
const match = line.match(
28835+
/^(\s*)([a-zA-Z][a-zA-Z0-9]*Version)(\s*=\s*)(['"])([^'"]+)(['"])(.*)$/
28836+
);
28837+
if (!match) return line;
28838+
28839+
const [, indent, key, separator, openQuote, currentValue, closeQuote, trailing] = match;
28840+
const projectName = camelToKebab(key.slice(0, -'Version'.length));
28841+
const targetVersion = versions[projectName];
28842+
if (!targetVersion || currentValue === targetVersion) return line;
28843+
28844+
updatedProperties.push(`${key}: ${targetVersion}`);
28845+
return `${indent}${key}${separator}${openQuote}${targetVersion}${closeQuote}${trailing}`;
28846+
}).join('\n');
28847+
28848+
// Inline dependency coordinates - "group:artifact:version" - for artifacts this train
28849+
// releases. Most Spring Cloud builds express these through a property or let the BOM
28850+
// manage them, which is why this file never needed it; spring-cloud-function's Azure
28851+
// sample pins one directly.
28852+
//
28853+
// Only org.springframework.* groups, so a third-party artifact that happens to share a
28854+
// prefix is never touched, and never when the version is an interpolation - rewriting
28855+
// "...:${springCloudFunctionVersion}" would replace the reference with a literal and
28856+
// break the very indirection the build is using.
28857+
const COORDINATE = /(['"])(org\.springframework\.[a-z0-9.]+):([A-Za-z0-9_.-]+):([^'"]+)\1/g;
28858+
const withCoordinates = updated.replace(
28859+
COORDINATE,
28860+
(whole, quote, groupId, artifactId, version) => {
28861+
if (version.includes('$')) return whole;
28862+
const projectName = projectForArtifact(artifactId, versions);
28863+
const targetVersion = projectName && versions[projectName];
28864+
if (!targetVersion || version === targetVersion) return whole;
28865+
updatedProperties.push(`${artifactId}: ${targetVersion}`);
28866+
return `${quote}${groupId}:${artifactId}:${targetVersion}${quote}`;
28867+
}
28868+
);
28869+
28870+
return { updated: withCoordinates, updatedProperties };
2881428871
}
2881528872

2881628873
// ── Utilities ──────────────────────────────────────────────────────────────
@@ -28861,6 +28918,31 @@ function artifactIdToProjectName(artifactId) {
2886128918
.replace(/-parent$/, '');
2886228919
}
2886328920

28921+
/**
28922+
* Resolves a Maven artifactId to the project in `versions` that releases it: an exact
28923+
* match, or the longest project name the artifactId extends on a `-` boundary.
28924+
*
28925+
* Spring Cloud publishes a project's modules at the project's own version, so
28926+
* spring-cloud-function-adapter-azure ships with spring-cloud-function and
28927+
* spring-cloud-config-server with spring-cloud-config. The boundary matters:
28928+
* spring-cloud-configuration would not resolve to spring-cloud-config.
28929+
*
28930+
* Longest wins so a project whose name extends another still resolves to itself.
28931+
* Returns null when nothing matches, which leaves the coordinate alone.
28932+
*
28933+
* Exported for unit testing.
28934+
*/
28935+
function projectForArtifact(artifactId, versions) {
28936+
if (Object.prototype.hasOwnProperty.call(versions, artifactId)) return artifactId;
28937+
let best = null;
28938+
for (const name of Object.keys(versions)) {
28939+
if (artifactId.startsWith(`${name}-`) && (best === null || name.length > best.length)) {
28940+
best = name;
28941+
}
28942+
}
28943+
return best;
28944+
}
28945+
2886428946
/**
2886528947
* Returns true when a child pom's parent is part of this project
2886628948
* (i.e. not an external parent like spring-boot-starter-parent or
@@ -28932,6 +29014,7 @@ module.exports = {
2893229014
findFiles,
2893329015
camelToKebab,
2893429016
artifactIdToProjectName,
29017+
projectForArtifact,
2893529018
isChildOfRoot,
2893629019
};
2893729020

0 commit comments

Comments
 (0)