2121permissions :
2222 contents : write
2323 id-token : write
24+ issues : read
25+ pull-requests : read
2426
2527env :
2628 AWS_REGION : us-west-2
@@ -34,6 +36,307 @@ 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+ let issue;
163+ try {
164+ const response = await github.rest.issues.get({
165+ owner,
166+ repo,
167+ issue_number: issueNumber,
168+ });
169+ issue = response.data;
170+ } catch (error) {
171+ if (error.status === 404) {
172+ core.info(`Skipping unresolved linked issue #${issueNumber}.`);
173+ issuesByNumber.set(issueNumber, null);
174+ return null;
175+ }
176+ throw error;
177+ }
178+
179+ if (issue.pull_request) {
180+ issuesByNumber.set(issueNumber, null);
181+ return null;
182+ }
183+
184+ const normalizedIssue = {
185+ number: issue.number,
186+ labels: issue.labels.map((label) => ({
187+ name: typeof label === 'string' ? label : label.name,
188+ })),
189+ };
190+
191+ issuesByNumber.set(issueNumber, normalizedIssue);
192+ return normalizedIssue;
193+ };
194+
195+ const getTimelineLinkedIssueNumbers = (event) => {
196+ const currentRepoApiUrl = `https://api.github.com/repos/${owner}/${repo}`.toLowerCase();
197+ const candidateIssues = [
198+ event.source?.issue,
199+ event.subject,
200+ ].filter(
201+ (item) =>
202+ item &&
203+ Number.isInteger(item.number) &&
204+ !item.pull_request &&
205+ item.repository_url?.toLowerCase() === currentRepoApiUrl
206+ );
207+
208+ return [...new Set(candidateIssues.map((item) => item.number))];
209+ };
210+
211+ const getLinkedIssues = async (pullRequestNumber) => {
212+ if (linkedIssuesByPullRequest.has(pullRequestNumber)) {
213+ return linkedIssuesByPullRequest.get(pullRequestNumber);
214+ }
215+
216+ const query = `
217+ query($owner: String!, $repo: String!, $number: Int!) {
218+ repository(owner: $owner, name: $repo) {
219+ pullRequest(number: $number) {
220+ closingIssuesReferences(first: 50) {
221+ nodes {
222+ number
223+ repository {
224+ name
225+ owner {
226+ login
227+ }
228+ }
229+ labels(first: 50) {
230+ nodes {
231+ name
232+ }
233+ }
234+ }
235+ }
236+ }
237+ }
238+ }
239+ `;
240+
241+ const result = await github.graphql(query, {
242+ owner,
243+ repo,
244+ number: pullRequestNumber,
245+ });
246+ const isCurrentRepoIssue = (issue) =>
247+ issue.repository?.name === repo && issue.repository.owner?.login === owner;
248+ const linkedIssues = new Map(
249+ result.repository.pullRequest.closingIssuesReferences.nodes
250+ .filter(isCurrentRepoIssue)
251+ .map((issue) => [
252+ issue.number,
253+ {
254+ number: issue.number,
255+ labels: issue.labels.nodes,
256+ },
257+ ])
258+ );
259+
260+ const timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, {
261+ owner,
262+ repo,
263+ issue_number: pullRequestNumber,
264+ per_page: 100,
265+ });
266+
267+ for (const event of timelineEvents) {
268+ if (!['connected', 'cross-referenced'].includes(event.event)) {
269+ continue;
270+ }
271+
272+ const candidateIssueNumbers = getTimelineLinkedIssueNumbers(event);
273+
274+ for (const issueNumber of candidateIssueNumbers) {
275+ if (linkedIssues.has(issueNumber)) {
276+ continue;
277+ }
278+
279+ const issue = await getIssue(issueNumber);
280+ if (issue) {
281+ linkedIssues.set(issue.number, issue);
282+ }
283+ }
284+ }
285+
286+ const resolvedIssues = [...linkedIssues.values()];
287+ linkedIssuesByPullRequest.set(pullRequestNumber, resolvedIssues);
288+ return resolvedIssues;
289+ };
290+
291+ core.info(
292+ `Evaluating ${prsByNumber.size} pull requests since ${previousTag}: ${[...prsByNumber.keys()]
293+ .map((number) => `#${number}`)
294+ .join(', ')}`
295+ );
296+
297+ const breakingPullRequests = [];
298+
299+ for (const pullRequest of prsByNumber.values()) {
300+ const prHasBreakingLabel = hasBreakingLabel(pullRequest.labels);
301+ const linkedIssues = await getLinkedIssues(pullRequest.number);
302+ const breakingIssues = linkedIssues.filter((issue) => hasBreakingLabel(issue.labels));
303+
304+ if (prHasBreakingLabel || breakingIssues.length > 0) {
305+ breakingPullRequests.push({
306+ number: pullRequest.number,
307+ breakingIssues,
308+ });
309+ }
310+ }
311+
312+ if (breakingPullRequests.length === 0) {
313+ core.info(`No BREAKING-labeled pull requests or linked issues found since ${previousTag}.`);
314+ return;
315+ }
316+
317+ const formatBreakingReferences = (pullRequest) => {
318+ const issueSuffix =
319+ pullRequest.breakingIssues.length === 0
320+ ? ''
321+ : ` (linked issues: ${pullRequest.breakingIssues.map((issue) => `#${issue.number}`).join(', ')})`;
322+
323+ return `#${pullRequest.number}${issueSuffix}`;
324+ };
325+
326+ if (releaseMajor > previousMajor) {
327+ core.info(
328+ `Found BREAKING-labeled pull requests or linked issues (${breakingPullRequests
329+ .map(formatBreakingReferences)
330+ .join(', ')}), and release version ${releaseVersion} bumps the major version from ${previousMajor} to ${releaseMajor}.`
331+ );
332+ return;
333+ }
334+
335+ core.setFailed(
336+ `Release ${releaseVersion} includes BREAKING-labeled pull requests or linked issues (${breakingPullRequests
337+ .map(formatBreakingReferences)
338+ .join(', ')}) since ${previousTag}, but the major version was not bumped from ${previousMajor}.`
339+ );
37340
38341 - name : Setup Java
39342 uses : actions/setup-java@v5
0 commit comments