diff --git a/.github/scripts/__tests__/rebase-plan.test.js b/.github/scripts/__tests__/rebase-plan.test.js new file mode 100644 index 0000000..a655a07 --- /dev/null +++ b/.github/scripts/__tests__/rebase-plan.test.js @@ -0,0 +1,173 @@ +'use strict'; + +const { isHotfix, lineOf, parseTrainYml, isCloudProject, buildPlan, summarizeResults } = require('../rebase-plan'); + +const TRAIN = `state: pending +schedule: + participation: + join: 2026-10-09 +releases: + reactor: + "2025.0.8": + release-repository: reactor/reactor-commercial + deployment-destination: maven_central + state: pending + spring-cloud-config: + "4.3.6": + release-repository: spring-cloud/spring-cloud-config-commercial + initial-release-branch-type: release + deployment-destination: spring_enterprise + state: pending + commit-hashes: + joined: abc + "5.0.6": + release-repository: spring-cloud/spring-cloud-config-commercial + deployment-destination: maven_central + state: ready + "5.0.6.1": + release-repository: spring-cloud/spring-cloud-config-commercial + deployment-destination: spring_enterprise + "5.1.0-RC1": + release-repository: spring-cloud/spring-cloud-config-commercial + deployment-destination: maven_central + spring-cloud-netflix: + "3.1.10": + release-repository: spring-cloud/spring-cloud-netflix-commercial + deployment-destination: spring_enterprise + "3.1.10.1": + release-repository: spring-cloud/spring-cloud-netflix-commercial + deployment-destination: spring_enterprise + "5.0.3.1": + release-repository: spring-cloud/spring-cloud-netflix-commercial + deployment-destination: spring_enterprise + spring-boot: + "4.0.0": + release-repository: spring-projects/spring-boot-commercial + deployment-destination: maven_central +`; + +describe('version helpers', () => { + it('detects hotfixes by the fourth segment', () => { + expect(isHotfix('5.0.4.1')).toBe(true); + expect(isHotfix('5.0.4')).toBe(false); + expect(isHotfix('5.1.0-RC1')).toBe(false); + }); + + it('takes the minor line, qualifier or not', () => { + expect(lineOf('5.0.4')).toBe('5.0'); + expect(lineOf('5.0.4.1')).toBe('5.0'); + expect(lineOf('5.1.0-RC1')).toBe('5.1'); + expect(lineOf('2025.1.4')).toBe('2025.1'); + expect(lineOf('nope')).toBeNull(); + }); +}); + +describe('parseTrainYml', () => { + const entries = parseTrainYml(TRAIN); + + it('reads only the releases section, in order', () => { + expect(entries.map(e => `${e.project}@${e.version}`)).toEqual([ + 'reactor@2025.0.8', 'spring-cloud-config@4.3.6', 'spring-cloud-config@5.0.6', + 'spring-cloud-config@5.0.6.1', 'spring-cloud-config@5.1.0-RC1', 'spring-cloud-netflix@3.1.10', + 'spring-cloud-netflix@3.1.10.1', 'spring-cloud-netflix@5.0.3.1', 'spring-boot@4.0.0', + ]); + }); + + it('reads the fields of each version, not the ones nested deeper', () => { + expect(entries[1]).toEqual({ + project: 'spring-cloud-config', version: '4.3.6', + repository: 'spring-cloud/spring-cloud-config-commercial', + destination: 'spring_enterprise', state: 'pending', + }); + expect(entries[2].state).toBe('ready'); + }); + + it('keeps only spring-cloud projects', () => { + expect([...new Set(entries.filter(isCloudProject).map(e => e.project))]) + .toEqual(['spring-cloud-config', 'spring-cloud-netflix']); + }); + + it('returns nothing for a file without releases', () => { + expect(parseTrainYml('state: pending\n')).toEqual([]); + }); +}); + +describe('buildPlan', () => { + const entries = parseTrainYml(TRAIN); + const internal = (...have) => (repo, branch) => have.includes(`${repo}@${branch}`); + const jobsOf = plan => plan.jobs.map(j => `${j.project} ${j.line} ${j.mode} [${j.versions}]`); + + it('splits a project by minor line and picks the path from the destination', () => { + const plan = buildPlan({ entries, internalExists: internal() }); + expect(jobsOf(plan)).toContain('spring-cloud-config 4.3 commercial [4.3.6]'); + expect(jobsOf(plan)).toContain('spring-cloud-config 5.1 oss [5.1.0-RC1]'); + }); + + it('shares one OSS job between a release and its hotfix', () => { + const plan = buildPlan({ entries, internalExists: internal() }); + expect(jobsOf(plan)).toContain('spring-cloud-config 5.0 oss [5.0.6 5.0.6.1]'); + }); + + it('sends a hotfix down the OSS path only when the project has an -internal branch', () => { + const none = buildPlan({ entries, internalExists: internal() }); + expect(jobsOf(none)).toContain('spring-cloud-netflix 5.0 commercial [5.0.3.1]'); + + const has = buildPlan({ + entries, + internalExists: internal('spring-cloud/spring-cloud-netflix-commercial@5.0.x-internal'), + }); + expect(jobsOf(has)).toContain('spring-cloud-netflix 5.0 oss [5.0.3.1]'); + }); + + it('keeps a commercial release and its hotfix together when there is no -internal branch', () => { + const plan = buildPlan({ entries, internalExists: internal() }); + expect(jobsOf(plan)).toContain('spring-cloud-netflix 3.1 commercial [3.1.10 3.1.10.1]'); + }); + + it('filters to the projects asked for and reports names the train lacks', () => { + const plan = buildPlan({ + entries, projects: ['spring-cloud-netflix', 'spring-cloud-nope'], internalExists: internal(), + }); + expect(new Set(plan.jobs.map(j => j.project))).toEqual(new Set(['spring-cloud-netflix'])); + expect(plan.unknown).toEqual(['spring-cloud-nope']); + }); + + it('does not treat a non-cloud project as unknown or plan it', () => { + const plan = buildPlan({ entries, projects: ['spring-boot'], internalExists: internal() }); + expect(plan.jobs).toEqual([]); + expect(plan.unknown).toEqual(['spring-boot']); + }); + + it('gives the job a version to resolve the base branch from', () => { + const plan = buildPlan({ entries, internalExists: internal() }); + expect(plan.jobs.find(j => j.project === 'spring-cloud-config' && j.line === '5.0').base_version).toBe('5.0.6'); + }); +}); + +describe('summarizeResults', () => { + const r = (project, branch, status, detail = '', base = '5.0.x') => ({ project, branch, base, status, detail }); + + it('lists conflicts, blocked branches and errors as work to do by hand, first', () => { + const out = summarizeResults({ + train: '2026.10.22', dryRun: false, + results: [ + r('b', 'release/1', 'rebased'), + r('a', '5.0.x-internal', 'conflict', 'conflicts in: pom.xml'), + r('a', 'release/5.0.4', 'blocked', 'needs 5.0.x-internal rebased first'), + ], + }); + expect(out).toMatch(/2 branch\(es\) could not be rebased and must be rebased by hand/); + expect(out.indexOf('5.0.x-internal')).toBeLessThan(out.indexOf('Rebased (1)')); + expect(out).toContain('conflicts in: pom.xml'); + }); + + it('says so when everything went through, and marks a dry run', () => { + const out = summarizeResults({ + train: 't', dryRun: true, + results: [r('a', 'release/1', 'rebased'), r('a', 'release/2', 'up-to-date'), r('a', 'release/3', 'skipped')], + }); + expect(out).toMatch(/Every branch was rebased cleanly/); + expect(out).toMatch(/dry run, nothing pushed/); + expect(out).not.toMatch(/by hand/); + }); +}); diff --git a/.github/scripts/rebase-branches.sh b/.github/scripts/rebase-branches.sh new file mode 100755 index 0000000..9387fe5 --- /dev/null +++ b/.github/scripts/rebase-branches.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Rebases one project's -internal and release/* branches for one minor line, for +# rebase-release-branches.yml. The plan (rebase-plan.js) decides what each job covers. +# +# oss .x-internal (commercial repo) is rebased onto the OSS base branch, then each +# release/ onto the rebased -internal branch. +# commercial each release/ is rebased onto the commercial .x branch. +# +# The base branches are only ever read. Only -internal and release/* are rewritten, and only +# pushed when DRY_RUN is not 'true' (always with --force-with-lease against the sha that was +# rebased, so a push that landed in the meantime is not overwritten). +# +# A rebase that conflicts is aborted and recorded; the branches that depend on it are recorded +# as blocked. Nothing here exits non-zero for a conflict - the summary is where those are +# reported, so one project does not hide the rest. +# +# Environment: +# PROJECT, LINE, MODE, VERSIONS (space separated), DRY_RUN, GH_TOKEN, RESULT_FILE +# OSS_BASE OSS branch to rebase -internal onto (oss mode) +# COMMERCIAL_BASE commercial .x branch (commercial mode) +# +# One JSON object per line is appended to RESULT_FILE: +# {project, branch, base, status, detail} +# status: rebased | up-to-date | skipped | conflict | blocked | error + +set -uo pipefail + +: "${PROJECT:?}" "${LINE:?}" "${MODE:?}" "${VERSIONS:?}" "${GH_TOKEN:?}" "${RESULT_FILE:?}" +DRY_RUN="${DRY_RUN:-true}" + +COMMERCIAL_REPO="spring-cloud/${PROJECT}-commercial" +OSS_REPO="spring-cloud/${PROJECT}" +INTERNAL="${LINE}.x-internal" +WORKDIR="${WORKDIR:-repo}" +# Overridable so the git logic can be tried against local repositories. +GIT_URL_BASE="${GIT_URL_BASE:-https://x-access-token:${GH_TOKEN}@github.com}" + +record() { # status branch base detail + jq -cn --arg project "$PROJECT" --arg status "$1" --arg branch "$2" --arg base "$3" --arg detail "${4:-}" \ + '{project: $project, branch: $branch, base: $base, status: $status, detail: $detail}' >> "$RESULT_FILE" + echo "[$1] ${PROJECT} ${2}${3:+ onto ${3}}${4:+ - ${4}}" +} + +# Cross-org clones and fetches fail on a blip often enough to be worth retrying. +retry() { + local what="$1" attempt + shift + for attempt in 1 2 3; do + "$@" && return 0 + [[ $attempt -lt 3 ]] && { echo "${what}: failed, attempt ${attempt}/3 - waiting $((attempt * 10))s..."; sleep $((attempt * 10)); } + done + return 1 +} + +remote_branch_exists() { git rev-parse --verify --quiet "refs/remotes/$1/$2" > /dev/null; } + +# rebase []: leaves the branch rebased locally, or aborts. +# Returns 0 on success, 1 on conflict (aborted, conflicting files in $CONFLICTS). +rebase() { + local branch="$1" onto="$2" upstream="${3:-}" + git checkout --quiet -B "$branch" "origin/${branch}" || return 2 + if [[ -n "$upstream" ]]; then + git rebase --quiet --onto "$onto" "$upstream" "$branch" > /dev/null 2>&1 && return 0 + else + git rebase --quiet "$onto" "$branch" > /dev/null 2>&1 && return 0 + fi + CONFLICTS=$(git diff --name-only --diff-filter=U | head -10 | paste -sd, -) + git rebase --abort > /dev/null 2>&1 || true + return 1 +} + +push() { # branch old_sha + [[ "$DRY_RUN" == "true" ]] && return 0 + git push --quiet --force-with-lease="refs/heads/${1}:${2}" origin "${1}:refs/heads/${1}" +} + +CONFLICTS="" + +# Rebases one branch and records the outcome. $4 is the upstream for --onto, if any. +# Sets REBASED_OK=1 when the branch ends up on its base (rebased or already there). +process() { # branch onto onto_label [upstream] + local branch="$1" onto="$2" label="$3" upstream="${4:-}" old rc + REBASED_OK=0 + if ! remote_branch_exists origin "$branch"; then + record skipped "$branch" "$label" "branch does not exist in ${COMMERCIAL_REPO}" + return + fi + old=$(git rev-parse "refs/remotes/origin/${branch}") + if git merge-base --is-ancestor "$onto" "$old"; then + record up-to-date "$branch" "$label" + REBASED_OK=1 + return + fi + rebase "$branch" "$onto" "$upstream"; rc=$? + if [[ $rc -eq 1 ]]; then + record conflict "$branch" "$label" "conflicts in: ${CONFLICTS:-unknown}" + return + elif [[ $rc -ne 0 ]]; then + record error "$branch" "$label" "could not check out the branch" + return + fi + if ! push "$branch" "$old"; then + record error "$branch" "$label" "rebased, but the push was rejected (the branch changed during the run?)" + return + fi + record rebased "$branch" "$label" "$([[ "$DRY_RUN" == "true" ]] && echo 'dry run, not pushed')" + REBASED_OK=1 +} + +rm -rf "$WORKDIR" +retry "clone ${COMMERCIAL_REPO}" git clone --quiet "${GIT_URL_BASE}/${COMMERCIAL_REPO}.git" "$WORKDIR" || { + for v in $VERSIONS; do record error "release/${v}" "" "could not clone ${COMMERCIAL_REPO}"; done + exit 0 +} +cd "$WORKDIR" || exit 1 +git config user.name "Spring Builds" +git config user.email "svc.spring-builds@broadcom.com" + +if [[ "$MODE" == "oss" ]]; then + if [[ -z "${OSS_BASE:-}" ]]; then + record error "$INTERNAL" "" "no OSS base branch could be resolved in ${OSS_REPO}" + for v in $VERSIONS; do record blocked "release/${v}" "$INTERNAL" "needs ${INTERNAL} rebased first"; done + exit 0 + fi + git remote add oss "${GIT_URL_BASE}/${OSS_REPO}.git" + retry "fetch ${OSS_REPO}@${OSS_BASE}" git fetch --quiet oss "$OSS_BASE" || { + record error "$INTERNAL" "oss/${OSS_BASE}" "could not fetch ${OSS_REPO}@${OSS_BASE}" + for v in $VERSIONS; do record blocked "release/${v}" "$INTERNAL" "needs ${INTERNAL} rebased first"; done + exit 0 + } + oss_ref="refs/remotes/oss/${OSS_BASE}" + + if ! remote_branch_exists origin "$INTERNAL"; then + record skipped "$INTERNAL" "oss/${OSS_BASE}" "branch does not exist in ${COMMERCIAL_REPO}" + for v in $VERSIONS; do record blocked "release/${v}" "$INTERNAL" "${INTERNAL} does not exist"; done + exit 0 + fi + old_internal=$(git rev-parse "refs/remotes/origin/${INTERNAL}") + + process "$INTERNAL" "$oss_ref" "oss/${OSS_BASE}" + if [[ $REBASED_OK -ne 1 ]]; then + for v in $VERSIONS; do record blocked "release/${v}" "$INTERNAL" "needs ${INTERNAL} rebased first"; done + exit 0 + fi + # In a dry run the push is skipped, so the rebased branch only exists locally - which is + # the one the releases are rebased onto either way. + new_internal=$(git rev-parse --verify --quiet "refs/heads/${INTERNAL}" || echo "$old_internal") + + for v in $VERSIONS; do + b="release/${v}" + upstream="" + # Replay only the release branch's own commits. A plain rebase would also replay the old + # -internal commits that were just rewritten. + if remote_branch_exists origin "$b"; then + upstream=$(git merge-base "refs/remotes/origin/${b}" "$old_internal" 2> /dev/null || true) + fi + process "$b" "$new_internal" "$INTERNAL" "$upstream" + done +else + if [[ -z "${COMMERCIAL_BASE:-}" ]]; then + for v in $VERSIONS; do record error "release/${v}" "" "no ${LINE}.x branch could be resolved in ${COMMERCIAL_REPO}"; done + exit 0 + fi + for v in $VERSIONS; do + process "release/${v}" "refs/remotes/origin/${COMMERCIAL_BASE}" "$COMMERCIAL_BASE" + done +fi diff --git a/.github/scripts/rebase-plan.js b/.github/scripts/rebase-plan.js new file mode 100644 index 0000000..600caa8 --- /dev/null +++ b/.github/scripts/rebase-plan.js @@ -0,0 +1,231 @@ +'use strict'; + +// Works out which branches rebase-release-branches.yml has to rebase, from a release train's +// train.yml (spring-io/release-train, read at the tag named after the train). +// +// Every spring-cloud version in train.yml leads to release/ in the project's +// commercial repo. How that branch is brought up to date depends on where the version goes: +// +// maven_central an OSS release. .x-internal (commercial repo) is rebased onto the OSS +// .x branch, then release/ onto the rebased -internal branch. +// spring_enterprise a commercial release. release/ is rebased onto the commercial +// .x branch. +// hotfix the 4-segment versions (5.0.4.1). They are spring_enterprise in train.yml +// but cut from -internal, so when the project has an .x-internal branch +// they follow the OSS path. Without one they follow the commercial path. +// +// Only -internal and release/* branches are ever rewritten. The OSS and commercial .x +// branches are used as bases and never pushed to. +// +// -internal is shared by the whole minor line, so one job covers every version of a project in +// that line: 5.0.4 and 5.0.4.1 rebase -internal once and both release branches follow it. + +const { execFileSync } = require('child_process'); +const fs = require('fs'); + +const ORG = 'spring-cloud'; + +const isHotfix = version => /^\d+\.\d+\.\d+\.\d+$/.test(version); + +// 5.0.4 -> 5.0, 5.0.4.1 -> 5.0, 5.1.0-RC1 -> 5.1 +const lineOf = version => { + const m = String(version).match(/^(\d+)\.(\d+)\./); + return m ? `${m[1]}.${m[2]}` : null; +}; + +// Reads the `releases:` section of train.yml. The file is machine-written and regular, so +// this reads the three indent levels it uses instead of pulling in a YAML dependency (the +// scripts here have none): +// +// releases: +// spring-cloud-config: project +// "5.0.4": version +// release-repository: spring-cloud/...-commercial fields of the version +// deployment-destination: maven_central +// +// Returns [{ project, version, repository, destination, state }] in file order. +const parseTrainYml = text => { + const entries = []; + let inReleases = false; + let project = null; + let current = null; + + for (const line of String(text).split('\n')) { + if (/^\S/.test(line)) { + inReleases = /^releases:\s*$/.test(line); + project = null; + current = null; + continue; + } + if (!inReleases) continue; + + let m; + if ((m = line.match(/^ {2}([A-Za-z0-9._-]+):\s*$/))) { + project = m[1]; + current = null; + } else if (project && (m = line.match(/^ {4}"?([^":\s]+)"?:\s*$/))) { + current = { project, version: m[1], repository: '', destination: '', state: '' }; + entries.push(current); + } else if (current && (m = line.match(/^ {6}([a-z-]+):\s*(\S+)\s*$/))) { + if (m[1] === 'release-repository') current.repository = m[2]; + else if (m[1] === 'deployment-destination') current.destination = m[2]; + else if (m[1] === 'state') current.state = m[2]; + } + } + return entries; +}; + +// The spring-cloud projects among the train's entries. train.yml also carries Reactor, +// Micrometer, Spring Data and so on, none of which this repo manages. +const isCloudProject = e => e.project.startsWith('spring-cloud-') && e.repository.startsWith(`${ORG}/`); + +// Pure: entries in, plan out. `internalExists(repo, branch)` is injected so tests need no +// network. +// +// `projects` is the optional filter. Names the train does not contain come back in `unknown` +// so the summary can say so rather than silently doing nothing for a typo. +const buildPlan = ({ entries, projects = [], internalExists }) => { + const cloud = entries.filter(isCloudProject); + const wanted = new Set(projects); + const present = new Set(cloud.map(e => e.project)); + const unknown = [...wanted].filter(p => !present.has(p)); + + const skipped = []; + const lines = new Map(); + for (const e of cloud) { + if (wanted.size && !wanted.has(e.project)) continue; + const line = lineOf(e.version); + if (!line) { + skipped.push({ project: e.project, version: e.version, reason: `unusable version '${e.version}'` }); + continue; + } + const key = `${e.project}@${line}`; + if (!lines.has(key)) lines.set(key, { project: e.project, line, versions: [] }); + lines.get(key).versions.push(e); + } + + const jobs = []; + for (const { project, line, versions } of lines.values()) { + const commercialRepo = `${ORG}/${project}-commercial`; + const internalBranch = `${line}.x-internal`; + const oss = versions.some(v => v.destination === 'maven_central') || + (versions.some(v => isHotfix(v.version)) && internalExists(commercialRepo, internalBranch)); + jobs.push({ + project, + line, + mode: oss ? 'oss' : 'commercial', + // A space separated string rather than an array: matrix values go through + // fromJSON and into env vars, and the job just word-splits it. + versions: versions.map(v => v.version).join(' '), + // The version the base branch is resolved from. Every version in a line gives the same + // .x, so the first will do. + base_version: versions[0].version, + }); + } + return { jobs, skipped, unknown }; +}; + +const ghBranchExists = (repo, branch) => { + try { + execFileSync('gh', ['api', `repos/${repo}/branches/${branch}`, '--silent'], + { stdio: ['ignore', 'pipe', 'pipe'] }); + return true; + } catch (err) { + const stderr = String(err.stderr || ''); + // 404 is the answer "no". Anything else (auth, rate limit) must not be read as "this is + // not an OSS line" - that would rebase a hotfix onto the wrong base. + if (/404|Not Found/i.test(stderr)) return false; + throw new Error(`could not check ${repo} ${branch}: ${stderr.trim() || err.message}`); + } +}; + +const summarize = ({ train, jobs, skipped, unknown }) => { + const lines = [ + `### Rebase release branches - ${train}`, + '', + `**To rebase (${jobs.length})**`, + '', + ...(jobs.length + ? jobs.map(j => `- ${j.project} ${j.line}.x (${j.mode}): ${j.versions.split(' ').map(v => `\`release/${v}\``).join(', ')}`) + : ['- none']), + '', + ]; + if (skipped.length) { + lines.push(`**Skipped (${skipped.length})**`, '', + ...skipped.map(s => `- ${s.project} ${s.version}: ${s.reason}`), ''); + } + if (unknown.length) { + lines.push(`:warning: Not in the train, ignored: ${unknown.map(p => `\`${p}\``).join(', ')}`, ''); + } + return lines.join('\n'); +}; + +// The report written once every job has finished. `results` is the {project, branch, base, +// status, detail} lines rebase-branches.sh writes. Conflicts (and anything that blocked behind +// one, or errored) come first and are spelled out as work for a person, since that is the one +// thing the run cannot do itself. +const STATUS_LABEL = { rebased: 'Rebased', 'up-to-date': 'Already up to date', skipped: 'Skipped' }; + +const summarizeResults = ({ train, results, dryRun }) => { + const by = status => results.filter(r => r.status === status); + const manual = results.filter(r => ['conflict', 'blocked', 'error'].includes(r.status)); + const row = r => `| ${r.project} | \`${r.branch}\` | ${r.base ? `\`${r.base}\`` : ''} | ${r.detail || ''} |`; + const table = rows => ['| Project | Branch | Rebased onto | Detail |', '|---|---|---|---|', ...rows.map(row), '']; + + const lines = [`### Rebase release branches - ${train}${dryRun ? ' (dry run, nothing pushed)' : ''}`, '']; + if (manual.length) { + lines.push(`:x: **${manual.length} branch(es) could not be rebased and must be rebased by hand**`, '', + ...table(manual.sort((a, b) => a.project.localeCompare(b.project)))); + const conflicts = by('conflict'); + if (conflicts.length) { + lines.push('To rebase one by hand (the base branch is only read, never pushed):', '', '```', + 'git clone git@github.com:spring-cloud/-commercial.git && cd -commercial', + 'git checkout && git rebase # resolve, then: git rebase --continue', + 'git push --force-with-lease origin ', '```', '', + 'For a release/ branch after a -internal rebase, use `git rebase --onto `.', ''); + } + } else { + lines.push(':white_check_mark: Every branch was rebased cleanly or was already up to date.', ''); + } + for (const status of ['rebased', 'up-to-date', 'skipped']) { + const rows = by(status); + if (!rows.length) continue; + lines.push(`
${STATUS_LABEL[status]} (${rows.length})`, '', ...table(rows), '
', ''); + } + return lines.join('\n'); +}; + +module.exports = { isHotfix, lineOf, parseTrainYml, isCloudProject, buildPlan, summarize, summarizeResults }; + +// CLI: node rebase-plan.js [projects] +// +// `projects` is a comma separated list, blank for every spring-cloud project in the train. +// Writes `matrix` (a {include:[...]} object) and `has_work` to $GITHUB_OUTPUT and a report to +// $GITHUB_STEP_SUMMARY, when those are set. +if (require.main === module) { + const [train, projectsArg = ''] = process.argv.slice(2); + if (!train) { + process.stderr.write('usage: rebase-plan.js [comma,separated,projects]\n'); + process.exit(2); + } + try { + const raw = execFileSync('gh', ['api', `repos/spring-io/release-train/contents/train.yml?ref=${train}`, + '-H', 'Accept: application/vnd.github.raw'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + const entries = parseTrainYml(raw); + if (!entries.length) throw new Error(`no releases found in train.yml at ${train}`); + + const projects = projectsArg.split(',').map(p => p.trim()).filter(Boolean); + const plan = buildPlan({ entries, projects, internalExists: ghBranchExists }); + const report = summarize({ train, ...plan }); + + process.stdout.write(report + '\n'); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report + '\n'); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, + `matrix=${JSON.stringify({ include: plan.jobs })}\nhas_work=${plan.jobs.length > 0}\n`); + } + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } +} diff --git a/.github/scripts/rebase-summary.js b/.github/scripts/rebase-summary.js new file mode 100644 index 0000000..14255f1 --- /dev/null +++ b/.github/scripts/rebase-summary.js @@ -0,0 +1,41 @@ +'use strict'; + +// Writes the final report of rebase-release-branches.yml from the result-*.jsonl files the +// rebase jobs uploaded. +// +// node rebase-summary.js +// +// Exits 1 only when a job reported an error (a push that was rejected, a clone that failed). +// Conflicts do not fail the run: they are the expected "do this by hand" outcome and are +// reported in the summary and as warning annotations. + +const fs = require('fs'); +const path = require('path'); +const { summarizeResults } = require('./rebase-plan'); + +const [train, dryRun, dir] = process.argv.slice(2); +if (!train || !dir) { + process.stderr.write('usage: rebase-summary.js \n'); + process.exit(2); +} + +const results = []; +const walk = d => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.name.endsWith('.jsonl')) { + for (const line of fs.readFileSync(p, 'utf8').split('\n')) if (line.trim()) results.push(JSON.parse(line)); + } + } +}; +if (fs.existsSync(dir)) walk(dir); + +const report = summarizeResults({ train, results, dryRun: dryRun === 'true' }); +process.stdout.write(report + '\n'); +if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report + '\n'); + +for (const r of results.filter(x => ['conflict', 'blocked', 'error'].includes(x.status))) { + process.stdout.write(`::warning title=Rebase by hand::${r.project} ${r.branch}: ${r.status}${r.detail ? ` - ${r.detail}` : ''}\n`); +} +process.exit(results.some(r => r.status === 'error') ? 1 : 0); diff --git a/.github/workflows/README-rebase-release-branches.md b/.github/workflows/README-rebase-release-branches.md new file mode 100644 index 0000000..019989a --- /dev/null +++ b/.github/workflows/README-rebase-release-branches.md @@ -0,0 +1,45 @@ +# rebase-release-branches + +Brings the `release/` and `-internal` branches of every spring-cloud project in a Spring release train up to date with their `.x` branches. Instead of rebasing each project's branches by hand, dispatch this once per train. + +## What it does + +1. **Plans** — reads `train.yml` from `spring-io/release-train` at the tag named after the train. Each spring-cloud version in it is `release/` in `spring-cloud/-commercial`. Versions are grouped by project and minor line (`5.0.4` and `5.0.4.1` share the `5.0` line), because `-internal` is shared by the whole line. +2. **Rebases** — one job per project and line (up to 5 at once). See below. +3. **Summarizes** — lists what was rebased, what was already up to date, what was skipped, and, first, every project and branch that **must be rebased by hand**. + +## What gets rebased + +| `deployment-destination` | Steps | +|---|---| +| `maven_central` (OSS release) | 1. `.x-internal` in the commercial repo is rebased onto the OSS `.x` branch (or `main` when `main` is on that line, as for a new minor). 2. `release/` is rebased onto the rebased `.x-internal`. | +| `spring_enterprise` (commercial release) | `release/` is rebased onto the commercial `.x` branch. | +| `spring_enterprise`, 4-segment version (hotfix, e.g. `5.0.4.1`) | If the project has an `.x-internal` branch it follows the OSS steps: `-internal` is rebased once, then every `release/` branch of that line (`5.0.4` and `5.0.4.1`) onto it. Otherwise it follows the commercial steps. | + +Only `-internal` and `release/*` branches are rewritten. The OSS and commercial `.x` branches are bases: they are read and never pushed to. + +A `release/` branch following a rewritten `-internal` is rebased with `git rebase --onto `, so only its own commits are replayed. + +## Conflicts and missing branches + +- A rebase that conflicts is aborted. The branch is reported as `conflict` with the conflicting files, and the branches that depend on it (the `release/` branches behind a conflicting `-internal`) as `blocked`. Both appear under **must be rebased by hand** and as warning annotations. The other projects carry on, and the run still succeeds. +- A branch that does not exist is `skipped`. A branch already on its base is `up-to-date`, so re-running after fixing something by hand only does what is left. +- A failed clone or fetch, or a rejected push, is an `error` and fails the run. + +## Pushing + +Pushes use `--force-with-lease` against the sha that was rebased, so a commit pushed to the branch during the run is not overwritten. With `dry_run` (the default) every rebase is done locally and reported, and nothing is pushed. + +## Inputs + +| Name | Required | Default | Description | +|------|----------|---------|-------------| +| `spring_release_train` | yes | — | Release train version, the tag of `spring-io/release-train` to read, e.g. `2026.10.22` | +| `projects` | no | blank | Comma separated projects, e.g. `spring-cloud-config,spring-cloud-bus`. Blank means every spring-cloud project in `train.yml`. Names not in the train are listed as ignored. | +| `dry_run` | no | `true` | Rebase locally and report, without pushing | + +Uses the `GH_ACTIONS_REPO_TOKEN` secret. + +## Code + +[`rebase-plan.js`](../scripts/rebase-plan.js) (plan and report), [`rebase-branches.sh`](../scripts/rebase-branches.sh) (git work for one project and line) and [`rebase-summary.js`](../scripts/rebase-summary.js). diff --git a/.github/workflows/rebase-release-branches.yml b/.github/workflows/rebase-release-branches.yml new file mode 100644 index 0000000..ff423c0 --- /dev/null +++ b/.github/workflows/rebase-release-branches.yml @@ -0,0 +1,151 @@ +name: Rebase Release Branches + +run-name: "Rebase Release Branches - ${{ inputs.spring_release_train }}${{ inputs.dry_run && ' - Dry Run' || '' }}" + +# Brings the release/ and -internal branches of every spring-cloud project in a Spring release +# train up to date with their .x branches, instead of rebasing each by hand. +# +# The projects and versions come from spring-io/release-train's train.yml, read at the tag +# named after the train. Each version there is release/ in -commercial. +# +# deployment-destination: maven_central OSS release +# 1. .x-internal (commercial repo) is rebased onto the OSS .x branch +# (main when main is on that line, as for a new minor) +# 2. release/ is rebased onto the rebased .x-internal +# +# deployment-destination: spring_enterprise commercial release +# release/ is rebased onto the commercial .x branch +# +# hotfix (4-segment version, e.g. 5.0.4.1) spring_enterprise in train.yml, but when the +# project has an .x-internal branch it follows the OSS steps above, so -internal is +# rebased once and release/5.0.4 and release/5.0.4.1 both follow it. +# +# Only -internal and release/* branches are rewritten. The OSS and commercial .x branches are +# bases: they are read and never pushed. Pushes use --force-with-lease against the sha that was +# rebased. +# +# A rebase that conflicts is aborted, and the branches that depend on it are reported as +# blocked. The summary lists every such project and branch under "must be rebased by hand"; +# the other projects carry on. Branches that do not exist are skipped, and branches already on +# their base are left alone, so re-running after fixing something by hand only does what is left. +# +# dry_run (the default) does every rebase locally and reports the result without pushing. +# +# The plan is .github/scripts/rebase-plan.js, the per-project git work is +# .github/scripts/rebase-branches.sh and the report is .github/scripts/rebase-summary.js. + +on: + workflow_dispatch: + inputs: + spring_release_train: + description: 'Spring release train version, the tag of spring-io/release-train whose train.yml is used (e.g. 2026.10.22)' + required: true + type: string + projects: + description: 'Comma separated spring-cloud projects to rebase (e.g. spring-cloud-config,spring-cloud-bus). Blank rebases every spring-cloud project in train.yml.' + required: false + type: string + default: '' + dry_run: + description: 'Rebase locally and report, without pushing' + required: false + type: boolean + default: true + +permissions: + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + has_work: ${{ steps.plan.outputs.has_work }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Work out which branches need rebasing + id: plan + env: + GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + TRAIN: ${{ inputs.spring_release_train }} + PROJECTS: ${{ inputs.projects }} + run: node .github/scripts/rebase-plan.js "$TRAIN" "$PROJECTS" + + rebase: + name: ${{ matrix.project }} ${{ matrix.line }}.x (${{ matrix.mode }}) + needs: plan + if: ${{ needs.plan.outputs.has_work == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + # -internal sits on the OSS branch of the same line: .x, or main when main is on + # that line. Never fails the job - an unresolved branch is reported in the summary. + - name: Resolve the OSS base branch + id: oss_base + if: ${{ matrix.mode == 'oss' }} + uses: ./.github/actions/resolve-release-branch + with: + repo: spring-cloud/${{ matrix.project }} + version: ${{ matrix.base_version }} + commercial: 'false' + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + + # Commercial repos have no main to fall back on. + - name: Resolve the commercial base branch + id: commercial_base + if: ${{ matrix.mode == 'commercial' }} + uses: ./.github/actions/resolve-release-branch + with: + repo: spring-cloud/${{ matrix.project }}-commercial + version: ${{ matrix.base_version }} + commercial: 'true' + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + + - name: Rebase + env: + GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + PROJECT: ${{ matrix.project }} + LINE: ${{ matrix.line }} + MODE: ${{ matrix.mode }} + VERSIONS: ${{ matrix.versions }} + OSS_BASE: ${{ steps.oss_base.outputs.branch }} + COMMERCIAL_BASE: ${{ steps.commercial_base.outputs.branch }} + DRY_RUN: ${{ inputs.dry_run }} + RESULT_FILE: ${{ runner.temp }}/result.jsonl + run: .github/scripts/rebase-branches.sh + + - name: Upload result + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: result-${{ matrix.project }}-${{ matrix.line }} + path: ${{ runner.temp }}/result.jsonl + if-no-files-found: ignore + + summary: + needs: [plan, rebase] + if: ${{ always() && needs.plan.outputs.has_work == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download results + uses: actions/download-artifact@v4 + with: + pattern: result-* + path: results + + - name: Summarize + env: + TRAIN: ${{ inputs.spring_release_train }} + DRY_RUN: ${{ inputs.dry_run }} + run: node .github/scripts/rebase-summary.js "$TRAIN" "$DRY_RUN" results diff --git a/README.md b/README.md index 121e1aa..fa4af14 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Shared GitHub Actions workflows and composite actions for Spring Cloud projects. | [create-commercial-branch.yml](.github/workflows/create-commercial-branch.yml) | Simplified wrapper over `initialize-commercial-branch` for the common case: copies an OSS branch to `-commercial` using the same branch name. | — | | [create-hotfix-branch.yml](.github/workflows/create-hotfix-release-branch.yml) | Creates a commercial hotfix `release/[version]` branch, by default forked from the `-internal` branch (preserving history), or from an OSS tag when `use_tag` is set. Stamps the project version, ensures release-train workflows are present, triggers `release-train-join`, and starts CI. | [README](.github/workflows/README-create-hotfix-branch.md) | | [create-release-branches.yml](.github/workflows/create-release-branches.yml) | Runs `create-oss-release-branch`, `create-commercial-release-branch` or `create-hotfix-release-branch` (chosen from a drop-down) for every project in a release train's `jenkins-releaser-config` properties file whose `release/` branch doesn't exist yet, waiting on each run. | [README](.github/workflows/README-create-release-branches.md) | +| [rebase-release-branches.yml](.github/workflows/rebase-release-branches.yml) | Rebases the `release/` and `-internal` branches of every spring-cloud project in a Spring release train (read from `spring-io/release-train`'s `train.yml`) onto their `.x` branches, and lists the ones that conflict and need rebasing by hand. Dry run by default. | [README](.github/workflows/README-rebase-release-branches.md) | | [retire-branch.yml](.github/workflows/retire-branch.yml) | Retires a branch: removes it from `projects.json`, removes its Dependabot entries, and locks the branch permanently via the repository's `Locked Branches` ruleset. | [README](.github/workflows/README-retire-branch.md) | | [lock-branches.yml](.github/workflows/lock-unlock-branches.yml) | Locks or unlocks the branches of some or all projects via a `Release Freeze` ruleset per repository — freeze while a release is staged, unfreeze before `post-release` needs to push. Defaults to a dry run. | [README](.github/workflows/README-lock-branches.md) | | [post-release.yml](.github/workflows/post-release.yml) | Runs the chores that follow a release train: verifies every project was tagged, closes milestones, publishes GitHub releases, seeds the next snapshot properties file, opens the next milestones, merges `release/` branches back into `.x` and bumps them to the new snapshots, nudges Dependabot, opens the website PR documenting the released versions — with the announcement blog post too, on an OSS train — bumps the Spring Cloud version on start.spring.io, and rolls the release train's GitHub Project board over to the next version. Defaults to a dry run. | [README](.github/workflows/README-post-release.md) |