diff --git a/.github/actions/update-commercial-repositories/README.md b/.github/actions/update-commercial-repositories/README.md
index d3f7532..1ce7e38 100644
--- a/.github/actions/update-commercial-repositories/README.md
+++ b/.github/actions/update-commercial-repositories/README.md
@@ -17,6 +17,8 @@ Any `` or `` block in a POM file, and any `rep
Each `` and `` block containing old `repo.spring.io` URLs is replaced in its entirety with the four commercial entries above, preserving the surrounding indentation. Blocks that do not reference old `repo.spring.io` URLs are left untouched.
+With `keep-oss-repositories: 'true'`, the existing old `repo.spring.io` entries (snapshots, milestones, release) are kept after the four commercial ones instead of being dropped, so a build can resolve both commercial and OSS-only artifacts. Entries that are neither commercial nor `repo.spring.io` are still dropped. If a kept entry has the same `` as one of the four commercial repositories, the action fails, naming the file, the id and the URL, and commits nothing: Maven rejects a block that declares an id twice, and the commercial repository's credentials are matched by id. This is for hotfix branches, whose dependencies mix commercial hotfix releases with OSS-only releases.
+
### Gradle files (`.gradle`, `.gradle.kts`)
Within each `repositories { }` block, individual `maven { url '...' }` entries referencing old `repo.spring.io` URLs are removed and replaced with the four commercial entries. Other entries (e.g. `mavenCentral()`, `gradlePluginPortal()`) are preserved.
@@ -74,6 +76,8 @@ After editing `src/index.js`, rebuild the bundle with `npm run build` and commit
| `repository` | yes | — | Repository to update (`org/repo-name`) |
| `branch` | yes | — | Branch to update |
| `token` | yes | — | GitHub token with `contents: write` permission on the repository |
+| `add-snapshot-repo` | no | `false` | Also add `https://repo.spring.io/snapshot` as a snapshot-only repository |
+| `keep-oss-repositories` | no | `false` | Keep existing old `repo.spring.io` entries in POM blocks after the commercial ones. No effect on Gradle files |
| `commit-message` | no | `Updating repositories to commercial Broadcom repositories` | Commit message |
| `git-user-name` | no | `Spring Builds` | Git author name |
| `git-user-email` | no | `svc.spring-builds@broadcom.com` | Git author email |
diff --git a/.github/actions/update-commercial-repositories/__tests__/index.test.js b/.github/actions/update-commercial-repositories/__tests__/index.test.js
index 60da428..6bd5b10 100644
--- a/.github/actions/update-commercial-repositories/__tests__/index.test.js
+++ b/.github/actions/update-commercial-repositories/__tests__/index.test.js
@@ -117,6 +117,109 @@ describe('updatePom', () => {
expect(result).toContain('');
COMMERCIAL_REPOS.forEach(r => expect(result).toContain(r.url));
});
+
+ describe('keepOssRepositories', () => {
+ const mixed = `
+
+
+ spring-snapshots
+ https://repo.spring.io/libs-snapshot-local
+
+ true
+
+
+
+ spring-milestones
+ https://repo.spring.io/libs-milestone-local
+
+
+ internal-mirror
+ https://nexus.example.com/maven
+
+
+
+
+ spring-snapshots
+ https://repo.spring.io/libs-snapshot-local
+
+
+`;
+
+ test('is off by default: OSS repo.spring.io entries are dropped', () => {
+ const result = updatePom(mixed);
+ expect(result).not.toContain('libs-snapshot-local');
+ expect(result).not.toContain('libs-milestone-local');
+ });
+
+ test('keeps the old repo.spring.io entries in both block types', () => {
+ const result = updatePom(mixed, new Set(), false, true);
+ expect(result.match(/libs-snapshot-local/g)).toHaveLength(2);
+ expect(result.match(/libs-milestone-local/g)).toHaveLength(1);
+ COMMERCIAL_REPOS.forEach(r => expect(result).toContain(r.url));
+ });
+
+ test('puts the commercial repos before the kept OSS ones', () => {
+ const result = updatePom(mixed, new Set(), false, true);
+ const firstBlock = result.slice(0, result.indexOf(''));
+ expect(firstBlock.indexOf('spring-enterprise-maven-prod-local'))
+ .toBeLessThan(firstBlock.indexOf('libs-snapshot-local'));
+ });
+
+ test('does not keep entries that are not repo.spring.io', () => {
+ const result = updatePom(mixed, new Set(), false, true);
+ expect(result).not.toContain('nexus.example.com');
+ });
+
+ test('is idempotent', () => {
+ const once = updatePom(mixed, new Set(), false, true);
+ expect(updatePom(once, new Set(), false, true)).toBe(once);
+ });
+
+ test('does not declare spring-snapshots twice alongside includeSnapshotRepo', () => {
+ const result = updatePom(mixed, new Set(), true, true);
+ const firstBlock = result.slice(0, result.indexOf(''));
+ expect(firstBlock.match(/spring-snapshots<\/id>/g)).toHaveLength(1);
+ expect(firstBlock).toContain('https://repo.spring.io/snapshot');
+ expect(firstBlock).not.toContain('libs-snapshot-local');
+ });
+
+ describe('id collision with a commercial repository', () => {
+ const clash = `
+
+
+ spring-commercial-release
+ https://repo.spring.io/release
+
+
+`;
+
+ test('throws, naming the id, the block and the URL', () => {
+ expect(() => updatePom(clash, new Set(), false, true))
+ .toThrow(/ entry 'spring-commercial-release' \(https:\/\/repo\.spring\.io\/release\)/);
+ });
+
+ test('throws for a pluginRepository too', () => {
+ const plugin = clash
+ .replace(/repositories>/g, 'pluginRepositories>')
+ .replace(/repository>/g, 'pluginRepository>');
+ expect(() => updatePom(plugin, new Set(), false, true)).toThrow(/ entry/);
+ });
+
+ test('does not throw when the OSS entries are being dropped anyway', () => {
+ expect(() => updatePom(clash)).not.toThrow();
+ });
+
+ test('does not throw for the same id on a non-OSS URL, which is dropped', () => {
+ const nonOss = clash.replace('https://repo.spring.io/release', 'https://nexus.example.com/maven');
+ expect(() => updatePom(nonOss, new Set(), false, true)).not.toThrow();
+ });
+ });
+
+ test('does not duplicate an entry that is also in preserveIds', () => {
+ const result = updatePom(mixed, new Set(['spring-milestones']), false, true);
+ expect(result.match(/spring-milestones<\/id>/g)).toHaveLength(1);
+ });
+ });
});
// ── findBraceEnd ──────────────────────────────────────────────────────────────
@@ -244,6 +347,22 @@ describe('processFile', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
+ test('reports the file and leaves it unwritten when a kept OSS id collides with a commercial one', () => {
+ const content = `
+
+
+ spring-commercial-snapshot
+ https://repo.spring.io/libs-snapshot-local
+
+
+`;
+ const filePath = path.join(tmpDir, 'pom.xml');
+ fs.writeFileSync(filePath, content);
+ expect(() => processFile(filePath, new Set(), new Set(), false, false, true))
+ .toThrow(new RegExp(`${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: entry 'spring-commercial-snapshot'`));
+ expect(fs.readFileSync(filePath, 'utf-8')).toBe(content);
+ });
+
test('updates a pom.xml with old spring.io repos', () => {
const content = `
diff --git a/.github/actions/update-commercial-repositories/action.yml b/.github/actions/update-commercial-repositories/action.yml
index 896a14e..354fec6 100644
--- a/.github/actions/update-commercial-repositories/action.yml
+++ b/.github/actions/update-commercial-repositories/action.yml
@@ -16,6 +16,10 @@ inputs:
description: 'When true, also add https://repo.spring.io/snapshot as a snapshot-only repository (no releases). Use when creating an OSS release branch that needs snapshot dependencies.'
required: false
default: 'false'
+ keep-oss-repositories:
+ description: 'When true, POM / blocks keep their existing old repo.spring.io entries (snapshots, milestones, release) after the commercial ones, so a build can resolve both commercial and OSS-only artifacts. Fails if a kept entry has the same id as a commercial repository. Has no effect on Gradle files. Use for hotfix branches, whose dependencies mix commercial hotfix releases with OSS-only releases.'
+ required: false
+ default: 'false'
commit-message:
description: 'Commit message for the repository updates'
required: false
diff --git a/.github/actions/update-commercial-repositories/dist/index.js b/.github/actions/update-commercial-repositories/dist/index.js
index f922349..6a9f13b 100644
--- a/.github/actions/update-commercial-repositories/dist/index.js
+++ b/.github/actions/update-commercial-repositories/dist/index.js
@@ -25689,6 +25689,7 @@ const COMMERCIAL_REPOS = [
];
const COMMERCIAL_URLS = new Set(COMMERCIAL_REPOS.map(r => r.url));
+const COMMERCIAL_IDS = new Set(COMMERCIAL_REPOS.map(r => r.id));
const SPRING_SNAPSHOT_REPO = {
id: 'spring-snapshots',
@@ -25767,20 +25768,37 @@ function buildPomBlock(tag, indent, preservedEntries = [], includeSnapshotRepo =
* Updates and blocks in POM content.
* preserveIds is a Set of repository IDs to keep alongside the commercial repos.
* includeSnapshotRepo adds https://repo.spring.io/snapshot as a snapshot-only entry.
+ * keepOssRepositories keeps every existing entry whose URL is an old (non-commercial)
+ * repo.spring.io one, after the commercial repos, so a build can resolve both commercial
+ * and OSS-only artifacts. Skipped for an entry with id spring-snapshots when
+ * includeSnapshotRepo is set, which would otherwise be declared twice.
+ * Throws if a kept entry has the same id as one of the commercial repos: Maven rejects a
+ * block that declares an id twice, and credentials are matched by id, so the clash can
+ * only be resolved by a person.
* Exported for unit testing.
*/
-function updatePom(content, preserveIds = new Set(), includeSnapshotRepo = false) {
+function updatePom(content, preserveIds = new Set(), includeSnapshotRepo = false, keepOssRepositories = false) {
const replaceBlock = (match, indent, body, tag) => {
if (!OLD_SPRING_IO_RE.test(match)) return match;
if ([...COMMERCIAL_URLS].some(u => match.includes(u))) return match;
let preserved = [];
- if (preserveIds.size > 0) {
+ if (preserveIds.size > 0 || keepOssRepositories) {
const entryTag = tag === 'repositories' ? 'repository' : 'pluginRepository';
const entryRe = new RegExp(`[ \\t]*<${entryTag}>[\\s\\S]*?<\\/${entryTag}>`, 'g');
for (const m of match.matchAll(entryRe)) {
- const idMatch = m[0].match(/(.*?)<\/id>/);
- if (idMatch && preserveIds.has(idMatch[1].trim())) {
+ const id = (m[0].match(/(.*?)<\/id>/) || [])[1]?.trim();
+ const url = (m[0].match(/(.*?)<\/url>/) || [])[1]?.trim();
+ const isOss = keepOssRepositories && url && OLD_SPRING_IO_RE.test(url)
+ && !(includeSnapshotRepo && id === SPRING_SNAPSHOT_REPO.id);
+ if (isOss && id && COMMERCIAL_IDS.has(id)) {
+ throw new Error(
+ `<${tag}> entry '${id}' (${url}) uses the same id as a commercial repository. ` +
+ `Keeping both would declare the id twice, which Maven rejects, and the credentials ` +
+ `for the commercial repository would apply to the wrong URL. Rename the entry.`
+ );
+ }
+ if ((id && preserveIds.has(id)) || isOss) {
preserved.push(m[0].trim());
}
}
@@ -25978,7 +25996,7 @@ function updateGradle(content, isKotlin, withCredentials = false, includeSnapsho
* Returns true when the file was modified.
* Exported for unit testing.
*/
-function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(), withCredentials = false, includeSnapshotRepo = false) {
+function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(), withCredentials = false, includeSnapshotRepo = false, keepOssRepositories = false) {
const ext = path.extname(filePath).toLowerCase();
const isKts = filePath.endsWith('.gradle.kts');
const isGradle = ext === '.gradle' || isKts;
@@ -26001,7 +26019,11 @@ function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(),
let changed = false;
if (isXml) {
- newContent = updatePom(content, preserveIds, includeSnapshotRepo);
+ try {
+ newContent = updatePom(content, preserveIds, includeSnapshotRepo, keepOssRepositories);
+ } catch (e) {
+ throw new Error(`${filePath}: ${e.message}`);
+ }
changed = newContent !== content;
} else {
[newContent, changed] = updateGradle(content, isKts, withCredentials, includeSnapshotRepo);
@@ -26031,7 +26053,7 @@ const PROJECT_OVERRIDES = {
* POM: replaces repo.spring.io repos while preserving the spring-milestones entry.
* Gradle: replaces with named maven {} blocks that include credentials.
*/
-function springCloudContractUpdate(root) {
+function springCloudContractUpdate(root, { keepOssRepositories = false } = {}) {
const updated = new Set();
const preserveIds = new Set(['spring-milestones']);
@@ -26057,7 +26079,11 @@ function springCloudContractUpdate(root) {
let changed = false;
if (isXml) {
- newContent = updatePom(content, preserveIds);
+ try {
+ newContent = updatePom(content, preserveIds, false, keepOssRepositories);
+ } catch (e) {
+ throw new Error(`${filePath}: ${e.message}`);
+ }
changed = newContent !== content;
} else {
[newContent, changed] = updateGradle(content, isKts, true);
@@ -26101,7 +26127,8 @@ async function run() {
const branch = core.getInput('branch', { required: true });
const token = core.getInput('token', { required: true });
const addSnapshotRepo = (core.getInput('add-snapshot-repo') || 'false').toLowerCase() === 'true';
- const commitMsg = core.getInput('commit-message') || 'Updating repositories to commercial Broadcom repositories';
+ const keepOssRepositories = (core.getInput('keep-oss-repositories') || 'false').toLowerCase() === 'true';
+ const commitMsg = core.getInput('commit-message') || 'Updating repositories to commercial Broadcom repositories';
const gitName = core.getInput('git-user-name') || 'Spring Builds';
const gitEmail = core.getInput('git-user-email') || 'svc.spring-builds@broadcom.com';
core.setSecret(token);
@@ -26110,6 +26137,7 @@ async function run() {
core.info(`Repository: ${repository}`);
core.info(`Branch: ${branch}`);
core.info(`Add snapshot repo: ${addSnapshotRepo}`);
+ core.info(`Keep OSS repos: ${keepOssRepositories}`);
core.info('');
const repoDir = path.join(os.tmpdir(), '_repos_repo');
@@ -26132,14 +26160,14 @@ async function run() {
if (override) {
core.info(`Running project-specific override for: ${repoName}`);
- updatedByOverride = override(repoDir);
+ updatedByOverride = override(repoDir, { keepOssRepositories });
core.info('');
}
core.info('Scanning for old repo.spring.io repository references...');
let changed = 0;
walkDir(repoDir, (filePath) => {
- if (processFile(filePath, updatedByOverride, new Set(), false, addSnapshotRepo)) changed++;
+ if (processFile(filePath, updatedByOverride, new Set(), false, addSnapshotRepo, keepOssRepositories)) changed++;
});
core.info(`\nUpdated repository references in ${changed} file(s).`);
diff --git a/.github/actions/update-commercial-repositories/src/index.js b/.github/actions/update-commercial-repositories/src/index.js
index 1a917a9..13875c1 100644
--- a/.github/actions/update-commercial-repositories/src/index.js
+++ b/.github/actions/update-commercial-repositories/src/index.js
@@ -40,6 +40,7 @@ const COMMERCIAL_REPOS = [
];
const COMMERCIAL_URLS = new Set(COMMERCIAL_REPOS.map(r => r.url));
+const COMMERCIAL_IDS = new Set(COMMERCIAL_REPOS.map(r => r.id));
const SPRING_SNAPSHOT_REPO = {
id: 'spring-snapshots',
@@ -118,20 +119,37 @@ function buildPomBlock(tag, indent, preservedEntries = [], includeSnapshotRepo =
* Updates and blocks in POM content.
* preserveIds is a Set of repository IDs to keep alongside the commercial repos.
* includeSnapshotRepo adds https://repo.spring.io/snapshot as a snapshot-only entry.
+ * keepOssRepositories keeps every existing entry whose URL is an old (non-commercial)
+ * repo.spring.io one, after the commercial repos, so a build can resolve both commercial
+ * and OSS-only artifacts. Skipped for an entry with id spring-snapshots when
+ * includeSnapshotRepo is set, which would otherwise be declared twice.
+ * Throws if a kept entry has the same id as one of the commercial repos: Maven rejects a
+ * block that declares an id twice, and credentials are matched by id, so the clash can
+ * only be resolved by a person.
* Exported for unit testing.
*/
-function updatePom(content, preserveIds = new Set(), includeSnapshotRepo = false) {
+function updatePom(content, preserveIds = new Set(), includeSnapshotRepo = false, keepOssRepositories = false) {
const replaceBlock = (match, indent, body, tag) => {
if (!OLD_SPRING_IO_RE.test(match)) return match;
if ([...COMMERCIAL_URLS].some(u => match.includes(u))) return match;
let preserved = [];
- if (preserveIds.size > 0) {
+ if (preserveIds.size > 0 || keepOssRepositories) {
const entryTag = tag === 'repositories' ? 'repository' : 'pluginRepository';
const entryRe = new RegExp(`[ \\t]*<${entryTag}>[\\s\\S]*?<\\/${entryTag}>`, 'g');
for (const m of match.matchAll(entryRe)) {
- const idMatch = m[0].match(/(.*?)<\/id>/);
- if (idMatch && preserveIds.has(idMatch[1].trim())) {
+ const id = (m[0].match(/(.*?)<\/id>/) || [])[1]?.trim();
+ const url = (m[0].match(/(.*?)<\/url>/) || [])[1]?.trim();
+ const isOss = keepOssRepositories && url && OLD_SPRING_IO_RE.test(url)
+ && !(includeSnapshotRepo && id === SPRING_SNAPSHOT_REPO.id);
+ if (isOss && id && COMMERCIAL_IDS.has(id)) {
+ throw new Error(
+ `<${tag}> entry '${id}' (${url}) uses the same id as a commercial repository. ` +
+ `Keeping both would declare the id twice, which Maven rejects, and the credentials ` +
+ `for the commercial repository would apply to the wrong URL. Rename the entry.`
+ );
+ }
+ if ((id && preserveIds.has(id)) || isOss) {
preserved.push(m[0].trim());
}
}
@@ -329,7 +347,7 @@ function updateGradle(content, isKotlin, withCredentials = false, includeSnapsho
* Returns true when the file was modified.
* Exported for unit testing.
*/
-function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(), withCredentials = false, includeSnapshotRepo = false) {
+function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(), withCredentials = false, includeSnapshotRepo = false, keepOssRepositories = false) {
const ext = path.extname(filePath).toLowerCase();
const isKts = filePath.endsWith('.gradle.kts');
const isGradle = ext === '.gradle' || isKts;
@@ -352,7 +370,11 @@ function processFile(filePath, updatedSet = new Set(), preserveIds = new Set(),
let changed = false;
if (isXml) {
- newContent = updatePom(content, preserveIds, includeSnapshotRepo);
+ try {
+ newContent = updatePom(content, preserveIds, includeSnapshotRepo, keepOssRepositories);
+ } catch (e) {
+ throw new Error(`${filePath}: ${e.message}`);
+ }
changed = newContent !== content;
} else {
[newContent, changed] = updateGradle(content, isKts, withCredentials, includeSnapshotRepo);
@@ -382,7 +404,7 @@ const PROJECT_OVERRIDES = {
* POM: replaces repo.spring.io repos while preserving the spring-milestones entry.
* Gradle: replaces with named maven {} blocks that include credentials.
*/
-function springCloudContractUpdate(root) {
+function springCloudContractUpdate(root, { keepOssRepositories = false } = {}) {
const updated = new Set();
const preserveIds = new Set(['spring-milestones']);
@@ -408,7 +430,11 @@ function springCloudContractUpdate(root) {
let changed = false;
if (isXml) {
- newContent = updatePom(content, preserveIds);
+ try {
+ newContent = updatePom(content, preserveIds, false, keepOssRepositories);
+ } catch (e) {
+ throw new Error(`${filePath}: ${e.message}`);
+ }
changed = newContent !== content;
} else {
[newContent, changed] = updateGradle(content, isKts, true);
@@ -452,7 +478,8 @@ async function run() {
const branch = core.getInput('branch', { required: true });
const token = core.getInput('token', { required: true });
const addSnapshotRepo = (core.getInput('add-snapshot-repo') || 'false').toLowerCase() === 'true';
- const commitMsg = core.getInput('commit-message') || 'Updating repositories to commercial Broadcom repositories';
+ const keepOssRepositories = (core.getInput('keep-oss-repositories') || 'false').toLowerCase() === 'true';
+ const commitMsg = core.getInput('commit-message') || 'Updating repositories to commercial Broadcom repositories';
const gitName = core.getInput('git-user-name') || 'Spring Builds';
const gitEmail = core.getInput('git-user-email') || 'svc.spring-builds@broadcom.com';
core.setSecret(token);
@@ -461,6 +488,7 @@ async function run() {
core.info(`Repository: ${repository}`);
core.info(`Branch: ${branch}`);
core.info(`Add snapshot repo: ${addSnapshotRepo}`);
+ core.info(`Keep OSS repos: ${keepOssRepositories}`);
core.info('');
const repoDir = path.join(os.tmpdir(), '_repos_repo');
@@ -483,14 +511,14 @@ async function run() {
if (override) {
core.info(`Running project-specific override for: ${repoName}`);
- updatedByOverride = override(repoDir);
+ updatedByOverride = override(repoDir, { keepOssRepositories });
core.info('');
}
core.info('Scanning for old repo.spring.io repository references...');
let changed = 0;
walkDir(repoDir, (filePath) => {
- if (processFile(filePath, updatedByOverride, new Set(), false, addSnapshotRepo)) changed++;
+ if (processFile(filePath, updatedByOverride, new Set(), false, addSnapshotRepo, keepOssRepositories)) changed++;
});
core.info(`\nUpdated repository references in ${changed} file(s).`);
diff --git a/.github/scripts/__tests__/verify-commercial-settings.test.js b/.github/scripts/__tests__/verify-commercial-settings.test.js
new file mode 100644
index 0000000..823cbf8
--- /dev/null
+++ b/.github/scripts/__tests__/verify-commercial-settings.test.js
@@ -0,0 +1,134 @@
+'use strict';
+
+const { problems } = require('../verify-commercial-settings');
+
+const USW1 = 'https://usw1.packages.broadcom.com/artifactory/spring-enterprise-maven-prod-local';
+const SERVER = `spring-commercial-release
+ \${env.COMMERCIAL_ARTIFACTORY_USERNAME}
+ \${env.COMMERCIAL_ARTIFACTORY_PASSWORD}`;
+
+const repo = (tag, { id = 'spring-commercial-release', url = USW1, releases = '' } = {}) =>
+ `<${tag}>${id}${url}${releases}${tag}>`;
+
+const settings = ({
+ servers = SERVER,
+ profileId = 'spring',
+ repos = repo('repository'),
+ pluginRepos = repo('pluginRepository'),
+ extra = '',
+ ns = '',
+} = {}) => `
+
+ ${extra}
+ ${servers}
+ ${profileId}
+ ${repos}
+ ${pluginRepos}
+
+`;
+
+describe('problems', () => {
+ it('accepts a complete file', () => {
+ expect(problems(settings())).toEqual([]);
+ });
+
+ it('accepts a namespaced file', () => {
+ expect(problems(settings({ ns: ' xmlns="http://maven.apache.org/SETTINGS/1.0.0"' }))).toEqual([]);
+ });
+
+ it('accepts a root element with several attributes, as Maven settings files have', () => {
+ const ns = ' xmlns="http://maven.apache.org/SETTINGS/1.0.0"\n xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
+ + ' xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"';
+ expect(problems(settings({ ns }))).toEqual([]);
+ });
+
+ it('accepts other repositories alongside the commercial one', () => {
+ const other = repo('repository', { id: 'spring-snapshots', url: 'https://repo.spring.io/libs-snapshot-local' });
+ expect(problems(settings({ repos: other + repo('repository') }))).toEqual([]);
+ });
+
+ it('accepts the profile id appearing after its repositories', () => {
+ const xml = settings().replace(
+ /spring<\/id>([\s\S]*?)<\/profile>/, '$1spring');
+ expect(problems(xml)).toEqual([]);
+ });
+
+ describe('a text search would be satisfied, a structural check is not', () => {
+ it('rejects the URL and id appearing only in a comment', () => {
+ const oss = settings({
+ servers: '',
+ repos: repo('repository', { id: 'spring-snapshots', url: 'https://repo.spring.io/x' }),
+ pluginRepos: repo('pluginRepository', { id: 'spring-snapshots', url: 'https://repo.spring.io/x' }),
+ extra: ``,
+ });
+ expect(problems(oss)).toHaveLength(3);
+ });
+
+ it('rejects a server with no repository to use it', () => {
+ const found = problems(settings({ repos: '', pluginRepos: '' }));
+ expect(found).toHaveLength(2);
+ found.forEach(p => expect(p).toMatch(/inside ));
+ });
+
+ it('rejects a commercial repository that is only in a comment', () => {
+ const commented = ``;
+ expect(problems(settings({ repos: commented }))).toHaveLength(1);
+ });
+ });
+
+ it('rejects repositories without pluginRepositories', () => {
+ const found = problems(settings({ pluginRepos: '' }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(//);
+ });
+
+ it('rejects pluginRepositories without repositories', () => {
+ const found = problems(settings({ repos: '' }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(//);
+ });
+
+ it('rejects the wrong URL host', () => {
+ expect(problems(settings({ repos: repo('repository', { url: 'https://repo.spring.io/release' }) })))
+ .toHaveLength(1);
+ });
+
+ it('rejects the wrong repository id', () => {
+ expect(problems(settings({ repos: repo('repository', { id: 'something-else' }) }))).toHaveLength(1);
+ });
+
+ it('does not count repositories in a different profile', () => {
+ const found = problems(settings({ profileId: 'ide' }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(/no with id 'spring'/);
+ });
+
+ it('rejects releases disabled on the commercial release repository', () => {
+ const disabled = repo('repository', { releases: 'false' });
+ const found = problems(settings({ repos: disabled }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(/releases disabled/);
+ });
+
+ it('rejects a missing server', () => {
+ const found = problems(settings({ servers: '' }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(/without credentials/);
+ });
+
+ it('rejects a server with no credentials', () => {
+ const found = problems(settings({ servers: 'spring-commercial-release' }));
+ expect(found).toHaveLength(1);
+ expect(found[0]).toMatch(/no username and password/);
+ });
+
+ it('does not count a server for another id', () => {
+ const other = SERVER.replace('spring-commercial-release', 'repo.spring.io');
+ expect(problems(settings({ servers: other }))).toHaveLength(1);
+ });
+
+ it('still rejects a truncated file, as missing what it looks for', () => {
+ const found = problems('');
+ expect(found.length).toBeGreaterThan(0);
+ });
+});
diff --git a/.github/scripts/verify-commercial-settings.js b/.github/scripts/verify-commercial-settings.js
new file mode 100644
index 0000000..59f796e
--- /dev/null
+++ b/.github/scripts/verify-commercial-settings.js
@@ -0,0 +1,95 @@
+'use strict';
+
+// Checks that a .settings.xml can resolve commercial hotfix artifacts.
+//
+// The docs build of a hotfix tag runs Maven with `--settings .settings.xml -P spring` (forced
+// by .mvn/maven.config), so that file alone has to supply the commercial release repository
+// and the credentials for it. Comments are stripped and the elements are read structurally, so
+// a comment, or a with no matching repository, cannot satisfy it - which a search for
+// the URL or the id in the text would.
+//
+// Requires, in the `spring` profile, the commercial release repository in both
+// and (releases not disabled), and a with the same id and a
+// username and password.
+//
+// Node has no XML parser and these modules take no dependencies (workflows run them with no
+// install step), so this is regular-expression based, like the repo's XML-editing actions. It
+// does not check well-formedness: Maven fails loudly on a malformed file, and a damaged one
+// is still rejected here, as missing the profile, repository or server it looks for.
+
+const fs = require('fs');
+
+const PROFILE_ID = 'spring';
+const REPO_ID = 'spring-commercial-release';
+const REPO_URL_PREFIX = 'https://usw1.packages.broadcom.com/';
+const BLOCKS = [['repositories', 'repository'], ['pluginRepositories', 'pluginRepository']];
+
+const stripComments = xml => xml.replace(//g, '');
+
+const blocks = (xml, tag) =>
+ [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)${tag}>`, 'g'))].map(m => m[1]);
+
+const field = (xml, tag) => {
+ const m = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)${tag}>`));
+ return m ? m[1].trim() : '';
+};
+
+const without = (xml, tags) =>
+ tags.reduce((acc, tag) => acc.replace(new RegExp(`<${tag}>[\\s\\S]*?${tag}>`, 'g'), ''), xml);
+
+// A profile's own , not one belonging to a repository nested inside it.
+const profileId = profile =>
+ field(without(profile, ['repositories', 'pluginRepositories', 'activation', 'properties']), 'id');
+
+
+function problems(xml) {
+ const clean = stripComments(xml);
+
+ const found = [];
+
+ const profiles = blocks(clean, 'profile').filter(p => profileId(p) === PROFILE_ID);
+ if (profiles.length === 0) {
+ found.push(`no with id '${PROFILE_ID}'`);
+ } else {
+ for (const [block, entry] of BLOCKS) {
+ const entries = profiles
+ .flatMap(p => blocks(p, block))
+ .flatMap(b => blocks(b, entry))
+ .filter(e => field(e, 'id') === REPO_ID && field(e, 'url').startsWith(REPO_URL_PREFIX));
+ if (entries.length === 0) {
+ found.push(`profile '${PROFILE_ID}' has no <${entry}> with id '${REPO_ID}' and a url `
+ + `starting ${REPO_URL_PREFIX} inside <${block}>`);
+ }
+ for (const e of entries) {
+ if (blocks(e, 'releases').some(r => field(r, 'enabled').toLowerCase() === 'false')) {
+ found.push(`<${entry}> '${REPO_ID}' in <${block}> has releases disabled, but it is `
+ + 'the repository that holds the hotfix releases');
+ }
+ }
+ }
+ }
+
+ const servers = blocks(clean, 'server').filter(s => field(s, 'id') === REPO_ID);
+ if (servers.length === 0) {
+ found.push(`no with id '${REPO_ID}', so the repository would be requested without credentials`);
+ } else if (!servers.some(s => field(s, 'username') && field(s, 'password'))) {
+ found.push(` '${REPO_ID}' has no username and password`);
+ }
+
+ return found;
+}
+
+module.exports = { problems };
+
+// CLI: verify-commercial-settings.js
+// Exit status 0 when satisfied, 1 with the problems on stderr otherwise.
+if (require.main === module) {
+ const file = process.argv[2];
+ if (!file) {
+ process.stderr.write('usage: verify-commercial-settings.js \n');
+ process.exit(2);
+ }
+ const found = problems(fs.readFileSync(file, 'utf-8'));
+ found.forEach(p => process.stderr.write(` - ${p}\n`));
+ process.exit(found.length ? 1 : 0);
+}
diff --git a/.github/workflows/README-create-hotfix-branch.md b/.github/workflows/README-create-hotfix-branch.md
index 3f2eaea..28357ab 100644
--- a/.github/workflows/README-create-hotfix-branch.md
+++ b/.github/workflows/README-create-hotfix-branch.md
@@ -9,7 +9,7 @@ The hotfix version itself is never passed in directly — it's looked up from th
1. **Looks up the hotfix version and derives names** — detects this project's artifact ID from the OSS repo's root `pom.xml`, then reads `releaser.fixed-versions[]` from the `release_train_version`'s properties file in `spring-cloud-release-commercial@jenkins-releaser-config` to get the hotfix version (e.g. `5.0.4.1`). From it: the commercial branch is `release/` (e.g. `release/5.0.4.1`), the internal branch is `..x-internal` (e.g. `5.0.x-internal`), and (when `use_tag` is set) the OSS tag is `v..` (e.g. `v5.0.4`). The commercial repo is always the OSS repo with `-commercial` appended.
2. **Validates the `-internal` branch** (default path only) — fails before making any changes if the `-internal` branch's `pom.xml` version doesn't exactly match `..-INTERNAL-SNAPSHOT` — i.e. if the `-internal` branch is out of date.
3. **Creates the branch**:
- - Default (`use_tag: false`): forks `release/` from the tip of the `-internal` branch via the GitHub API, preserving its full git history. Then adds a retargeted `ci-release.yml`/`release-ci-settings.xml` (via `add-commercial-release-files`, which already point the build at the commercial deploy target — no `.settings.xml` copy, workflow rewrite, or `pom.xml` distribution-management edit needed), updates license headers (and `checkstyle-header.txt`), and registers the branch in `config/projects.json` (a hotfix branch can fail its own CI independently of `-internal`, so it needs its own scheduled CI entry — this entry is removed automatically once the hotfix's release is finalized).
+ - Default (`use_tag: false`): forks `release/` from the tip of the `-internal` branch via the GitHub API, preserving its full git history. Then restores `.settings.xml` from the repo's default branch (`copy-settings-xml`) and verifies it — the file must exist, and its `spring` profile must declare the usw1 `spring-commercial-release` repository in both `` and ``, with a `` carrying credentials for it (parsed, not grepped; the job fails otherwise) — the docs build of the hotfix tag runs Maven with `--settings .settings.xml` (forced by `.mvn/maven.config`), not `release-ci-settings.xml`, and must resolve the commercial-only hotfix parent POMs. It adds a retargeted `ci-release.yml`/`release-ci-settings.xml` (via `add-commercial-release-files`, which already point the build at the commercial deploy target — so no workflow rewrite or `pom.xml` distribution-management edit is needed), updates license headers (and `checkstyle-header.txt`), points the pom/Gradle `` blocks at the commercial repositories (`update-commercial-repositories`; the pom blocks keep their existing `repo.spring.io` entries after the commercial ones, since a hotfix mixes commercial hotfix releases with OSS-only releases), and registers the branch in `config/projects.json` (a hotfix branch can fail its own CI independently of `-internal`, so it needs its own scheduled CI entry — this entry is removed automatically once the hotfix's release is finalized).
- `use_tag: true`: delegates to [`initialize-commercial-branch`](initialize-commercial-branch.yml), which creates an orphan branch from the OSS tag and runs the full suite of commercial setup actions: copy `.settings.xml`, update CI/PR workflows, update licence headers, replace OSS repositories with commercial Broadcom repositories, update distribution management, and update `config/projects.json`.
4. **Creates a milestone** — creates a milestone in the commercial repo for the hotfix version if one does not already exist.
5. **Stamps the project version** — updates the project version in `pom.xml`, `gradle.properties`, and `build.gradle` files to `-SNAPSHOT` (e.g. `5.0.4.1-SNAPSHOT`), and updates dependency versions from the release train. `versions` can supply additional dependency version overrides on top.
diff --git a/.github/workflows/create-hotfix-release-branch.yml b/.github/workflows/create-hotfix-release-branch.yml
index 4def7c0..87b3d8a 100644
--- a/.github/workflows/create-hotfix-release-branch.yml
+++ b/.github/workflows/create-hotfix-release-branch.yml
@@ -281,13 +281,18 @@ jobs:
# history (so the hotfix branch can later be rebased onto an updated -internal branch),
# then layers on only what -internal doesn't already have: a retargeted ci-release.yml
# (mirrors create-oss-release-branch.yml's release/-from-internal flow —
- # distribution-management/workflow-rewrite/settings.xml are redundant here, since
+ # distribution-management and the ci.yml/pr.yml rewrite are redundant here, since
# ci-release.yml + release-ci-settings.xml already point the build at the commercial
# deploy target), Broadcom license headers (and checkstyle-header.txt in the same
# commit), and a config/projects.json registration — a hotfix branch carries real changes
# on top of -internal and can fail its own CI independently of it, unlike a GA
# release/, so it needs its own scheduled CI entry. The entry is removed again
- # by spring-release-train-project-ready once the hotfix ships. A no-op when use_tag is set.
+ # by spring-release-train-project-ready once the hotfix ships.
+ #
+ # .settings.xml and the pom/Gradle repository blocks are restored too, which a GA
+ # release/ doesn't need: the docs build of the hotfix tag runs Maven with
+ # --settings .settings.xml (forced by .mvn/maven.config), not release-ci-settings.xml, and
+ # must resolve the commercial-only hotfix parent POMs from usw1. A no-op when use_tag is set.
create-internal-release-branch:
needs: [derive, validate-internal-branch]
if: ${{ !inputs.use_tag }}
@@ -325,6 +330,50 @@ jobs:
echo "base_sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
echo "Branch ${COMMERCIAL_BRANCH} created."
+ # source-branch is left to default to the repo's default branch, as the original
+ # (orphan) flow did, so the hotfix gets the same commercial servers and repositories.
+ - name: Copy settings.xml
+ uses: ./.github/actions/copy-settings-xml
+ with:
+ repository: ${{ needs.derive.outputs.commercial_repo }}
+ target-branch: ${{ needs.derive.outputs.commercial_branch }}
+ token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
+
+ # copy-settings-xml silently does nothing when its source branch has no .settings.xml,
+ # and the default branch is only a good source while it is a hand-initialised commercial
+ # branch. If it ever became an OSS-mirroring -internal branch, or lost the file, the
+ # branch would be created without the one file the docs build of the tag resolves its
+ # dependencies through. So the file must exist, and must actually configure the commercial
+ # release repository and its credentials (parsed, not grepped: a comment or a lone
+ # must not satisfy it).
+ - name: Verify settings.xml carries commercial config
+ env:
+ GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
+ run: |
+ COMMERCIAL_REPO="${{ needs.derive.outputs.commercial_repo }}"
+ COMMERCIAL_BRANCH="${{ needs.derive.outputs.commercial_branch }}"
+
+ if ! CONTENT=$(gh api "repos/${COMMERCIAL_REPO}/contents/.settings.xml" \
+ -X GET -f ref="$COMMERCIAL_BRANCH" --jq '.content' 2>/tmp/settings-err); then
+ cat /tmp/settings-err
+ echo "ERROR: could not read .settings.xml from ${COMMERCIAL_BRANCH} in ${COMMERCIAL_REPO}."
+ if grep -q "404" /tmp/settings-err; then
+ echo "The file does not exist, so copy-settings-xml had nothing to copy: the default"
+ echo "branch of ${COMMERCIAL_REPO} has no .settings.xml. The docs build of the hotfix tag"
+ echo "needs it to resolve the commercial hotfix dependencies."
+ fi
+ exit 1
+ fi
+
+ echo "$CONTENT" | base64 -d > /tmp/settings.xml
+ if ! node .github/scripts/verify-commercial-settings.js /tmp/settings.xml; then
+ echo "ERROR: .settings.xml on ${COMMERCIAL_BRANCH} does not configure the commercial release"
+ echo "repository. The default branch of ${COMMERCIAL_REPO} is not a usable source for it; the"
+ echo "docs build of the hotfix tag would fail to resolve dependencies."
+ exit 1
+ fi
+ echo ".settings.xml on ${COMMERCIAL_BRANCH} configures the commercial release repository and its credentials."
+
- name: Add commercial release CI files
uses: ./.github/actions/add-commercial-release-files
with:
@@ -340,6 +389,17 @@ jobs:
branch: ${{ needs.derive.outputs.commercial_branch }}
token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
+ # A hotfix mixes commercial hotfix releases (usw1) with OSS-only releases that have no
+ # commercial counterpart (repo.spring.io), so the existing OSS repositories stay
+ # alongside the commercial ones rather than being replaced by them.
+ - name: Update repositories to commercial Broadcom repositories
+ uses: ./.github/actions/update-commercial-repositories
+ with:
+ repository: ${{ needs.derive.outputs.commercial_repo }}
+ branch: ${{ needs.derive.outputs.commercial_branch }}
+ keep-oss-repositories: 'true'
+ token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}
+
- name: Update projects.json
uses: ./.github/actions/update-projects-json
with:
diff --git a/docs/release-automation.md b/docs/release-automation.md
index a29ee95..41dbec2 100644
--- a/docs/release-automation.md
+++ b/docs/release-automation.md
@@ -601,7 +601,7 @@ The hotfix version is never passed in — it's looked up from this project's own
| Mechanism | full-history `-internal` branch, then refs-API cut | refs-API cut from `-internal`, same mechanism as a normal release | orphan branch via `initialize-commercial-branch`, no `-internal` branch |
| History | preserved | preserved | discarded — `trigger-ci` squashes to a single root commit and force-pushes |
| Pre-flight check | — | `-internal`'s pom.xml must already be at `..-INTERNAL-SNAPSHOT`, or the job fails | — |
-| Commercial customisation | full `initialize-commercial-branch` suite | only `add-commercial-release-files` (retargeted `ci-release.yml`) + license headers — `-internal` doesn't carry the rest, but `ci-release.yml`/`release-ci-settings.xml` already cover the deploy target | full `initialize-commercial-branch` suite |
+| Commercial customisation | full `initialize-commercial-branch` suite | `add-commercial-release-files` (retargeted `ci-release.yml`), license headers, plus `.settings.xml` and the pom/Gradle repository blocks (the docs build of the tag reads those, not `release-ci-settings.xml`); the pom blocks keep their OSS `repo.spring.io` entries after the commercial ones, since a hotfix mixes commercial and OSS-only dependencies. Distribution management and the `ci.yml`/`pr.yml` rewrite are skipped — `ci-release.yml`/`release-ci-settings.xml` already cover the deploy target | full `initialize-commercial-branch` suite |
| `config/projects.json` | scheduled (`-internal`), not registered (`release/`) | registered — a hotfix branch's CI can fail independently of `-internal`'s; removed by `spring-release-train-project-ready` once the hotfix ships | registered (as it always has been) |
| Version stamping | stays `-INTERNAL-SNAPSHOT` until readiness | explicit `-SNAPSHOT` immediately, before join | explicit `-SNAPSHOT` immediately, before join |
| `deployment-destination` | `Maven Central` or `Spring Enterprise` | always `Spring Enterprise` | always `Spring Enterprise` |
@@ -660,15 +660,15 @@ flowchart TD
| Job | Line | What it does |
|---|---|---|
-| `derive` | `:104` | Reads the OSS repo's root `pom.xml` `artifactId` to detect the project name, fetches `releaser.fixed-versions[]` from the release train's properties file to get the hotfix version (e.g. `5.0.4.1`), and splits it into `release/`, `..x-internal`, the expected `-INTERNAL-SNAPSHOT` version, and (for `use_tag`) `v..` |
-| `validate-internal-branch` | `:212` | Fetches `pom.xml` from the `-internal` branch via the contents API and fails the workflow if its version doesn't exactly match `derive`'s expected `-INTERNAL-SNAPSHOT` version. A no-op when `use_tag` is set |
-| `initialize` | `:250` | `use_tag` only. Calls `initialize-commercial-branch.yml` with `oss_tag` (not `oss_branch`) and `set_default_branch: false`, `secrets: inherit`. Produces an **orphan** branch carrying the commercial setup — Broadcom license headers, commercial Artifactory repositories and ``, restricted CI/PR workflows, `.settings.xml`, Antora playbook and `projects.json` entries — and none of the OSS history |
-| `create-internal-release-branch` | `:273` | Default path only. Forks `release/` from the `-internal` branch's tip via the same refs-API cut `create-oss-release-branch.yml` uses, preserving history. Then runs only `add-commercial-release-files` (retargeting the inherited `ci-release.yml`), `update-license-headers`, and `update-projects-json` — no settings.xml/workflow-rewrite/distribution-management, since `ci-release.yml`/`release-ci-settings.xml` already cover the commercial deploy target |
-| `update-versions` | `:334` | Clone the new branch. If `release_train_version` was given, first apply that train's dependency versions with `commercial: 'true'`; then **always** stamp the project version to `derive`'s pre-computed `-SNAPSHOT`, since that first pass would otherwise leave the train's version on the project. Commit `[skip actions]` |
-| `create-milestone` | `:390` | Milestone in the **commercial** repo — not the OSS repo, unlike every other flow |
-| `ensure-workflows` | `:404` | Check the branch with the shared [`check-release-train-workflows`](../.github/actions/check-release-train-workflows/) action; run the generator only if it reports something missing. Also resolves the primary JDK from `projects.json` — looking up `commercial.jdkVersions[release/]`, which branch creation populated — falling back to the commercial default, then the global default, warning at each step |
-| `trigger-release-train-join` | `:482` | Always `deployment-destination=Spring Enterprise`, then `gh run watch --exit-status` |
-| `trigger-ci` | `:512` | `use_tag`: re-orphan, commit the whole tree as `Initialize hotfix branch`, force-push. Default: soft-reset to the `-internal` fork point, squash only the initialisation commits on top, force-push — history at and before the fork point is untouched |
+| `derive` | `:107` | Reads the OSS repo's root `pom.xml` `artifactId` to detect the project name, fetches `releaser.fixed-versions[]` from the release train's properties file to get the hotfix version (e.g. `5.0.4.1`), and splits it into `release/`, `..x-internal`, the expected `-INTERNAL-SNAPSHOT` version, and (for `use_tag`) `v..` |
+| `validate-internal-branch` | `:230` | Fetches `pom.xml` from the `-internal` branch via the contents API and fails the workflow if its version doesn't exactly match `derive`'s expected `-INTERNAL-SNAPSHOT` version. A no-op when `use_tag` is set |
+| `initialize` | `:268` | `use_tag` only. Calls `initialize-commercial-branch.yml` with `oss_tag` (not `oss_branch`) and `set_default_branch: false`, `secrets: inherit`. Produces an **orphan** branch carrying the commercial setup — Broadcom license headers, commercial Artifactory repositories and ``, restricted CI/PR workflows, `.settings.xml`, Antora playbook and `projects.json` entries — and none of the OSS history |
+| `create-internal-release-branch` | `:296` | Default path only. Forks `release/` from the `-internal` branch's tip via the same refs-API cut `create-oss-release-branch.yml` uses, preserving history. Then runs `copy-settings-xml` (followed by a guard that parses the result and fails if the file is missing, or if its `spring` profile lacks the usw1 `spring-commercial-release` repository in `` or ``, or the matching `` credentials; `.github/scripts/verify-commercial-settings.js`), `add-commercial-release-files` (retargeting the inherited `ci-release.yml`), `update-license-headers`, `update-commercial-repositories`, and `update-projects-json`. Skips workflow-rewrite and distribution-management, since `ci-release.yml`/`release-ci-settings.xml` already cover the commercial deploy target; keeps `.settings.xml` and the repository blocks because the docs build of the hotfix tag resolves its dependencies through them |
+| `update-versions` | `:406` | Clone the new branch. If `release_train_version` was given, first apply that train's dependency versions with `commercial: 'true'`; then **always** stamp the project version to `derive`'s pre-computed `-SNAPSHOT`, since that first pass would otherwise leave the train's version on the project. Commit `[skip actions]` |
+| `create-milestone` | `:462` | Milestone in the **commercial** repo — not the OSS repo, unlike every other flow |
+| `ensure-workflows` | `:476` | Check the branch with the shared [`check-release-train-workflows`](../.github/actions/check-release-train-workflows/) action; run the generator only if it reports something missing. Also resolves the primary JDK from `projects.json` — looking up `commercial.jdkVersions[release/]`, which branch creation populated — falling back to the commercial default, then the global default, warning at each step |
+| `trigger-release-train-join` | `:555` | Always `deployment-destination=Spring Enterprise`, then `gh run watch --exit-status` |
+| `trigger-ci` | `:579` | `use_tag`: re-orphan, commit the whole tree as `Initialize hotfix branch`, force-push. Default: soft-reset to the `-internal` fork point, squash only the initialisation commits on top, force-push — history at and before the fork point is untouched |
### Two jobs worth a closer look