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.',