Skip to content

Commit 1f54aa1

Browse files
committed
Update version now honors releaser:version-check-off
1 parent b3f4da3 commit 1f54aa1

6 files changed

Lines changed: 143 additions & 9 deletions

File tree

‎.github/actions/update-project-versions/README.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,34 @@ The action walks the `directory` input (default: repo root) recursively and upda
1010
|---|---|
1111
| `pom.xml` (root) | `<version>` of the project itself |
1212
| `pom.xml` (all) | `<parent><version>` when the parent belongs to this project or is tracked in `versions` |
13-
| `pom.xml` (root **and** child modules) | `<properties>` entries whose key ends in `.version` when the project name is in `versions` |
13+
| `pom.xml` (root **and** child modules) | `<properties>` entries whose key ends in `.version` when the project name is in `versions`, unless [pinned](#pinning-a-property-with-releaserversion-check-off) |
1414
| `gradle.properties` | Bare `version=` key → `project-version`; `{prefix}Version=` keys → looked up in `versions` via camelCase→kebab-case conversion |
1515
| `build.gradle` / `build.gradle.kts` | `version = '...'` / `version = "..."` declaration → `project-version` |
1616

1717
The following directories are always skipped: `.git`, `node_modules`, `target`, `build`, `.gradle`.
1818

19+
### Pinning a property with `@releaser:version-check-off`
20+
21+
A `<properties>` entry is left alone when it carries the marker the Jenkins releaser has always used, as a trailing comment on the same line:
22+
23+
```xml
24+
<spring-cloud-function.version>4.3.6-SNAPSHOT</spring-cloud-function.version>
25+
<spring-cloud-stream.version>4.3.4</spring-cloud-stream.version><!-- @releaser:version-check-off -->
26+
```
27+
28+
Here `spring-cloud-function.version` is bumped as usual and `spring-cloud-stream.version` is not.
29+
30+
**This exists for dependency cycles inside a release train.** `spring-cloud-stream` builds against `spring-cloud-function`, and `spring-cloud-function`'s samples build against `spring-cloud-stream`. Pointing both at the other's snapshot is a cycle, so one side pins to a released version and says so in the pom.
31+
32+
The marker is matched against the property it trails, not the file, so a pinned entry does not pin its neighbours. A pinned property is **reported in the log even though nothing changed** — a property deliberately left alone otherwise looks exactly like the bump quietly missing one:
33+
34+
```
35+
Left alone in spring-cloud-function-samples/function-sample-cloudevent-stream/pom.xml
36+
(@releaser:version-check-off): spring-cloud-stream.version (pinned at 4.3.4)
37+
```
38+
39+
It applies to `<properties>` entries only. A project's own `<version>` is not covered — an unreleasable project is expressed by leaving it out of the release train, not by a marker.
40+
1941
## Inputs
2042

2143
| Name | Required | Default | Description |

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
replaceProjectVersion,
1212
replaceParentVersion,
1313
replacePropertyValue,
14+
hasVersionCheckOff,
1415
updateGradlePropertiesContent,
1516
updateBuildGradleContent,
1617
camelToKebab,
@@ -113,6 +114,41 @@ describe('replaceParentVersion', () => {
113114
});
114115
});
115116

117+
// ── hasVersionCheckOff ────────────────────────────────────────────────────────
118+
119+
describe('hasVersionCheckOff', () => {
120+
// the exact shape spring-cloud-function's sample poms use, where pinning matters because
121+
// stream depends on function and function's samples depend on stream
122+
const pinned = `<properties>
123+
<spring-cloud-function.version>4.3.6-SNAPSHOT</spring-cloud-function.version>
124+
<spring-cloud-stream.version>4.3.4</spring-cloud-stream.version><!-- @releaser:version-check-off -->
125+
</properties>`;
126+
127+
test('finds the marker on the property it trails', () => {
128+
expect(hasVersionCheckOff(pinned, 'spring-cloud-stream.version')).toBe(true);
129+
});
130+
131+
test('does not leak to a neighbouring property', () => {
132+
expect(hasVersionCheckOff(pinned, 'spring-cloud-function.version')).toBe(false);
133+
});
134+
135+
test('is false when the property carries no marker', () => {
136+
const xml = '<spring-boot.version>3.2.2</spring-boot.version>';
137+
expect(hasVersionCheckOff(xml, 'spring-boot.version')).toBe(false);
138+
});
139+
140+
test('tolerates extra comment text around the marker', () => {
141+
const xml = '<a.version>1</a.version> <!-- pinned, @releaser:version-check-off, see #123 -->';
142+
expect(hasVersionCheckOff(xml, 'a.version')).toBe(true);
143+
});
144+
145+
test('a marker elsewhere in the file does not pin an unrelated property', () => {
146+
const xml = `<version>0.0.1-SNAPSHOT</version><!-- @releaser:version-check-off -->
147+
<b.version>2</b.version>`;
148+
expect(hasVersionCheckOff(xml, 'b.version')).toBe(false);
149+
});
150+
});
151+
116152
// ── replacePropertyValue ──────────────────────────────────────────────────────
117153

118154
describe('replacePropertyValue', () => {

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28335,7 +28335,7 @@ async function run() {
2833528335

2833628336
for (const file of pomFiles) {
2833728337
const isRoot = path.resolve(file) === path.resolve(rootPom);
28338-
const { changed, updatedProperties } = updatePomFile(
28338+
const { changed, updatedProperties, skippedProperties } = updatePomFile(
2833928339
file,
2834028340
isRoot,
2834128341
projectVersion,
@@ -28347,6 +28347,12 @@ async function run() {
2834728347
if (changed) {
2834828348
core.info(`Updated ${path.relative(directory, file)}: ${updatedProperties.join(', ')}`);
2834928349
}
28350+
// Reported even when nothing changed: a property left alone on purpose is the kind
28351+
// of thing that otherwise looks like the bump silently missing one.
28352+
if (skippedProperties.length) {
28353+
core.info(`Left alone in ${path.relative(directory, file)} ` +
28354+
`(@releaser:version-check-off): ${skippedProperties.join(', ')}`);
28355+
}
2835028356
}
2835128357
}
2835228358

@@ -28537,13 +28543,14 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
2853728543
const content = fs.readFileSync(filePath, 'utf-8');
2853828544
let updated = content;
2853928545
const updatedProperties = [];
28546+
const skippedProperties = [];
2854028547

2854128548
const parser = new XMLParser({ ignoreAttributes: false });
2854228549
const parsed = parser.parse(content);
2854328550
const project = parsed?.project;
2854428551

2854528552
if (!project) {
28546-
return { changed: false, updatedProperties: [] };
28553+
return { changed: false, updatedProperties: [], skippedProperties: [] };
2854728554
}
2854828555

2854928556
// 1. Update the project's own <version>, skipping the <parent> block.
@@ -28595,13 +28602,18 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
2859528602
}
2859628603

2859728604
// 3. Update <properties> entries ending in .version that match the versions map.
28598-
// This applies to ALL pom files — root and child modules alike.
28605+
// This applies to ALL pom files — root and child modules alike, except any the pom
28606+
// has opted out of with @releaser:version-check-off.
2859928607
const properties = project?.properties ?? {};
2860028608
for (const [key, currentValue] of Object.entries(properties)) {
2860128609
if (!key.endsWith('.version')) continue;
2860228610
const projectName = key.slice(0, -'.version'.length);
2860328611
const targetVersion = versions[projectName];
2860428612
if (targetVersion && String(currentValue) !== targetVersion) {
28613+
if (hasVersionCheckOff(updated, key)) {
28614+
skippedProperties.push(`${key} (pinned at ${currentValue})`);
28615+
continue;
28616+
}
2860528617
const prev = updated;
2860628618
updated = replacePropertyValue(updated, key, targetVersion);
2860728619
if (updated !== prev) {
@@ -28614,7 +28626,7 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
2861428626
if (changed) {
2861528627
fs.writeFileSync(filePath, updated, 'utf-8');
2861628628
}
28617-
return { changed, updatedProperties };
28629+
return { changed, updatedProperties, skippedProperties };
2861828630
}
2861928631

2862028632
/**
@@ -28663,6 +28675,25 @@ function replaceParentVersion(xml, oldVersion, newVersion) {
2866328675
*
2866428676
* Exported for unit testing.
2866528677
*/
28678+
/**
28679+
* True when the pom has opted this property out of version management with the marker the
28680+
* Jenkins releaser has always used:
28681+
*
28682+
* <spring-cloud-stream.version>4.3.4</spring-cloud-stream.version><!-- @releaser:version-check-off -->
28683+
*
28684+
* Needed where two projects in the same train depend on each other - spring-cloud-stream
28685+
* builds against spring-cloud-function and function's samples build against stream - because
28686+
* pointing both at the other's snapshot is a cycle. One side pins to a release and says so.
28687+
*
28688+
* Exported for unit testing.
28689+
*/
28690+
function hasVersionCheckOff(xml, propertyName) {
28691+
const escapedName = escapeRegex(propertyName);
28692+
return new RegExp(
28693+
`<${escapedName}>[^<]*<\\/${escapedName}>[ \\t]*<!--[^>]*@releaser:version-check-off[^>]*-->`
28694+
).test(xml);
28695+
}
28696+
2866628697
function replacePropertyValue(xml, propertyName, newVersion) {
2866728698
const escapedName = escapeRegex(propertyName);
2866828699
const regex = new RegExp(`(<${escapedName}>\\s*)([^<]*)(\\s*<\\/${escapedName}>)`);
@@ -28895,6 +28926,7 @@ module.exports = {
2889528926
replaceProjectVersion,
2889628927
replaceParentVersion,
2889728928
replacePropertyValue,
28929+
hasVersionCheckOff,
2889828930
updateGradlePropertiesContent,
2889928931
updateBuildGradleContent,
2890028932
findFiles,

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ async function run() {
110110

111111
for (const file of pomFiles) {
112112
const isRoot = path.resolve(file) === path.resolve(rootPom);
113-
const { changed, updatedProperties } = updatePomFile(
113+
const { changed, updatedProperties, skippedProperties } = updatePomFile(
114114
file,
115115
isRoot,
116116
projectVersion,
@@ -122,6 +122,12 @@ async function run() {
122122
if (changed) {
123123
core.info(`Updated ${path.relative(directory, file)}: ${updatedProperties.join(', ')}`);
124124
}
125+
// Reported even when nothing changed: a property left alone on purpose is the kind
126+
// of thing that otherwise looks like the bump silently missing one.
127+
if (skippedProperties.length) {
128+
core.info(`Left alone in ${path.relative(directory, file)} ` +
129+
`(@releaser:version-check-off): ${skippedProperties.join(', ')}`);
130+
}
125131
}
126132
}
127133

@@ -312,13 +318,14 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
312318
const content = fs.readFileSync(filePath, 'utf-8');
313319
let updated = content;
314320
const updatedProperties = [];
321+
const skippedProperties = [];
315322

316323
const parser = new XMLParser({ ignoreAttributes: false });
317324
const parsed = parser.parse(content);
318325
const project = parsed?.project;
319326

320327
if (!project) {
321-
return { changed: false, updatedProperties: [] };
328+
return { changed: false, updatedProperties: [], skippedProperties: [] };
322329
}
323330

324331
// 1. Update the project's own <version>, skipping the <parent> block.
@@ -370,13 +377,18 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
370377
}
371378

372379
// 3. Update <properties> entries ending in .version that match the versions map.
373-
// This applies to ALL pom files — root and child modules alike.
380+
// This applies to ALL pom files — root and child modules alike, except any the pom
381+
// has opted out of with @releaser:version-check-off.
374382
const properties = project?.properties ?? {};
375383
for (const [key, currentValue] of Object.entries(properties)) {
376384
if (!key.endsWith('.version')) continue;
377385
const projectName = key.slice(0, -'.version'.length);
378386
const targetVersion = versions[projectName];
379387
if (targetVersion && String(currentValue) !== targetVersion) {
388+
if (hasVersionCheckOff(updated, key)) {
389+
skippedProperties.push(`${key} (pinned at ${currentValue})`);
390+
continue;
391+
}
380392
const prev = updated;
381393
updated = replacePropertyValue(updated, key, targetVersion);
382394
if (updated !== prev) {
@@ -389,7 +401,7 @@ function updatePomFile(filePath, isRoot, projectVersion, versions, currentRootVe
389401
if (changed) {
390402
fs.writeFileSync(filePath, updated, 'utf-8');
391403
}
392-
return { changed, updatedProperties };
404+
return { changed, updatedProperties, skippedProperties };
393405
}
394406

395407
/**
@@ -438,6 +450,25 @@ function replaceParentVersion(xml, oldVersion, newVersion) {
438450
*
439451
* Exported for unit testing.
440452
*/
453+
/**
454+
* True when the pom has opted this property out of version management with the marker the
455+
* Jenkins releaser has always used:
456+
*
457+
* <spring-cloud-stream.version>4.3.4</spring-cloud-stream.version><!-- @releaser:version-check-off -->
458+
*
459+
* Needed where two projects in the same train depend on each other - spring-cloud-stream
460+
* builds against spring-cloud-function and function's samples build against stream - because
461+
* pointing both at the other's snapshot is a cycle. One side pins to a release and says so.
462+
*
463+
* Exported for unit testing.
464+
*/
465+
function hasVersionCheckOff(xml, propertyName) {
466+
const escapedName = escapeRegex(propertyName);
467+
return new RegExp(
468+
`<${escapedName}>[^<]*<\\/${escapedName}>[ \\t]*<!--[^>]*@releaser:version-check-off[^>]*-->`
469+
).test(xml);
470+
}
471+
441472
function replacePropertyValue(xml, propertyName, newVersion) {
442473
const escapedName = escapeRegex(propertyName);
443474
const regex = new RegExp(`(<${escapedName}>\\s*)([^<]*)(\\s*<\\/${escapedName}>)`);
@@ -670,6 +701,7 @@ module.exports = {
670701
replaceProjectVersion,
671702
replaceParentVersion,
672703
replacePropertyValue,
704+
hasVersionCheckOff,
673705
updateGradlePropertiesContent,
674706
updateBuildGradleContent,
675707
findFiles,

‎.github/workflows/README-post-release.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,8 @@ If all three attempts fail, the step **records a status and exits 0** rather tha
264264

265265
The `ls-remote` is checked the same way for a related reason. `--exit-code` exits **2** when the remote answered and has no such branch — a real answer, and the common one — while any other non-zero is a transport failure. Treating the two alike would skip the merge for a project whose release branch was there all along, bump it anyway, and call the run a success.
266266

267+
**`already-merged` alongside `would-push` is not a contradiction.** The Merge column answers "did `release/<version>` need merging into `.x`", and the Push column answers "is there anything to push at all" — and the version bump is a commit in its own right. Most runs show exactly that pair, because the merge has usually happened already while the snapshot bump has not. `nothing-to-push` means the branch was already at the new versions too. The summary says so under the table.
268+
267269
The merge commit and the version-bump commit go up in a **single push**: one CI run per project, and if anything fails in between, nothing is pushed and the branch is left untouched so the whole project can simply be re-run. The `release/<version>` branch is left in place, not deleted.
268270

269271
### CI and PR workflow files are preserved across the merge

‎.github/workflows/post-release.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3144,6 +3144,16 @@ jobs:
31443144
`${icon(r.pushStatus)} ${r.pushStatus} | ${dep} |`);
31453145
}
31463146
L.push('');
3147+
// The Merge and Push columns look like they disagree when a no-op merge sits next
3148+
// to a push - which is most runs, since the merge is usually already done and the
3149+
// bump usually is not. Saying what the push carries is cheaper than everyone
3150+
// working it out from the job log each time.
3151+
L.push('**Merge and Push answer different questions.** The merge commit and ' +
3152+
'the version bump go up together in a single push, so the Push column covers the ' +
3153+
'whole leg: `already-merged` next to `would-push` means the merge was a no-op and ' +
3154+
'the snapshot bump is what would be pushed, while `nothing-to-push` means the ' +
3155+
'branch already had both.');
3156+
L.push('');
31473157
31483158
const restoredAny = mb.filter(r => r.restored);
31493159
const autoAny = mb.filter(r => r.autoResolved);

0 commit comments

Comments
 (0)