|
| 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 |
0 commit comments