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