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,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