Skip to content

Commit cfe09b4

Browse files
authored
chore: check BREAKING changes in a maven release (#441)
1 parent eff8dc9 commit cfe09b4

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

.github/workflows/release_maven.yml

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ on:
2121
permissions:
2222
contents: write
2323
id-token: write
24+
issues: read
25+
pull-requests: read
2426

2527
env:
2628
AWS_REGION: us-west-2
@@ -34,6 +36,232 @@ jobs:
3436
uses: actions/checkout@v6
3537
with:
3638
fetch-depth: 0
39+
40+
- name: Determine previous release tag
41+
id: previous_release
42+
shell: bash
43+
run: |
44+
previous_tag="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | head -n 1)"
45+
if [ -z "$previous_tag" ]; then
46+
echo "No previous release tag found. Skipping BREAKING change validation."
47+
echo "tag=" >> "$GITHUB_OUTPUT"
48+
exit 0
49+
fi
50+
51+
echo "Found previous release tag: $previous_tag"
52+
echo "tag=$previous_tag" >> "$GITHUB_OUTPUT"
53+
54+
- name: Collect release commit SHAs
55+
id: release_commits
56+
if: steps.previous_release.outputs.tag != ''
57+
shell: bash
58+
run: |
59+
{
60+
echo "shas<<EOF"
61+
git rev-list "${{ steps.previous_release.outputs.tag }}..HEAD"
62+
echo "EOF"
63+
} >> "$GITHUB_OUTPUT"
64+
65+
- name: Collect release pull request numbers from git history
66+
id: release_pull_requests
67+
if: steps.previous_release.outputs.tag != ''
68+
shell: bash
69+
run: |
70+
{
71+
echo "numbers<<EOF"
72+
git log --format='%s' "${{ steps.previous_release.outputs.tag }}..HEAD" |
73+
sed -nE 's/^.*\(#([0-9]+)\)$/\1/p; s/^Merge pull request #([0-9]+).*$/\1/p' |
74+
sort -u
75+
echo "EOF"
76+
} >> "$GITHUB_OUTPUT"
77+
78+
- name: Validate BREAKING changes require major version bump
79+
if: steps.previous_release.outputs.tag != ''
80+
uses: actions/github-script@v8
81+
env:
82+
PREVIOUS_TAG: ${{ steps.previous_release.outputs.tag }}
83+
RELEASE_VERSION: ${{ github.event.inputs.release_version }}
84+
RELEASE_COMMITS: ${{ steps.release_commits.outputs.shas }}
85+
RELEASE_PULL_REQUESTS: ${{ steps.release_pull_requests.outputs.numbers }}
86+
with:
87+
script: |
88+
const previousTag = process.env.PREVIOUS_TAG;
89+
const releaseVersion = process.env.RELEASE_VERSION;
90+
const commitShas = process.env.RELEASE_COMMITS
91+
.split('\n')
92+
.map((sha) => sha.trim())
93+
.filter(Boolean);
94+
const releasePullRequestNumbers = process.env.RELEASE_PULL_REQUESTS
95+
.split('\n')
96+
.map((number) => number.trim())
97+
.filter(Boolean)
98+
.map((number) => Number.parseInt(number, 10))
99+
.filter((number) => Number.isInteger(number));
100+
const owner = context.repo.owner;
101+
const repo = context.repo.repo;
102+
const breakingLabel = 'BREAKING';
103+
104+
const parseMajor = (version) => {
105+
const match = version.match(/^v?(\d+)\.\d+\.\d+(?:[-+].*)?$/);
106+
if (!match) {
107+
throw new Error(`Unable to parse semantic version from "${version}"`);
108+
}
109+
return Number.parseInt(match[1], 10);
110+
};
111+
112+
const previousMajor = parseMajor(previousTag);
113+
const releaseMajor = parseMajor(releaseVersion);
114+
115+
if (commitShas.length === 0) {
116+
core.info(`No commits found between ${previousTag} and HEAD. Skipping BREAKING change validation.`);
117+
return;
118+
}
119+
120+
const prsByNumber = new Map();
121+
for (const commitSha of commitShas) {
122+
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
123+
owner,
124+
repo,
125+
commit_sha: commitSha,
126+
});
127+
128+
for (const pullRequest of pullRequests) {
129+
if (pullRequest.merged_at) {
130+
prsByNumber.set(pullRequest.number, pullRequest);
131+
}
132+
}
133+
}
134+
135+
for (const pullRequestNumber of releasePullRequestNumbers) {
136+
if (prsByNumber.has(pullRequestNumber)) {
137+
continue;
138+
}
139+
140+
const { data: pullRequest } = await github.rest.pulls.get({
141+
owner,
142+
repo,
143+
pull_number: pullRequestNumber,
144+
});
145+
146+
if (pullRequest.merged_at) {
147+
prsByNumber.set(pullRequest.number, pullRequest);
148+
}
149+
}
150+
151+
const hasBreakingLabel = (labels) =>
152+
labels.some((label) => label.name?.toUpperCase() === breakingLabel);
153+
154+
const getTimelineLinkedPullRequestNumbers = (event) => {
155+
const currentRepoApiUrl = `https://api.github.com/repos/${owner}/${repo}`.toLowerCase();
156+
const candidatePullRequests = [
157+
event.source?.issue,
158+
event.subject,
159+
].filter(
160+
(item) =>
161+
item &&
162+
Number.isInteger(item.number) &&
163+
item.pull_request &&
164+
item.repository_url?.toLowerCase() === currentRepoApiUrl
165+
);
166+
167+
return [...new Set(candidatePullRequests.map((item) => item.number))];
168+
};
169+
170+
core.info(
171+
`Evaluating ${prsByNumber.size} pull requests since ${previousTag}: ${[...prsByNumber.keys()]
172+
.map((number) => `#${number}`)
173+
.join(', ')}`
174+
);
175+
176+
const releasePullRequestNumbersSet = new Set(prsByNumber.keys());
177+
const breakingIssuesByPullRequest = new Map();
178+
const breakingIssues = (
179+
await github.paginate(github.rest.issues.listForRepo, {
180+
owner,
181+
repo,
182+
labels: breakingLabel,
183+
state: 'all',
184+
per_page: 100,
185+
})
186+
).filter((issue) => !issue.pull_request);
187+
188+
core.info(
189+
`Evaluating ${breakingIssues.length} BREAKING-labeled issues in ${owner}/${repo}: ${breakingIssues
190+
.map((issue) => `#${issue.number}`)
191+
.join(', ')}`
192+
);
193+
194+
for (const issue of breakingIssues) {
195+
const timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
196+
owner,
197+
repo,
198+
issue_number: issue.number,
199+
per_page: 100,
200+
});
201+
202+
const linkedReleasePullRequestNumbers = new Set();
203+
204+
for (const event of timelineEvents) {
205+
if (!['connected', 'cross-referenced'].includes(event.event)) {
206+
continue;
207+
}
208+
209+
for (const pullRequestNumber of getTimelineLinkedPullRequestNumbers(event)) {
210+
if (releasePullRequestNumbersSet.has(pullRequestNumber)) {
211+
linkedReleasePullRequestNumbers.add(pullRequestNumber);
212+
}
213+
}
214+
}
215+
216+
for (const pullRequestNumber of linkedReleasePullRequestNumbers) {
217+
const linkedIssues = breakingIssuesByPullRequest.get(pullRequestNumber) ?? [];
218+
linkedIssues.push({ number: issue.number });
219+
breakingIssuesByPullRequest.set(pullRequestNumber, linkedIssues);
220+
}
221+
}
222+
223+
const breakingPullRequests = [];
224+
225+
for (const pullRequest of prsByNumber.values()) {
226+
const prHasBreakingLabel = hasBreakingLabel(pullRequest.labels);
227+
const breakingIssues = breakingIssuesByPullRequest.get(pullRequest.number) ?? [];
228+
229+
if (prHasBreakingLabel || breakingIssues.length > 0) {
230+
breakingPullRequests.push({
231+
number: pullRequest.number,
232+
breakingIssues,
233+
});
234+
}
235+
}
236+
237+
if (breakingPullRequests.length === 0) {
238+
core.info(`No BREAKING-labeled pull requests or linked issues found since ${previousTag}.`);
239+
return;
240+
}
241+
242+
const formatBreakingReferences = (pullRequest) => {
243+
const issueSuffix =
244+
pullRequest.breakingIssues.length === 0
245+
? ''
246+
: ` (linked issues: ${pullRequest.breakingIssues.map((issue) => `#${issue.number}`).join(', ')})`;
247+
248+
return `#${pullRequest.number}${issueSuffix}`;
249+
};
250+
251+
if (releaseMajor > previousMajor) {
252+
core.info(
253+
`Found BREAKING-labeled pull requests or linked issues (${breakingPullRequests
254+
.map(formatBreakingReferences)
255+
.join(', ')}), and release version ${releaseVersion} bumps the major version from ${previousMajor} to ${releaseMajor}.`
256+
);
257+
return;
258+
}
259+
260+
core.setFailed(
261+
`Release ${releaseVersion} includes BREAKING-labeled pull requests or linked issues (${breakingPullRequests
262+
.map(formatBreakingReferences)
263+
.join(', ')}) since ${previousTag}, but the major version was not bumped from ${previousMajor}.`
264+
);
37265
38266
- name: Setup Java
39267
uses: actions/setup-java@v5

0 commit comments

Comments
 (0)