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}`; + +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 { + 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]*?)`, 'g'))].map(m => m[1]); + +const field = (xml, tag) => { + const m = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); + return m ? m[1].trim() : ''; +}; + +const without = (xml, tags) => + tags.reduce((acc, tag) => acc.replace(new RegExp(`<${tag}>[\\s\\S]*?`, '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