Skip to content

Commit 2d10cd8

Browse files
committed
chore: check BREAKING changes in a maven release
1 parent 118865a commit 2d10cd8

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

.github/workflows/release_maven.yml

Lines changed: 271 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,275 @@ 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+
const linkedIssuesByPullRequest = new Map();
122+
const issuesByNumber = new Map();
123+
124+
for (const commitSha of commitShas) {
125+
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
126+
owner,
127+
repo,
128+
commit_sha: commitSha,
129+
});
130+
131+
for (const pullRequest of pullRequests) {
132+
if (pullRequest.merged_at) {
133+
prsByNumber.set(pullRequest.number, pullRequest);
134+
}
135+
}
136+
}
137+
138+
for (const pullRequestNumber of releasePullRequestNumbers) {
139+
if (prsByNumber.has(pullRequestNumber)) {
140+
continue;
141+
}
142+
143+
const { data: pullRequest } = await github.rest.pulls.get({
144+
owner,
145+
repo,
146+
pull_number: pullRequestNumber,
147+
});
148+
149+
if (pullRequest.merged_at) {
150+
prsByNumber.set(pullRequest.number, pullRequest);
151+
}
152+
}
153+
154+
const hasBreakingLabel = (labels) =>
155+
labels.some((label) => label.name?.toUpperCase() === breakingLabel);
156+
157+
const getIssue = async (issueNumber) => {
158+
if (issuesByNumber.has(issueNumber)) {
159+
return issuesByNumber.get(issueNumber);
160+
}
161+
162+
const { data: issue } = await github.rest.issues.get({
163+
owner,
164+
repo,
165+
issue_number: issueNumber,
166+
});
167+
168+
if (issue.pull_request) {
169+
issuesByNumber.set(issueNumber, null);
170+
return null;
171+
}
172+
173+
const normalizedIssue = {
174+
number: issue.number,
175+
labels: issue.labels.map((label) => ({
176+
name: typeof label === 'string' ? label : label.name,
177+
})),
178+
};
179+
180+
issuesByNumber.set(issueNumber, normalizedIssue);
181+
return normalizedIssue;
182+
};
183+
184+
const getLinkedIssues = async (pullRequestNumber) => {
185+
if (linkedIssuesByPullRequest.has(pullRequestNumber)) {
186+
return linkedIssuesByPullRequest.get(pullRequestNumber);
187+
}
188+
189+
const query = `
190+
query($owner: String!, $repo: String!, $number: Int!) {
191+
repository(owner: $owner, name: $repo) {
192+
pullRequest(number: $number) {
193+
closingIssuesReferences(first: 50) {
194+
nodes {
195+
number
196+
labels(first: 50) {
197+
nodes {
198+
name
199+
}
200+
}
201+
}
202+
}
203+
}
204+
}
205+
}
206+
`;
207+
208+
const result = await github.graphql(query, {
209+
owner,
210+
repo,
211+
number: pullRequestNumber,
212+
});
213+
const linkedIssues = new Map(
214+
result.repository.pullRequest.closingIssuesReferences.nodes.map((issue) => [
215+
issue.number,
216+
{
217+
number: issue.number,
218+
labels: issue.labels.nodes,
219+
},
220+
])
221+
);
222+
223+
const timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
224+
owner,
225+
repo,
226+
issue_number: pullRequestNumber,
227+
per_page: 100,
228+
});
229+
230+
for (const event of timelineEvents) {
231+
if (!['connected', 'cross-referenced'].includes(event.event)) {
232+
continue;
233+
}
234+
235+
const candidateIssueNumbers = [
236+
event.source?.issue?.number,
237+
event.source?.number,
238+
event.subject?.issue?.number,
239+
event.subject?.number,
240+
].filter((number) => Number.isInteger(number));
241+
242+
for (const issueNumber of candidateIssueNumbers) {
243+
if (linkedIssues.has(issueNumber)) {
244+
continue;
245+
}
246+
247+
const issue = await getIssue(issueNumber);
248+
if (issue) {
249+
linkedIssues.set(issue.number, issue);
250+
}
251+
}
252+
}
253+
254+
const resolvedIssues = [...linkedIssues.values()];
255+
linkedIssuesByPullRequest.set(pullRequestNumber, resolvedIssues);
256+
return resolvedIssues;
257+
};
258+
259+
core.info(
260+
`Evaluating ${prsByNumber.size} pull requests since ${previousTag}: ${[...prsByNumber.keys()]
261+
.map((number) => `#${number}`)
262+
.join(', ')}`
263+
);
264+
265+
const breakingPullRequests = [];
266+
267+
for (const pullRequest of prsByNumber.values()) {
268+
const prHasBreakingLabel = hasBreakingLabel(pullRequest.labels);
269+
const linkedIssues = await getLinkedIssues(pullRequest.number);
270+
const breakingIssues = linkedIssues.filter((issue) => hasBreakingLabel(issue.labels));
271+
272+
if (prHasBreakingLabel || breakingIssues.length > 0) {
273+
breakingPullRequests.push({
274+
number: pullRequest.number,
275+
breakingIssues,
276+
});
277+
}
278+
}
279+
280+
if (breakingPullRequests.length === 0) {
281+
core.info(`No BREAKING-labeled pull requests or linked issues found since ${previousTag}.`);
282+
return;
283+
}
284+
285+
const formatBreakingReferences = (pullRequest) => {
286+
const issueSuffix =
287+
pullRequest.breakingIssues.length === 0
288+
? ''
289+
: ` (linked issues: ${pullRequest.breakingIssues.map((issue) => `#${issue.number}`).join(', ')})`;
290+
291+
return `#${pullRequest.number}${issueSuffix}`;
292+
};
293+
294+
if (releaseMajor > previousMajor) {
295+
core.info(
296+
`Found BREAKING-labeled pull requests or linked issues (${breakingPullRequests
297+
.map(formatBreakingReferences)
298+
.join(', ')}), and release version ${releaseVersion} bumps the major version from ${previousMajor} to ${releaseMajor}.`
299+
);
300+
return;
301+
}
302+
303+
core.setFailed(
304+
`Release ${releaseVersion} includes BREAKING-labeled pull requests or linked issues (${breakingPullRequests
305+
.map(formatBreakingReferences)
306+
.join(', ')}) since ${previousTag}, but the major version was not bumped from ${previousMajor}.`
307+
);
37308
38309
- name: Setup Java
39310
uses: actions/setup-java@v5

0 commit comments

Comments
 (0)