Skip to content

Commit 321e218

Browse files
committed
Centralize depeendabot release train mapping to a common action
1 parent 3eecc26 commit 321e218

7 files changed

Lines changed: 172 additions & 169 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
name: 'Build Releaser Map'
2+
description: >
3+
Reads the jenkins-releaser-config branch of spring-cloud-release and
4+
spring-cloud-release-commercial and writes a {type: {project: {version: train}}} JSON
5+
map, used to resolve which GitHub Project (release train) a PR's base branch belongs to.
6+
Read-only, so it is shared by the reporting and triage workflows.
7+
8+
inputs:
9+
output-file:
10+
description: 'Path to write the JSON map to'
11+
required: false
12+
default: 'releaser-maps.json'
13+
token:
14+
description: 'GitHub token with read access to both spring-cloud-release repositories'
15+
required: true
16+
17+
outputs:
18+
map-file:
19+
description: 'Path of the JSON file written'
20+
value: ${{ steps.build.outputs.map-file }}
21+
22+
runs:
23+
using: composite
24+
steps:
25+
# The jenkins-releaser-config branch is the same for every repository, so the calling
26+
# workflow reads it once here and shares the result with its scan jobs as an artifact
27+
# rather than re-fetching it ~35 times.
28+
- name: Read jenkins-releaser-config
29+
id: build
30+
shell: bash
31+
env:
32+
GH_TOKEN: ${{ inputs.token }}
33+
OUTPUT_FILE: ${{ inputs.output-file }}
34+
run: |
35+
node - << 'JSEOF'
36+
const fs = require('fs');
37+
const { execFileSync } = require('child_process');
38+
39+
const gh = args => {
40+
try {
41+
return execFileSync('gh', args,
42+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 });
43+
} catch (err) {
44+
console.log(` gh failed: ${(err.stderr || err.message || '').split('\n')[0]}`);
45+
return null;
46+
}
47+
};
48+
49+
const SOURCES = {
50+
oss: 'spring-cloud/spring-cloud-release',
51+
commercial: 'spring-cloud/spring-cloud-release-commercial',
52+
};
53+
54+
// Reads one repository's jenkins-releaser-config branch into
55+
// {project: {version: train}}. Returns null if the branch cannot be listed at
56+
// all, so a missing fallback source is distinguishable from an empty one.
57+
const readMap = (label, repo) => {
58+
const listing = gh(['api', `repos/${repo}/contents/?ref=jenkins-releaser-config`]);
59+
if (!listing) {
60+
console.log(`${label}: could not list jenkins-releaser-config on ${repo}`);
61+
return null;
62+
}
63+
const files = JSON.parse(listing)
64+
.map(f => f.name)
65+
.filter(n => n.endsWith('-snapshot.properties'));
66+
67+
const map = {};
68+
for (const name of files) {
69+
const raw = gh(['api',
70+
`repos/${repo}/contents/${name}?ref=jenkins-releaser-config`, '--jq', '.content']);
71+
if (!raw) continue;
72+
const body = Buffer.from(raw.trim(), 'base64').toString('utf8');
73+
74+
const versions = {};
75+
for (const line of body.split('\n')) {
76+
const m = line.match(/^releaser\.fixed-versions\[(.+?)\]=(.+)$/);
77+
if (m) versions[m[1]] = m[2].trim();
78+
}
79+
80+
// The train is this file's spring-cloud-release version, which is also the
81+
// title of the org-level GitHub Project board.
82+
const train = (versions['spring-cloud-release'] || '').replace(/-SNAPSHOT$/, '');
83+
if (!train) continue;
84+
85+
for (const [project, version] of Object.entries(versions)) {
86+
map[project] = map[project] || {};
87+
map[project][version] = train;
88+
}
89+
}
90+
console.log(`${label}: ${files.length} snapshot file(s), ` +
91+
`${Object.keys(map).length} project(s) mapped`);
92+
return map;
93+
};
94+
95+
const out = {};
96+
for (const [type, repo] of Object.entries(SOURCES)) {
97+
out[type] = readMap(type, repo) || {};
98+
}
99+
100+
// Gap-fill the OSS map from the commercial one. Commercial is where a train's
101+
// snapshot file lands first - when a train is opened and the branches are bumped,
102+
// the OSS properties file can lag by days, and every PR against a bumped branch
103+
// resolves to no train in the meantime. The commercial files record plain OSS
104+
// versions (5.1.0-SNAPSHOT, 2026.0.0-SNAPSHOT) for exactly these projects, so
105+
// they are a faithful stand-in.
106+
//
107+
// Only versions the OSS files do not already carry are taken, so an authoritative
108+
// OSS mapping is never overwritten by the fallback.
109+
//
110+
// Commercial-only trains are skipped: their titles (2025.1.2.1,
111+
// 2025.1.3-INTERNAL) have no OSS project board, so adopting one would turn
112+
// "could not resolve project" into a confident pointer at a board that does not
113+
// exist. Only plain YYYY.N.N trains are boards on the OSS side.
114+
const OSS_TRAIN = /^\d{4}\.\d+\.\d+$/;
115+
let filled = 0;
116+
for (const [project, versions] of Object.entries(out.commercial || {})) {
117+
for (const [version, train] of Object.entries(versions)) {
118+
if (!OSS_TRAIN.test(train)) continue;
119+
out.oss[project] = out.oss[project] || {};
120+
if (out.oss[project][version]) continue;
121+
out.oss[project][version] = train;
122+
filled++;
123+
console.log(` oss gap-filled from commercial: ${project} ${version} -> ${train}`);
124+
}
125+
}
126+
console.log(`oss: ${filled} version(s) gap-filled from commercial`);
127+
128+
const outputFile = process.env.OUTPUT_FILE || 'releaser-maps.json';
129+
fs.writeFileSync(outputFile, JSON.stringify(out, null, 2));
130+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `map-file=${outputFile}\n`);
131+
JSEOF

.github/workflows/README-dependabot-report.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ targeting a branch that is no longer maintained.
1010
1. **`setup`** expands [`config/projects.json`](../../config/projects.json) into one matrix
1111
entry per repository (not per branch — Dependabot PRs are listed repo-wide, so fanning
1212
out per branch would fetch the same list several times).
13-
2. **`releaser-map`** reads the `jenkins-releaser-config` branch of `spring-cloud-release`
14-
and `spring-cloud-release-commercial` once, building a `{type: {project: {version:
15-
train}}}` map, then gap-fills the OSS side from the commercial side, and shares the
16-
result with the scan jobs as an artifact. This is what resolves which GitHub Project a
17-
PR belongs to — see [Project resolution](#project-resolution).
13+
2. **`releaser-map`** runs the shared
14+
[`releaser-map`](../actions/releaser-map/action.yml) action, which reads the
15+
`jenkins-releaser-config` branch of `spring-cloud-release` and
16+
`spring-cloud-release-commercial` once, builds a `{type: {project: {version: train}}}`
17+
map, and gap-fills the OSS side from the commercial side. The job shares the result
18+
with the scan jobs as an artifact. This is what resolves which GitHub Project a PR
19+
belongs to — see [Project resolution](#project-resolution).
1820
3. **`scan`** runs the [`dependabot-scan`](../actions/dependabot-scan/action.yml) action
1921
per repository and uploads its JSON result.
2022
4. **`summary`** merges every result into a job-summary table plus detail sections, and
@@ -138,6 +140,17 @@ OSS mapping is never overwritten. Commercial-only trains are skipped — titles
138140
adopted. Their version keys (`5.0.2.1-SNAPSHOT`, `5.0.3-INTERNAL-SNAPSHOT`) cannot collide
139141
with an OSS branch version anyway.
140142

143+
The fallback lives in the shared [`releaser-map`](../actions/releaser-map/action.yml)
144+
action rather than inline in this workflow. It was inline at first, and
145+
`dependabot-triage.yml` carried its own copy of the same script — so the fix reached the
146+
report and not triage, and triage kept reporting "no train resolved" for a week. Both
147+
workflows now call the one action.
148+
149+
It cannot cover a branch whose version is in *neither* config. When 2026.0.0 opened,
150+
`spring-cloud-task` and `spring-cloud-vault` on `5.0.x` were at `5.0.4-SNAPSHOT` while both
151+
configs recorded `5.0.3-SNAPSHOT` for them, so those two stayed unresolved. That is a
152+
missing properties entry, not something the report can infer.
153+
141154
This report only *resolves* the expected board; it does not read board membership, which
142155
would need the `project` scope the token may not have.
143156

.github/workflows/README-dependabot-triage.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ re-run with `dry_run` unchecked.
1616

1717
1. **`setup`** expands [`config/projects.json`](../../config/projects.json) into one matrix
1818
entry per repository.
19-
2. **`releaser-map`** reads the `jenkins-releaser-config` branch once and shares it as an
20-
artifact — same as the report.
19+
2. **`releaser-map`** runs the shared
20+
[`releaser-map`](../actions/releaser-map/action.yml) action and shares its output as an
21+
artifact — the identical job the report runs, calling the identical action, so a PR can
22+
never resolve to one train in the report and another here. See
23+
[Project resolution](README-dependabot-report.md#project-resolution).
2124
3. **`triage`** runs the shared [`dependabot-scan`](../actions/dependabot-scan/action.yml)
2225
action per repository, then applies the three actions below.
2326
4. **`summary`** merges the per-repo results into one job-summary table.

.github/workflows/dependabot-report.yml

Lines changed: 6 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -126,107 +126,13 @@ jobs:
126126
name: Build Releaser Map
127127
runs-on: ubuntu-latest
128128
steps:
129-
# Resolving a PR's GitHub Project means mapping its base-branch version to a
130-
# release train via the *-snapshot.properties files on the jenkins-releaser-config
131-
# branch of spring-cloud-release. That branch is the same for every repository, so
132-
# it is read once here and shared with the scan jobs as an artifact rather than
133-
# re-fetched ~35 times.
134-
#
135-
# The OSS map is then gap-filled from spring-cloud-release-commercial's copy of the
136-
# same branch. Commercial is where a train's snapshot file lands.
137-
- name: Read jenkins-releaser-config
138-
env:
139-
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
140-
run: |
141-
node - << 'JSEOF'
142-
const fs = require('fs');
143-
const { execFileSync } = require('child_process');
144-
145-
const gh = args => {
146-
try {
147-
return execFileSync('gh', args,
148-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 });
149-
} catch (err) {
150-
console.log(` gh failed: ${(err.stderr || err.message || '').split('\n')[0]}`);
151-
return null;
152-
}
153-
};
154-
155-
const SOURCES = {
156-
oss: 'spring-cloud/spring-cloud-release',
157-
commercial: 'spring-cloud/spring-cloud-release-commercial',
158-
};
159-
160-
// Reads one repository's jenkins-releaser-config branch into
161-
// {project: {version: train}}. Returns null if the branch cannot be listed at
162-
// all, so a missing fallback source is distinguishable from an empty one.
163-
const readMap = (label, repo) => {
164-
const listing = gh(['api', `repos/${repo}/contents/?ref=jenkins-releaser-config`]);
165-
if (!listing) {
166-
console.log(`${label}: could not list jenkins-releaser-config on ${repo}`);
167-
return null;
168-
}
169-
const files = JSON.parse(listing)
170-
.map(f => f.name)
171-
.filter(n => n.endsWith('-snapshot.properties'));
172-
173-
const map = {};
174-
for (const name of files) {
175-
const raw = gh(['api',
176-
`repos/${repo}/contents/${name}?ref=jenkins-releaser-config`, '--jq', '.content']);
177-
if (!raw) continue;
178-
const body = Buffer.from(raw.trim(), 'base64').toString('utf8');
179-
180-
const versions = {};
181-
for (const line of body.split('\n')) {
182-
const m = line.match(/^releaser\.fixed-versions\[(.+?)\]=(.+)$/);
183-
if (m) versions[m[1]] = m[2].trim();
184-
}
185-
186-
// The train is this file's spring-cloud-release version, which is also the
187-
// title of the org-level GitHub Project board.
188-
const train = (versions['spring-cloud-release'] || '').replace(/-SNAPSHOT$/, '');
189-
if (!train) continue;
190-
191-
for (const [project, version] of Object.entries(versions)) {
192-
map[project] = map[project] || {};
193-
map[project][version] = train;
194-
}
195-
}
196-
console.log(`${label}: ${files.length} snapshot file(s), ` +
197-
`${Object.keys(map).length} project(s) mapped`);
198-
return map;
199-
};
200-
201-
const out = {};
202-
for (const [type, repo] of Object.entries(SOURCES)) {
203-
out[type] = readMap(type, repo) || {};
204-
}
205-
206-
// Gap-fill the OSS map from the commercial one. Only versions the OSS files do
207-
// not already carry are taken, so an authoritative OSS mapping is never
208-
// overwritten by the fallback.
209-
//
210-
// Commercial-only trains are skipped: their titles (2025.1.2.1,
211-
// 2025.1.3-INTERNAL) have no OSS project board, so adopting one would turn
212-
// "could not resolve project" into a confident pointer at a board that does not
213-
// exist. Only plain YYYY.N.N trains are boards on the OSS side.
214-
const OSS_TRAIN = /^\d{4}\.\d+\.\d+$/;
215-
let filled = 0;
216-
for (const [project, versions] of Object.entries(out.commercial || {})) {
217-
for (const [version, train] of Object.entries(versions)) {
218-
if (!OSS_TRAIN.test(train)) continue;
219-
out.oss[project] = out.oss[project] || {};
220-
if (out.oss[project][version]) continue;
221-
out.oss[project][version] = train;
222-
filled++;
223-
console.log(` oss gap-filled from commercial: ${project} ${version} -> ${train}`);
224-
}
225-
}
226-
console.log(`oss: ${filled} version(s) gap-filled from commercial`);
129+
- name: Checkout
130+
uses: actions/checkout@v4
227131

228-
fs.writeFileSync('releaser-maps.json', JSON.stringify(out, null, 2));
229-
JSEOF
132+
- name: Build releaser map
133+
uses: ./.github/actions/releaser-map
134+
with:
135+
token: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
230136

231137
- name: Upload releaser map
232138
uses: actions/upload-artifact@v4

.github/workflows/dependabot-triage.yml

Lines changed: 6 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -145,69 +145,13 @@ jobs:
145145
name: Build Releaser Map
146146
runs-on: ubuntu-latest
147147
steps:
148-
# Identical to dependabot-report.yml: the jenkins-releaser-config branch is the same
149-
# for every repository, so it is read once and shared as an artifact.
150-
- name: Read jenkins-releaser-config
151-
env:
152-
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
153-
run: |
154-
node - << 'JSEOF'
155-
const fs = require('fs');
156-
const { execFileSync } = require('child_process');
157-
158-
const gh = args => {
159-
try {
160-
return execFileSync('gh', args,
161-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 });
162-
} catch (err) {
163-
console.log(` gh failed: ${(err.stderr || err.message || '').split('\n')[0]}`);
164-
return null;
165-
}
166-
};
167-
168-
const SOURCES = {
169-
oss: 'spring-cloud/spring-cloud-release',
170-
commercial: 'spring-cloud/spring-cloud-release-commercial',
171-
};
172-
173-
const out = {};
174-
for (const [type, repo] of Object.entries(SOURCES)) {
175-
out[type] = {};
176-
const listing = gh(['api', `repos/${repo}/contents/?ref=jenkins-releaser-config`]);
177-
if (!listing) {
178-
console.log(`${type}: could not list jenkins-releaser-config on ${repo}`);
179-
continue;
180-
}
181-
const files = JSON.parse(listing)
182-
.map(f => f.name)
183-
.filter(n => n.endsWith('-snapshot.properties'));
184-
185-
for (const name of files) {
186-
const raw = gh(['api',
187-
`repos/${repo}/contents/${name}?ref=jenkins-releaser-config`, '--jq', '.content']);
188-
if (!raw) continue;
189-
const body = Buffer.from(raw.trim(), 'base64').toString('utf8');
190-
191-
const versions = {};
192-
for (const line of body.split('\n')) {
193-
const m = line.match(/^releaser\.fixed-versions\[(.+?)\]=(.+)$/);
194-
if (m) versions[m[1]] = m[2].trim();
195-
}
196-
197-
const train = (versions['spring-cloud-release'] || '').replace(/-SNAPSHOT$/, '');
198-
if (!train) continue;
199-
200-
for (const [project, version] of Object.entries(versions)) {
201-
out[type][project] = out[type][project] || {};
202-
out[type][project][version] = train;
203-
}
204-
}
205-
console.log(`${type}: ${files.length} snapshot file(s), ` +
206-
`${Object.keys(out[type]).length} project(s) mapped`);
207-
}
148+
- name: Checkout
149+
uses: actions/checkout@v4
208150

209-
fs.writeFileSync('releaser-maps.json', JSON.stringify(out, null, 2));
210-
JSEOF
151+
- name: Build releaser map
152+
uses: ./.github/actions/releaser-map
153+
with:
154+
token: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
211155

212156
- name: Upload releaser map
213157
uses: actions/upload-artifact@v4

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Shared GitHub Actions workflows and composite actions for Spring Cloud projects.
5959
| [retire-branch-projects-json](.github/actions/retire-branch-projects-json/) | Updates `config/projects.json` when a branch is retired: removes it from `scheduled` and `jdkVersions`. Fails fast if the branch is still set as the default. | [README](.github/actions/retire-branch-projects-json/README.md) |
6060
| [trigger-branch-ci](.github/actions/trigger-branch-ci/) | Dispatches the `ci.yml` or `ci.yaml` workflow for each non-default branch in a Spring Cloud project. | [README](.github/actions/trigger-branch-ci/README.md) |
6161
| [dependabot-scan](.github/actions/dependabot-scan/) | Scans one repository for open Dependabot PRs and the state of its Dependabot update jobs, classifies each PR, and writes the result as JSON. Read-only, so [reporting](.github/workflows/README-dependabot-report.md) and [triage](.github/workflows/README-dependabot-triage.md) share it. | [README](.github/workflows/README-dependabot-report.md) |
62+
| [releaser-map](.github/actions/releaser-map/) | Builds the `{type: {project: {version: train}}}` map that resolves a PR's base branch to its release train, from the `jenkins-releaser-config` branch of `spring-cloud-release` and `spring-cloud-release-commercial`, gap-filling the OSS side from commercial. Shared by [reporting](.github/workflows/README-dependabot-report.md) and [triage](.github/workflows/README-dependabot-triage.md). | [README](.github/workflows/README-dependabot-report.md#project-resolution) |
6263
| [resolve-actions-ref](.github/actions/resolve-actions-ref/) | Resolves the latest published release of this repository to a commit SHA plus its tag. The single lookup used by everything that writes a ref into another repository. | [README](.github/actions/resolve-actions-ref/README.md) |
6364
| [sync-actions-ref](.github/actions/sync-actions-ref/) | Repoints references to this repository in one branch of one repository at a given SHA, with the tag as a trailing comment. Idempotent. | [README](.github/actions/sync-actions-ref/README.md) |
6465
| [set-commercial-creds-env-vars](.github/actions/set-commercial-creds-env-vars/) | Sets `COMMERCIAL_ARTIFACTORY_USERNAME/PASSWORD` environment variables, falling back to read-only credentials during PR builds. | [README](.github/actions/set-commercial-creds-env-vars/README.md) |

0 commit comments

Comments
 (0)