Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions .github/scripts/__tests__/rebase-plan.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
167 changes: 167 additions & 0 deletions .github/scripts/rebase-branches.sh
Original file line number Diff line number Diff line change
@@ -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 <line>.x-internal (commercial repo) is rebased onto the OSS base branch, then each
# release/<version> onto the rebased -internal branch.
# commercial each release/<version> is rebased onto the commercial <line>.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 <line>.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 <branch> <onto> [<upstream>]: 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
Loading
Loading