From 5db1362cb5a5b706dbc8c940d8849f8275ff4edd Mon Sep 17 00:00:00 2001 From: Ryan Baxter Date: Fri, 23 Oct 2026 09:33:16 -0400 Subject: [PATCH] Fix start.spring.io pr bugs for existing entries --- .github/scripts/README.md | 10 + .../boot-compatibility-range.test.js | 8 +- .../__tests__/start-site-mappings.test.js | 259 +++++++++++++++++ .github/scripts/boot-compatibility-range.js | 11 +- .github/scripts/start-site-mappings.js | 271 ++++++++++++++++++ .github/workflows/README-post-release.md | 17 +- .github/workflows/post-release.yml | 206 ++----------- 7 files changed, 580 insertions(+), 202 deletions(-) create mode 100644 .github/scripts/__tests__/start-site-mappings.test.js create mode 100644 .github/scripts/start-site-mappings.js diff --git a/.github/scripts/README.md b/.github/scripts/README.md index 2ee0d7c..6522f0e 100644 --- a/.github/scripts/README.md +++ b/.github/scripts/README.md @@ -78,6 +78,16 @@ on the numeric base of the Spring Boot version in the release's properties file, floor set by the *phase* rather than that Boot version verbatim, so the bound does not churn on every milestone. +## `start-site-mappings.js` + +`updateMappings(text, { releaseVersion, bootVersion })`: the edit `post-release.yml` makes to +the `spring-cloud` bom in start.spring.io's `application.yml`, as a line splice so the rest of +that file keeps its formatting. A train's mappings are found by its *line* (`2025.1`), never +the exact version, so `2025.1.4` finds the `2025.1.3` entry it replaces. Between two GA +releases only the version moves; at GA the line's `-SNAPSHOT` mapping moves on to the next +patch. Returns a status instead of throwing when it declines (`no-mapping`, +`unexpected-mappings`, `no-boot-version`, ...), so the workflow can report it. + ## `version-cmp.js` `cmp(a, b)`: dotted-numeric version comparison, tolerant of a leading `v` (release tags like diff --git a/.github/scripts/__tests__/boot-compatibility-range.test.js b/.github/scripts/__tests__/boot-compatibility-range.test.js index a7cbbb2..d4d03b4 100644 --- a/.github/scripts/__tests__/boot-compatibility-range.test.js +++ b/.github/scripts/__tests__/boot-compatibility-range.test.js @@ -65,9 +65,9 @@ describe('rangeFor', () => { expect(rangeFor('4.2.0-M3', '2026.0.0-M2')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); }); - it('moves the floor to -RC1 for release candidates', () => { - expect(rangeFor('4.2.0-RC1', '2026.0.0-RC1')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); - expect(rangeFor('4.2.0-RC2', '2026.0.0-RC2')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); + it('keeps the floor at -M1 for release candidates', () => { + expect(rangeFor('4.2.0-RC1', '2026.0.0-RC1')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); + expect(rangeFor('4.2.0-RC2', '2026.0.0-RC2')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); }); it('widens to the next Boot minor at GA', () => { @@ -78,7 +78,7 @@ describe('rangeFor', () => { const boot = '4.2.0-M2'; expect(rangeFor(boot, '2026.0.0-M1')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); expect(rangeFor(boot, '2026.0.0-M2')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); - expect(rangeFor(boot, '2026.0.0-RC1')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); + expect(rangeFor(boot, '2026.0.0-RC1')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); expect(rangeFor(boot, '2026.0.0')).toBe('[4.2.0,4.3.0-M1)'); }); }); diff --git a/.github/scripts/__tests__/start-site-mappings.test.js b/.github/scripts/__tests__/start-site-mappings.test.js new file mode 100644 index 0000000..9fd686b --- /dev/null +++ b/.github/scripts/__tests__/start-site-mappings.test.js @@ -0,0 +1,259 @@ +'use strict'; + +const { updateMappings, lineOf, nextSnapshot } = require('../start-site-mappings'); + +// The shape of start.spring.io's application.yml around the spring-cloud bom, including +// the neighbouring boms that are also called spring-cloud-something. +const file = mappings => [ + 'initializr:', + ' env:', + ' boms:', + ' spring-ai:', + ' groupId: org.springframework.ai', + ' artifactId: spring-ai-bom', + ' mappings:', + ' - compatibilityRange: "[4.0.0,4.2.0-M1)"', + ' version: 2.0.1', + ' spring-cloud:', + ' groupId: org.springframework.cloud', + ' artifactId: spring-cloud-dependencies', + ' versionProperty: spring-cloud.version', + ' order: 50', + ' mappings:', + ...mappings, + ' spring-cloud-azure:', + ' groupId: com.azure.spring', + ' artifactId: spring-cloud-azure-dependencies', + ' mappings:', + ' - compatibilityRange: "[4.0.0,4.1.0-M1)"', + ' version: 7.3.0', + '', +].join('\n'); + +const entry = (range, version, snapshot) => [ + ` - compatibilityRange: "${range}"`, + ` version: ${version}`, + ...(snapshot ? [' repositories:', ' - spring-snapshots'] : []), +]; + +const GA_LINE = entry('[4.0.0,4.2.0-M1)', '2025.1.3'); +const RC_TRAIN = [ + ...entry('[4.2.0-M1,4.2.0-SNAPSHOT)', '2026.0.0-RC1'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.0-SNAPSHOT', true), +]; + +describe('lineOf', () => { + it('reduces a version to major.minor', () => { + expect(lineOf('2025.1.4')).toBe('2025.1'); + expect(lineOf('2026.0.0-SNAPSHOT')).toBe('2026.0'); + expect(lineOf('2026.0.0-RC1')).toBe('2026.0'); + }); + + it('does not match a different line that shares a prefix', () => { + expect(lineOf('2025.10.0')).toBe('2025.10'); + expect(lineOf('2025.1.4')).not.toBe(lineOf('2025.10.0')); + }); + + it('returns null for anything that is not a version', () => { + expect(lineOf('main')).toBeNull(); + expect(lineOf(undefined)).toBeNull(); + }); +}); + +describe('nextSnapshot', () => { + it('moves to the next patch', () => { + expect(nextSnapshot('2026.0.0')).toBe('2026.0.1-SNAPSHOT'); + expect(nextSnapshot('2026.0.9')).toBe('2026.0.10-SNAPSHOT'); + }); +}); + +describe('a patch release of a line that is already mapped', () => { + // The bug: 2025.1.4 did not find the 2025.1.3 entry, so a second pair was appended. + const src = file([...GA_LINE, ...RC_TRAIN]); + const r = updateMappings(src, { releaseVersion: '2025.1.4', bootVersion: '4.0.9' }); + + it('rewrites the existing entry rather than adding one', () => { + expect(r.status).toBe('edited'); + expect(r.action).toBe('updated'); + expect(r.text).toContain('version: 2025.1.4'); + expect(r.text).not.toContain('version: 2025.1.3'); + expect(r.text.match(/version: 2025\.1\./g)).toHaveLength(1); + }); + + it('leaves the range alone, since the train has not changed phase', () => { + expect(r.text).toContain('compatibilityRange: "[4.0.0,4.2.0-M1)"'); + expect(r.text).not.toContain('4.0.9'); + }); + + it('touches nothing else, the other train and the other boms included', () => { + const before = src.split('\n'); + const after = r.text.split('\n'); + expect(after).toHaveLength(before.length); + expect(after.filter((l, i) => l !== before[i])).toEqual([' version: 2025.1.4']); + }); + + it('reports what changed', () => { + expect(r.from).toBe('2025.1.3'); + expect(r.changeDetail).toBe('`2025.1.3` → `2025.1.4`'); + }); +}); + +describe('a line with nothing mapped', () => { + it('adds a new train, with its snapshot, for a first milestone', () => { + const r = updateMappings(file(GA_LINE), { releaseVersion: '2026.0.0-M1', bootVersion: '4.2.0-M2' }); + expect(r.action).toBe('added'); + expect(r.text).toContain('compatibilityRange: "[4.2.0-M1,4.2.0-SNAPSHOT)"'); + expect(r.text).toContain('version: 2026.0.0-M1'); + expect(r.text).toContain('compatibilityRange: "[4.2.0-SNAPSHOT,4.3.0-M1)"'); + expect(r.text).toContain('version: 2026.0.0-SNAPSHOT'); + expect(r.text.match(/- spring-snapshots/g)).toHaveLength(1); + }); + + it('puts the new entries inside the bom, ahead of the next one', () => { + const r = updateMappings(file(GA_LINE), { releaseVersion: '2026.0.0-M1', bootVersion: '4.2.0-M2' }); + const lines = r.text.split('\n'); + expect(lines.indexOf(' version: 2026.0.0-SNAPSHOT')).toBeLessThan(lines.indexOf(' spring-cloud-azure:')); + }); + + it('adds a .0 that skipped its milestones, with the next patch as its snapshot', () => { + const r = updateMappings(file(GA_LINE), { releaseVersion: '2026.0.0', bootVersion: '4.2.0' }); + expect(r.action).toBe('added'); + expect(r.text).toContain('compatibilityRange: "[4.2.0,4.3.0-M1)"'); + expect(r.text).toContain('version: 2026.0.1-SNAPSHOT'); + expect(r.text).not.toContain('version: 2026.0.0-SNAPSHOT'); + }); + + it('declines a patch release for a line that has aged off the site', () => { + const src = file(GA_LINE); + const r = updateMappings(src, { releaseVersion: '2024.0.9', bootVersion: '3.4.5' }); + expect(r.status).toBe('no-mapping'); + expect(r.text).toBeUndefined(); + expect(r.mappings).toEqual(['2025.1.3']); + }); +}); + +describe('moving a train through its phases', () => { + it('bumps the version of a milestone entry and leaves its range and snapshot alone', () => { + const src = file([...GA_LINE, ...entry('[4.2.0-M1,4.2.0-SNAPSHOT)', '2026.0.0-M1'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.0-SNAPSHOT', true)]); + const r = updateMappings(src, { releaseVersion: '2026.0.0-M2', bootVersion: '4.2.0-M3' }); + expect(r.action).toBe('updated'); + expect(r.text).toContain('compatibilityRange: "[4.2.0-M1,4.2.0-SNAPSHOT)"'); + expect(r.text).toContain('version: 2026.0.0-M2'); + expect(r.text).toContain('version: 2026.0.0-SNAPSHOT'); + }); + + it('leaves the floor at -M1 at the first release candidate', () => { + const src = file([...GA_LINE, ...entry('[4.2.0-M1,4.2.0-SNAPSHOT)', '2026.0.0-M2'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.0-SNAPSHOT', true)]); + const r = updateMappings(src, { releaseVersion: '2026.0.0-RC1', bootVersion: '4.2.0-RC1' }); + expect(r.text).toContain('compatibilityRange: "[4.2.0-M1,4.2.0-SNAPSHOT)"'); + expect(r.text).toContain('version: 2026.0.0-RC1'); + expect(r.text).toContain('version: 2026.0.0-SNAPSHOT'); + }); + + describe('at GA', () => { + const src = file([...GA_LINE, ...RC_TRAIN]); + const r = updateMappings(src, { releaseVersion: '2026.0.0', bootVersion: '4.2.0' }); + + it('widens the release range and releases the version', () => { + expect(r.action).toBe('updated'); + expect(r.text).toContain('compatibilityRange: "[4.2.0,4.3.0-M1)"'); + expect(r.text).toContain('version: 2026.0.0\n'); + expect(r.text).not.toContain('2026.0.0-RC1'); + }); + + it("moves the line's snapshot on to the next patch, leaving its range and repository", () => { + expect(r.text).toContain('version: 2026.0.1-SNAPSHOT'); + expect(r.text).not.toContain('2026.0.0-SNAPSHOT'); + expect(r.text).toContain('compatibilityRange: "[4.2.0-SNAPSHOT,4.3.0-M1)"'); + expect(r.text.match(/- spring-snapshots/g)).toHaveLength(1); + expect(r.snapshotFrom).toBe('2026.0.0-SNAPSHOT'); + expect(r.snapshotTo).toBe('2026.0.1-SNAPSHOT'); + }); + + it('does not add or drop a mapping', () => { + expect(r.text.match(/compatibilityRange/g)).toHaveLength(src.match(/compatibilityRange/g).length); + }); + + it('leaves the earlier line alone', () => { + expect(r.text).toContain('version: 2025.1.3'); + }); + }); +}); + +describe('a patch release of a line that has a snapshot mapping', () => { + const src = file([ + ...entry('[4.2.0,4.3.0-M1)', '2026.0.0'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.1-SNAPSHOT', true), + ]); + + it('bumps both the version and the snapshot, keeping the range', () => { + const r = updateMappings(src, { releaseVersion: '2026.0.1', bootVersion: '4.2.1' }); + expect(r.text).toContain('compatibilityRange: "[4.2.0,4.3.0-M1)"'); + expect(r.text).toContain('version: 2026.0.1\n'); + expect(r.text).toContain('version: 2026.0.2-SNAPSHOT'); + expect(r.changeDetail).toBe('`2026.0.0` → `2026.0.1` and `2026.0.1-SNAPSHOT` → `2026.0.2-SNAPSHOT`'); + }); + + it('finishes a half-done update: release already bumped, snapshot not', () => { + const half = file([ + ...entry('[4.2.0,4.3.0-M1)', '2026.0.1'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.1-SNAPSHOT', true), + ]); + const r = updateMappings(half, { releaseVersion: '2026.0.1', bootVersion: '4.2.1' }); + expect(r.status).toBe('edited'); + expect(r.text).toContain('version: 2026.0.2-SNAPSHOT'); + expect(r.changeDetail).toBe('`2026.0.1-SNAPSHOT` → `2026.0.2-SNAPSHOT`'); + }); +}); + +describe('a re-run', () => { + it('reports already-current when nothing would change', () => { + const src = file([ + ...entry('[4.0.0,4.2.0-M1)', '2025.1.4'), + ]); + const r = updateMappings(src, { releaseVersion: '2025.1.4', bootVersion: '4.0.9' }); + expect(r.status).toBe('already-current'); + expect(r.text).toBeUndefined(); + }); + + it('reports already-current for a GA whose snapshot has already moved on', () => { + const src = file([ + ...entry('[4.2.0,4.3.0-M1)', '2026.0.0'), + ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.1-SNAPSHOT', true), + ]); + expect(updateMappings(src, { releaseVersion: '2026.0.0', bootVersion: '4.2.0' }).status) + .toBe('already-current'); + }); +}); + +describe('declining', () => { + it('needs a Boot version', () => { + const r = updateMappings(file(GA_LINE), { releaseVersion: '2025.1.4', bootVersion: '' }); + expect(r.status).toBe('no-boot-version'); + }); + + it('needs a Boot version it can parse', () => { + const r = updateMappings(file(GA_LINE), { releaseVersion: '2025.1.4', bootVersion: 'main' }); + expect(r.status).toBe('no-boot-version'); + }); + + it('will not guess when a snapshot mapping has no release beside it', () => { + const src = file([...GA_LINE, ...entry('[4.2.0-SNAPSHOT,4.3.0-M1)', '2026.0.0-SNAPSHOT', true)]); + const r = updateMappings(src, { releaseVersion: '2026.0.0-M1', bootVersion: '4.2.0-M1' }); + expect(r.status).toBe('unexpected-mappings'); + }); + + it('reports a file with no spring-cloud bom', () => { + const r = updateMappings('initializr:\n env:\n', { releaseVersion: '2025.1.4', bootVersion: '4.0.9' }); + expect(r.status).toBe('bom-not-found'); + }); + + it('is not misled by the neighbouring spring-cloud-* boms', () => { + // spring-cloud-azure sits directly below, with a version line of its own. + const r = updateMappings(file(GA_LINE), { releaseVersion: '2025.1.4', bootVersion: '4.0.9' }); + expect(r.text).toContain('version: 7.3.0'); + expect(r.text).toContain('version: 2.0.1'); + }); +}); diff --git a/.github/scripts/boot-compatibility-range.js b/.github/scripts/boot-compatibility-range.js index 40b7660..2a8269e 100644 --- a/.github/scripts/boot-compatibility-range.js +++ b/.github/scripts/boot-compatibility-range.js @@ -51,16 +51,19 @@ const phaseOf = releaseVersion => { // The range for the entry carrying the release itself. // // milestones -> [-M1,-SNAPSHOT) floor is the phase, not the Boot version -// candidates -> [-RC1,-SNAPSHOT) +// candidates -> [-M1,-SNAPSHOT) the same: the floor stays at -M1 // GA -> [,-M1) widened, since GA serves the whole line // -// The pre-release forms stop at -SNAPSHOT so the snapshot entry below takes over -// from there; the GA form has no such neighbour to defer to. +// The floor stays at -M1 through the release candidates too. It used to move to -RC1, and +// the maintainers set it back by hand on start.spring.io every time (68f2f54), because a +// user on a Boot milestone is still best served by the train's RC. The pre-release forms +// stop at -SNAPSHOT so the snapshot entry below takes over from there; the GA form +// has no such neighbour to defer to. const rangeFor = (bootVersion, releaseVersion) => { const base = bootBase(bootVersion); const phase = phaseOf(releaseVersion); if (phase === null) return `[${base},${nextBootMinor(bootVersion)}-M1)`; - return `[${base}-${phase}1,${base}-SNAPSHOT)`; + return `[${base}-M1,${base}-SNAPSHOT)`; }; // The range for the entry carrying the train's snapshot line. Written once, when the first diff --git a/.github/scripts/start-site-mappings.js b/.github/scripts/start-site-mappings.js new file mode 100644 index 0000000..c702d5a --- /dev/null +++ b/.github/scripts/start-site-mappings.js @@ -0,0 +1,271 @@ +'use strict'; + +// The edit post-release.yml makes to the `spring-cloud` bom in start.spring.io's +// application.yml, kept apart from the workflow so it can be tested against the shapes that +// file really takes. Handled as lines rather than parsed YAML so the change stays a splice +// and the other 700 lines of that file keep their formatting exactly. +// +// A train line (2025.1, 2026.0) owns up to two mappings: +// +// - compatibilityRange: "[4.2.0,4.3.0-M1)" the release itself +// version: 2026.0.0 +// - compatibilityRange: "[4.2.0-SNAPSHOT,4.3.0-M1)" the line's snapshots +// version: 2026.0.1-SNAPSHOT +// +// Both are found by the train *line*, never by the exact version. A patch release is the +// common case - 2025.1.4 replaces 2025.1.3 - and matching on the version would not find the +// entry it is meant to replace, then add a second one beside it. + +const { split } = require('./prerelease-rank'); +const { rangeFor, snapshotRangeFor } = require('./boot-compatibility-range'); + +// The one line that identifies the Spring Cloud bom among the dozen boms in that file, +// several of which are also called spring-cloud-something. +const ANCHOR = /^\s*artifactId:\s*spring-cloud-dependencies\s*$/; +const VERSION_RE = /^(\s*version:\s*)(["']?)([^"'\s]+)\2(\s*)$/; +// Each mapping is an item starting at a `- compatibilityRange:` line. +const ITEM = /^(\s*)-\s+compatibilityRange:\s*(.*?)\s*$/; + +const indentOf = l => l.match(/^(\s*)/)[1].length; +const unquote = s => s.replace(/^["']|["']$/g, ''); +const isSnapshot = v => /-SNAPSHOT$/i.test(v || ''); + +// 2025.1.4 -> 2025.1; 2026.0.0-SNAPSHOT -> 2026.0. null for anything that is not a version. +const lineOf = v => { + const m = String(v || '').match(/^(\d+\.\d+)(?:\.|$)/); + return m ? m[1] : null; +}; + +// The snapshot a GA release leaves behind it: 2026.0.0 -> 2026.0.1-SNAPSHOT. +const nextSnapshot = base => { + const parts = base.split('.'); + parts[parts.length - 1] = String(Number(parts[parts.length - 1]) + 1); + return `${parts.join('.')}-SNAPSHOT`; +}; + +// Finds the bom and its mappings. The bom's own key is the nearest earlier line indented +// less than its body, and the block runs to the next line indented no further than that +// key. Read from the file rather than assumed, so a reindentation upstream does not +// silently retarget this at the neighbouring bom. +const locate = lines => { + const anchor = lines.findIndex(l => ANCHOR.test(l)); + if (anchor < 0) return { error: 'no line matching "artifactId: spring-cloud-dependencies"' }; + + const bodyIndent = indentOf(lines[anchor]); + let keyLine = -1; + for (let i = anchor - 1; i >= 0; i--) { + if (!lines[i].trim()) continue; + if (indentOf(lines[i]) < bodyIndent) { keyLine = i; break; } + } + if (keyLine < 0) return { error: `no key above line ${anchor + 1}` }; + const keyIndent = indentOf(lines[keyLine]); + + let blockEnd = lines.length; + for (let i = anchor + 1; i < lines.length; i++) { + if (!lines[i].trim()) continue; + if (indentOf(lines[i]) <= keyIndent) { blockEnd = i; break; } + } + + // Each item runs to the next one, or to the end of the bom block. + const items = []; + for (let i = keyLine + 1; i < blockEnd; i++) { + const m = lines[i].match(ITEM); + if (m) items.push({ start: i, indent: m[1], range: m[2], end: blockEnd }); + } + for (let k = 0; k < items.length - 1; k++) items[k].end = items[k + 1].start; + for (const it of items) { + for (let i = it.start; i < it.end; i++) { + const m = lines[i].match(VERSION_RE); + if (m) { it.versionIndex = i; it.version = m[3]; it.vQuote = m[2]; break; } + } + } + return { keyLine, blockEnd, items }; +}; + +const setVersion = (lines, item, version) => { + const vq = item.vQuote; + lines[item.versionIndex] = lines[item.versionIndex].replace(VERSION_RE, `$1${vq}${version}${vq}$4`); +}; + +// Applies one release to the file's text. +// +// Returns { status, message, mappings } when it declines to or cannot edit, and otherwise +// { status: 'edited', text, action, changeDetail, from, snapshotFrom, snapshotTo, mappings } +// where action is 'added' or 'updated'. +// +// no-boot-version the Boot version the ranges derive from is missing or unusable +// bom-not-found the bom, or its mappings, are not where they were +// no-mapping a patch release for a line the site does not list - see below +// unexpected-mappings a snapshot mapping with no release mapping beside it +// already-current the file already reflects this release +// error the edit did not come out the shape intended; nothing is returned +const updateMappings = (original, { releaseVersion, bootVersion }) => { + const lines = original.split('\n'); + const found = locate(lines); + if (found.error) { + return { status: 'bom-not-found', message: `Could not find the spring-cloud bom: ${found.error}.`, mappings: [] }; + } + const { items } = found; + const mappings = items.map(it => it.version).filter(Boolean); + if (!items.length) { + return { status: 'bom-not-found', message: 'The spring-cloud bom has no compatibilityRange mappings.', mappings }; + } + + if (!bootVersion) { + return { + status: 'no-boot-version', mappings, + message: `The properties file for ${releaseVersion} has no spring-boot entry, and the ` + + 'compatibility range is derived from it. Nothing was changed.', + }; + } + + const rel = split(releaseVersion); + if (!rel) { + return { status: 'error', message: `'${releaseVersion}' is not a release version this can map.`, mappings }; + } + const isGA = rel.kind === null; + const trainLine = lineOf(rel.base); + + let wantRange, wantSnapshotRange; + try { + wantRange = rangeFor(bootVersion, releaseVersion); + wantSnapshotRange = snapshotRangeFor(bootVersion); + } catch (err) { + return { status: 'no-boot-version', message: `Cannot derive a compatibility range: ${err.message}`, mappings }; + } + + const ours = items.filter(it => lineOf(it.version) === trainLine); + const snapshotEntry = ours.find(it => isSnapshot(it.version)); + const releaseEntry = ours.find(it => !isSnapshot(it.version)); + + // What the line's snapshot mapping should read once this release is out. Before GA the + // train is still building towards , so it stays -SNAPSHOT; at GA that is + // released, and the line's snapshots move on to the next patch. + const wantSnapshotVersion = isGA ? nextSnapshot(rel.base) : `${rel.base}-SNAPSHOT`; + + const result = { mappings, from: '', snapshotFrom: '', snapshotTo: '' }; + let added = false; + let changeDetail; + + if (releaseEntry) { + // ── this line already has an entry: rewrite it in place ───────────────────────── + const fromVersion = releaseEntry.version; + const fromRange = unquote(releaseEntry.range); + result.from = fromVersion; + + if (releaseEntry.versionIndex === undefined) { + return { + status: 'unexpected-mappings', mappings, + message: `The ${releaseEntry.range} mapping has no version line to rewrite.`, + }; + } + + // The range moves as the train changes phase - the floor goes -M1 -> -RC1, and at GA the + // whole range widens to the next Boot minor. Between two GA releases it does not move at + // all: 2025.1.3 -> 2025.1.4 is a version bump, and deriving the range again from this + // release's Boot version would narrow it to [4.0.9,...) and drop everyone on 4.0.0-4.0.8. + const from = split(fromVersion); + const gaToGa = isGA && from && from.kind === null; + const toRange = gaToGa ? fromRange : wantRange; + + // Only a GA release moves the snapshot line. Both pre-release and GA runs leave a line + // with no snapshot mapping without one: the maintenance lines carry none. + const moveSnapshot = isGA && snapshotEntry && snapshotEntry.version !== wantSnapshotVersion; + + if (fromVersion === releaseVersion && fromRange === toRange && !moveSnapshot) { + return { + status: 'already-current', mappings, from: fromVersion, + message: `The file already maps ${toRange} to ${releaseVersion}.`, + }; + } + + const parts = []; + if (fromRange !== toRange) { + lines[releaseEntry.start] = `${releaseEntry.indent}- compatibilityRange: "${toRange}"`; + parts.push(`\`${fromRange}\` → \`"${toRange}"\``); + } + if (fromVersion !== releaseVersion) { + setVersion(lines, releaseEntry, releaseVersion); + parts.push(`\`${fromVersion}\` → \`${releaseVersion}\``); + } + if (moveSnapshot) { + if (snapshotEntry.versionIndex === undefined) { + return { + status: 'unexpected-mappings', mappings, + message: `The ${snapshotEntry.range} mapping has no version line to rewrite.`, + }; + } + result.snapshotFrom = snapshotEntry.version; + result.snapshotTo = wantSnapshotVersion; + setVersion(lines, snapshotEntry, wantSnapshotVersion); + parts.push(`\`${snapshotEntry.version}\` → \`${wantSnapshotVersion}\``); + } + changeDetail = parts.join(' and '); + } else if (!snapshotEntry) { + // ── nothing for this line ─────────────────────────────────────────────────────── + // A new train - its first milestone, or a .0 that skipped them - is added. A patch + // release for a line the site does not list is the opposite: the line has aged off, and + // putting it back would offer a Spring Cloud for a Boot range nobody asked to support. + if (isGA && Number(rel.base.split('.')[2]) > 0) { + return { + status: 'no-mapping', mappings, + message: `The file has no mapping for the ${trainLine} line, and ${releaseVersion} is a ` + + 'patch release rather than the start of a train, so nothing was added.', + }; + } + + // The release itself, and the train's snapshot line. Only the snapshot entry names a + // repository: Spring Boot 4.0 and up publishes milestones to Maven Central, so a + // milestone bom resolves without one - which is why the other pre-release entries in + // that file carry no repositories key - while snapshots still come from repo.spring.io. + const last = items[items.length - 1]; + const ind = last.indent; + const block = [ + `${ind}- compatibilityRange: "${wantRange}"`, + `${ind} version: ${releaseVersion}`, + `${ind}- compatibilityRange: "${wantSnapshotRange}"`, + `${ind} version: ${wantSnapshotVersion}`, + `${ind} repositories:`, + `${ind} - spring-snapshots`, + ]; + // After the last mapping's last non-blank line, so a blank line separating this bom + // from the next one stays where it is. + let at = last.end - 1; + while (at > last.start && !lines[at].trim()) at--; + lines.splice(at + 1, 0, ...block); + + added = true; + result.snapshotTo = wantSnapshotVersion; + changeDetail = `added \`${wantRange}\` → \`${releaseVersion}\` and ` + + `\`${wantSnapshotRange}\` → \`${wantSnapshotVersion}\``; + } else { + // A snapshot entry but nothing to release into. Adding a second entry here would leave + // two mappings for one line and no way to tell which a later run should move, so this + // is reported rather than guessed at. + return { + status: 'unexpected-mappings', mappings, + message: `The file has a ${snapshotEntry.version} mapping for this train but no release ` + + 'mapping beside it. Sort that out by hand; nothing was changed.', + }; + } + + // The edit is text, so prove the result still has the shape intended before it is + // written: the mappings list grew by what was expected, and carries this release once. + const after = locate(lines); + const expectedItems = items.length + (added ? 2 : 0); + const carries = after.items ? after.items.filter(it => it.version === releaseVersion).length : 0; + if (!after.items || after.items.length !== expectedItems || carries !== 1) { + return { + status: 'error', mappings, + message: `The edit did not produce the expected mappings: ${after.items ? after.items.length : 0} ` + + `mapping(s) where ${expectedItems} were expected, ${carries} carrying ${releaseVersion}. Left alone.`, + }; + } + + return { + ...result, status: 'edited', text: lines.join('\n'), + action: added ? 'added' : 'updated', changeDetail, + }; +}; + +module.exports = { updateMappings, lineOf, nextSnapshot }; diff --git a/.github/workflows/README-post-release.md b/.github/workflows/README-post-release.md index 616ffb3..79ef26e 100644 --- a/.github/workflows/README-post-release.md +++ b/.github/workflows/README-post-release.md @@ -527,7 +527,7 @@ The complete diff is also uploaded as the `website-changes` artifact, and on a d ## The start.spring.io PR -A one-line PR against [spring-io/start.spring.io](https://github.com/spring-io/start.spring.io), on a branch named `-release`, titled `Upgrade to Spring Cloud ` — the shape of [PR #2139](https://github.com/spring-io/start.spring.io/pull/2139). +A small PR against [spring-io/start.spring.io](https://github.com/spring-io/start.spring.io), on a branch named `-release`, titled `Upgrade to Spring Cloud ` — the shape of [PR #2139](https://github.com/spring-io/start.spring.io/pull/2139). **OSS only.** start.spring.io serves the public Initializr, so a commercial version has no place in it. Also skipped for a hotfix, when `projects` is set, and when `skip_start_site_pr` is checked. @@ -561,11 +561,14 @@ Both ranges are anchored on the Spring Boot version in the release's properties |---------|----------------------------------------| | `2026.0.0-M1` (first) | **added**, together with the `-SNAPSHOT` mapping | | `2026.0.0-M2` | version only — `[4.2.0-M1,4.2.0-SNAPSHOT)` is unchanged | -| `2026.0.0-RC1` | the floor moves — `[4.2.0-RC1,4.2.0-SNAPSHOT)` | +| `2026.0.0-RC1` | version only — the floor stays at `-M1`, so a user on an earlier Boot milestone is still offered the train | | `2026.0.0-RC2` | version only | | `2026.0.0` (GA) | widened to the next Boot minor — `[4.2.0,4.3.0-M1)` | +| `2026.0.1` and later patches | version only — `[4.2.0,4.3.0-M1)` is unchanged | -The `-SNAPSHOT` mapping is written once and never rewritten, GA included. Note that after GA its range overlaps the released one for every Boot version from `4.2.0` up, since `4.2.0-SNAPSHOT` sorts below `4.2.0`; which mapping wins is then down to Initializr's resolution order. +**The train's mappings are found by its line** (`2026.0`), not by the exact version, so a patch release finds the entry it replaces: `2025.1.4` rewrites the `2025.1.3` mapping in place rather than adding a second one. Between two GA releases the range is never re-derived — doing so from the new release's Boot version would narrow `[4.0.0,4.2.0-M1)` to `[4.0.9,4.1.0-M1)` and drop everyone on an earlier Boot. + +**The `-SNAPSHOT` mapping is left alone while the train is in milestones and release candidates, and moves on at GA.** Releasing `2026.0.0` rewrites `2026.0.0-SNAPSHOT` to `2026.0.1-SNAPSHOT`, and each later patch bumps it again (`2026.0.1` → `2026.0.2-SNAPSHOT`). Only its version moves — its range and repository stay. A line with no `-SNAPSHOT` mapping, such as a maintenance line, is not given one. Note that after GA the snapshot mapping's range overlaps the released one for every Boot version from `4.2.0` up, since `4.2.0-SNAPSHOT` sorts below `4.2.0`; which mapping wins is then down to Initializr's resolution order. **Only the `-SNAPSHOT` mapping names a repository.** Spring Boot 4.0 and up publishes milestones to Maven Central — start.spring.io's own `Repositories.java` says as much — so a milestone BOM resolves without one, which is why the other pre-release entries in that file carry no `repositories` key either. Snapshots still come from `repo.spring.io`. `spring-snapshots` and `spring-milestones` are Initializr built-in repository ids; nothing has to be declared in `application.yml` to reference them. @@ -575,8 +578,9 @@ The `-SNAPSHOT` mapping is written once and never rewritten, GA included. Note t ### When it declines to act -**No PR is opened, and the run says so**, in three cases: +**No PR is opened, and the run says so**, in four cases: +- `no-mapping` — a **patch** release (`2024.0.9`) for a line the file does not list. The line has aged off the site, and adding it back would offer a Spring Cloud for a Boot range nobody asked to support. A new train — its first milestone, or a `.0` that skipped them — is added instead. - `no-boot-version` — the properties file has no `spring-boot` entry, or one this cannot parse. Every range is derived from it. - `unexpected-mappings` — the train has a `-SNAPSHOT` mapping but no release mapping beside it. Adding one would leave two mappings for one train and no way to tell which a later run should move. - `bom-not-found` / `file-not-found` — the `spring-cloud` bom, or the file itself, has moved. @@ -591,9 +595,10 @@ The summary gets its own **start.spring.io PR** section spelling out the reason |--------|---------| | `created` / `would-create` | the PR was opened; the diff is in the job's own summary | | `added` | the train had no mappings, so both were added — its first release | -| `updated` | the train's existing mapping was rewritten | -| `already-current` | the mapping already names this version and range — a re-run, or a hand-made PR that already landed | +| `updated` | the train's existing mapping was rewritten, and at GA its snapshot mapping moved to the next patch | +| `already-current` | the mapping already names this version and range, and its snapshot has already moved on — a re-run, or a hand-made PR that already landed | | `branch-exists` | `-release` is already in the repo; it is left alone and the existing PR is linked | +| `no-mapping` | ⚠️ a patch release for a line the file does not list, see above | | `no-boot-version` | ⚠️ no usable `spring-boot` entry to derive the range from, see above | | `unexpected-mappings` | ⚠️ a `-SNAPSHOT` mapping with no release mapping beside it, see above | | `error` | ❌ the edit did not produce the expected mappings, so nothing was written | diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml index 800f1cc..d9faa16 100644 --- a/.github/workflows/post-release.yml +++ b/.github/workflows/post-release.yml @@ -2901,7 +2901,6 @@ jobs: env: GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} RELEASE_VERSION: ${{ inputs.release_version }} - PRERELEASE: ${{ needs.setup.outputs.prerelease }} ENTRIES: ${{ needs.setup.outputs.entries }} DRY_RUN: ${{ inputs.dry_run }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -2911,15 +2910,11 @@ jobs: const path = require('path'); const { execFileSync } = require('child_process'); - const prerelease = require(path.join(process.env.GITHUB_WORKSPACE, - '.github', 'scripts', 'prerelease-rank.js')); - const { rangeFor, snapshotRangeFor } = require(path.join( - process.env.GITHUB_WORKSPACE, '.github', 'scripts', - 'boot-compatibility-range.js')); + const { updateMappings } = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'start-site-mappings.js')); const token = process.env.GH_TOKEN; const releaseVersion = process.env.RELEASE_VERSION; - const isPrerelease = process.env.PRERELEASE === 'true'; const dryRun = process.env.DRY_RUN === 'true'; const runUrl = process.env.RUN_URL || ''; @@ -2935,10 +2930,6 @@ jobs: const REPO = 'spring-io/start.spring.io'; const BASE = 'main'; const FILE = 'start-site/src/main/resources/application.yml'; - // The one line that identifies the Spring Cloud bom among the dozen boms in that - // file, several of which are also called spring-cloud-something. - const ANCHOR = /^\s*artifactId:\s*spring-cloud-dependencies\s*$/; - const VERSION_RE = /^(\s*version:\s*)(["']?)([^"'\s]+)\2(\s*)$/; const gh = args => execFileSync('gh', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); @@ -2987,184 +2978,22 @@ jobs: } const original = fs.readFileSync(filePath, 'utf8'); - const lines = original.split('\n'); - const indentOf = l => l.match(/^(\s*)/)[1].length; - - const anchor = lines.findIndex(l => ANCHOR.test(l)); - if (anchor < 0) { - finish('bom-not-found', - `Could not find the spring-cloud bom in ${FILE} - no line matching ` + - '"artifactId: spring-cloud-dependencies".'); - } - - // The bom's own key is the nearest earlier line indented less than its body, and - // the block runs to the next line indented no further than that key. Read from the - // file rather than assumed, so a reindentation upstream does not silently retarget - // this at the neighbouring bom. - const bodyIndent = indentOf(lines[anchor]); - let keyLine = -1; - for (let i = anchor - 1; i >= 0; i--) { - if (!lines[i].trim()) continue; - if (indentOf(lines[i]) < bodyIndent) { keyLine = i; break; } - } - if (keyLine < 0) finish('bom-not-found', `Could not find the key above ${FILE}:${anchor + 1}.`); - const keyIndent = indentOf(lines[keyLine]); - - let blockEnd = lines.length; - for (let i = anchor + 1; i < lines.length; i++) { - if (!lines[i].trim()) continue; - if (indentOf(lines[i]) <= keyIndent) { blockEnd = i; break; } - } - console.log(`spring-cloud bom: ${FILE}:${keyLine + 1}-${blockEnd}`); - - // Each mapping is an item starting at a `- compatibilityRange:` line and running - // to the next one, or to the end of the bom block. Handled as lines rather than - // parsed YAML so this stays a splice and the other 700 lines of that file keep - // their formatting exactly. - const ITEM = /^(\s*)-\s+compatibilityRange:\s*(.*?)\s*$/; - - const items = []; - for (let i = keyLine + 1; i < blockEnd; i++) { - const m = lines[i].match(ITEM); - if (m) items.push({ start: i, indent: m[1], range: m[2], end: blockEnd }); - } - for (let k = 0; k < items.length - 1; k++) items[k].end = items[k + 1].start; - if (!items.length) { - finish('bom-not-found', - `The spring-cloud bom in ${FILE} has no compatibilityRange mappings.`); - } - for (const it of items) { - for (let i = it.start; i < it.end; i++) { - const m = lines[i].match(VERSION_RE); - if (m) { it.versionIndex = i; it.version = m[3]; it.vQuote = m[2]; break; } - } - } - result.mappings = items.map(it => it.version).filter(Boolean); + const edit = updateMappings(original, { releaseVersion, bootVersion }); + result.mappings = edit.mappings || []; console.log(`Versions currently mapped: ${result.mappings.join(', ') || 'none'}`); - - if (!bootVersion) { - finish('no-boot-version', - `The properties file for ${releaseVersion} has no spring-boot entry, and the ` + - 'compatibility range is derived from it. Nothing was changed.'); - } - - // This train's own entries, found by version rather than by Boot range: a train - // that has never shipped has no range to match on, and the old lineOf() test - the - // first two segments - cannot pick 2026.0.0 out of 2024.0.2 and 2025.1.0-M3 either. - const trainBase = prerelease.split(releaseVersion).base; - const belongsToTrain = v => !!v && (v === trainBase || v.startsWith(`${trainBase}-`)); - const isSnapshotVersion = v => /-SNAPSHOT$/i.test(v || ''); - const ours = items.filter(it => belongsToTrain(it.version)); - const snapshotEntry = ours.find(it => isSnapshotVersion(it.version)); - const releaseEntry = ours.find(it => !isSnapshotVersion(it.version)); - - let wantRange, wantSnapshotRange; - try { - wantRange = rangeFor(bootVersion, releaseVersion); - wantSnapshotRange = snapshotRangeFor(bootVersion); - } catch (err) { - finish('no-boot-version', `Cannot derive a compatibility range: ${err.message}`); - } - const snapshotVersion = `${trainBase}-SNAPSHOT`; - console.log(`Spring Boot ${bootVersion} -> ${wantRange} for ${releaseVersion}`); - - if (releaseEntry) { - // ── c. this train already has an entry: rewrite it in place ─────────────── - // Both fields, because the range moves as the train changes phase: the floor - // goes -M1 -> -RC1, and at GA the whole range widens to the next Boot minor. - const fromVersion = releaseEntry.version; - const fromRange = releaseEntry.range; - result.from = fromVersion; - - if (fromVersion === releaseVersion && - fromRange.replace(/^["']|["']$/g, '') === wantRange) { - finish('already-current', - `${FILE} already maps ${wantRange} to ${releaseVersion}.`); - } - if (releaseEntry.versionIndex === undefined) { - finish('unexpected-mappings', - `The ${fromRange} mapping in ${FILE} has no version line to rewrite.`); - } - - lines[releaseEntry.start] = - `${releaseEntry.indent}- compatibilityRange: "${wantRange}"`; - const vq = releaseEntry.vQuote; - lines[releaseEntry.versionIndex] = lines[releaseEntry.versionIndex] - .replace(VERSION_RE, `$1${vq}${releaseVersion}${vq}$4`); - - result.action = 'updated'; - changeDetail = `\`${fromRange}\` → \`"${wantRange}"\` and ` + - `\`${fromVersion}\` → \`${releaseVersion}\``; - console.log(`${FILE}:${releaseEntry.start + 1} ${fromRange} -> "${wantRange}"`); - console.log(`${FILE}:${releaseEntry.versionIndex + 1} ${fromVersion} -> ${releaseVersion}`); - } else if (!snapshotEntry) { - // ── c. first release of this train: add both entries ────────────────────── - // The release itself, and the train's snapshot line. Only the snapshot entry - // names a repository: Spring Boot 4.0 and up publishes milestones to Maven - // Central, so a milestone bom resolves without one - which is why the other - // pre-release entries in that file carry no repositories key - while snapshots - // still come from repo.spring.io. - const last = items[items.length - 1]; - const ind = last.indent; - const block = [ - `${ind}- compatibilityRange: "${wantRange}"`, - `${ind} version: ${releaseVersion}`, - `${ind}- compatibilityRange: "${wantSnapshotRange}"`, - `${ind} version: ${snapshotVersion}`, - `${ind} repositories:`, - `${ind} - spring-snapshots`, - ]; - - // After the last mapping's last non-blank line, so a blank line separating this - // bom from the next one stays where it is. - let at = last.end - 1; - while (at > last.start && !lines[at].trim()) at--; - lines.splice(at + 1, 0, ...block); - - result.action = 'added'; - result.added = [releaseVersion, snapshotVersion]; - changeDetail = `added \`${wantRange}\` → \`${releaseVersion}\` and ` + - `\`${wantSnapshotRange}\` → \`${snapshotVersion}\``; - console.log(`Added two mappings after ${FILE}:${at + 1}:`); - for (const l of block) console.log(l); - } else { - // A snapshot entry but nothing to release into. Adding a second entry here would - // leave two mappings for one train and no way to tell which a later run should - // move, so this is reported rather than guessed at. - finish('unexpected-mappings', - `${FILE} has a ${snapshotEntry.version} mapping for this train but no release ` + - 'mapping beside it. Sort that out by hand; nothing was changed.'); + if (edit.status !== 'edited') { + if (edit.from) result.from = edit.from; + finish(edit.status, edit.message); } - // The splice is a text edit, so prove the result still has the shape intended - // before writing it. The bom block is re-scanned rather than the YAML parsed - the - // runner has no YAML library, and what matters is that the mappings list grew by - // what was expected and carries this release exactly once. - const after = lines.slice(); - const aAnchor = after.findIndex(l => ANCHOR.test(l)); - let aKey = -1; - for (let k = aAnchor - 1; k >= 0; k--) { - if (!after[k].trim()) continue; - if (indentOf(after[k]) < indentOf(after[aAnchor])) { aKey = k; break; } - } - let aEnd = after.length; - for (let k = aAnchor + 1; k < after.length; k++) { - if (!after[k].trim()) continue; - if (indentOf(after[k]) <= indentOf(after[aKey])) { aEnd = k; break; } - } - const aItems = after.slice(aKey, aEnd).filter(l => ITEM.test(l)).length; - const aCarries = after.slice(aKey, aEnd) - .filter(l => { const m = l.match(VERSION_RE); return m && m[3] === releaseVersion; }) - .length; - const expectedItems = items.length + (result.action === 'added' ? 2 : 0); - if (aItems !== expectedItems || aCarries !== 1) { - finish('error', - `The edit did not produce the expected mappings in ${FILE}: ${aItems} ` + - `mapping(s) where ${expectedItems} were expected, ${aCarries} carrying ` + - `${releaseVersion}. Left alone.`); - } + result.from = edit.from; + result.action = edit.action; + if (edit.action === 'added') result.added = [releaseVersion, edit.snapshotTo]; + if (edit.snapshotTo) { result.snapshotFrom = edit.snapshotFrom; result.snapshotTo = edit.snapshotTo; } + changeDetail = edit.changeDetail; + console.log(`Spring Boot ${bootVersion}: ${changeDetail}`); - fs.writeFileSync(filePath, lines.join('\n')); + fs.writeFileSync(filePath, edit.text); git(['config', 'user.name', 'Spring Builds'], 'start'); git(['config', 'user.email', 'svc.spring-builds@broadcom.com'], 'start'); @@ -3203,9 +3032,10 @@ jobs: 'repository - Spring Boot 4.0 and up publishes milestones to Maven Central, so ' + 'the released mapping resolves without one, matching the other pre-release ' + 'entries in this file.' - : 'The `compatibilityRange` moves with the phase of the train: the floor goes ' + - '`-M1` to `-RC1` when release candidates start, and widens to the next Spring ' + - 'Boot minor at GA.', + : 'The `compatibilityRange` only moves at GA, when it widens to the next Spring ' + + 'Boot minor; the milestone floor stays at `-M1` through the release candidates. ' + + 'Between two GA releases only the version changes. At GA ' + + 'the line\'s `-SNAPSHOT` mapping, where there is one, moves on to the next patch.', '', 'Mappings for earlier trains are deliberately untouched; they use the previous ' + 'single-entry convention.',