2121permissions :
2222 contents : write
2323 id-token : write
24+ pull-requests : read
2425
2526env :
2627 AWS_REGION : us-west-2
@@ -34,6 +35,169 @@ 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 : Validate BREAKING changes require major version bump
65+ if : steps.previous_release.outputs.tag != ''
66+ uses : actions/github-script@v8
67+ env :
68+ PREVIOUS_TAG : ${{ steps.previous_release.outputs.tag }}
69+ RELEASE_VERSION : ${{ github.event.inputs.release_version }}
70+ RELEASE_COMMITS : ${{ steps.release_commits.outputs.shas }}
71+ with :
72+ script : |
73+ const previousTag = process.env.PREVIOUS_TAG;
74+ const releaseVersion = process.env.RELEASE_VERSION;
75+ const commitShas = process.env.RELEASE_COMMITS
76+ .split('\n')
77+ .map((sha) => sha.trim())
78+ .filter(Boolean);
79+ const owner = context.repo.owner;
80+ const repo = context.repo.repo;
81+ const breakingLabel = 'BREAKING';
82+
83+ const parseMajor = (version) => {
84+ const match = version.match(/^v?(\d+)\.\d+\.\d+(?:[-+].*)?$/);
85+ if (!match) {
86+ throw new Error(`Unable to parse semantic version from "${version}"`);
87+ }
88+ return Number.parseInt(match[1], 10);
89+ };
90+
91+ const previousMajor = parseMajor(previousTag);
92+ const releaseMajor = parseMajor(releaseVersion);
93+
94+ if (commitShas.length === 0) {
95+ core.info(`No commits found between ${previousTag} and HEAD. Skipping BREAKING change validation.`);
96+ return;
97+ }
98+
99+ const prsByNumber = new Map();
100+ const linkedIssuesByPullRequest = new Map();
101+
102+ for (const commitSha of commitShas) {
103+ const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
104+ owner,
105+ repo,
106+ commit_sha: commitSha,
107+ });
108+
109+ for (const pullRequest of pullRequests) {
110+ if (pullRequest.merged_at) {
111+ prsByNumber.set(pullRequest.number, pullRequest);
112+ }
113+ }
114+ }
115+
116+ const hasBreakingLabel = (labels) =>
117+ labels.some((label) => label.name?.toUpperCase() === breakingLabel);
118+
119+ const getLinkedIssues = async (pullRequestNumber) => {
120+ if (linkedIssuesByPullRequest.has(pullRequestNumber)) {
121+ return linkedIssuesByPullRequest.get(pullRequestNumber);
122+ }
123+
124+ const query = `
125+ query($owner: String!, $repo: String!, $number: Int!) {
126+ repository(owner: $owner, name: $repo) {
127+ pullRequest(number: $number) {
128+ closingIssuesReferences(first: 50) {
129+ nodes {
130+ number
131+ labels(first: 50) {
132+ nodes {
133+ name
134+ }
135+ }
136+ }
137+ }
138+ }
139+ }
140+ }
141+ `;
142+
143+ const result = await github.graphql(query, {
144+ owner,
145+ repo,
146+ number: pullRequestNumber,
147+ });
148+ const linkedIssues =
149+ result.repository.pullRequest.closingIssuesReferences.nodes.map((issue) => ({
150+ number: issue.number,
151+ labels: issue.labels.nodes,
152+ }));
153+
154+ linkedIssuesByPullRequest.set(pullRequestNumber, linkedIssues);
155+ return linkedIssues;
156+ };
157+
158+ const breakingPullRequests = [];
159+
160+ for (const pullRequest of prsByNumber.values()) {
161+ const prHasBreakingLabel = hasBreakingLabel(pullRequest.labels);
162+ const linkedIssues = await getLinkedIssues(pullRequest.number);
163+ const breakingIssues = linkedIssues.filter((issue) => hasBreakingLabel(issue.labels));
164+
165+ if (prHasBreakingLabel || breakingIssues.length > 0) {
166+ breakingPullRequests.push({
167+ number: pullRequest.number,
168+ breakingIssues,
169+ });
170+ }
171+ }
172+
173+ if (breakingPullRequests.length === 0) {
174+ core.info(`No BREAKING-labeled pull requests or linked issues found since ${previousTag}.`);
175+ return;
176+ }
177+
178+ const formatBreakingReferences = (pullRequest) => {
179+ const issueSuffix =
180+ pullRequest.breakingIssues.length === 0
181+ ? ''
182+ : ` (linked issues: ${pullRequest.breakingIssues.map((issue) => `#${issue.number}`).join(', ')})`;
183+
184+ return `#${pullRequest.number}${issueSuffix}`;
185+ };
186+
187+ if (releaseMajor > previousMajor) {
188+ core.info(
189+ `Found BREAKING-labeled pull requests or linked issues (${breakingPullRequests
190+ .map(formatBreakingReferences)
191+ .join(', ')}), and release version ${releaseVersion} bumps the major version from ${previousMajor} to ${releaseMajor}.`
192+ );
193+ return;
194+ }
195+
196+ core.setFailed(
197+ `Release ${releaseVersion} includes BREAKING-labeled pull requests or linked issues (${breakingPullRequests
198+ .map(formatBreakingReferences)
199+ .join(', ')}) since ${previousTag}, but the major version was not bumped from ${previousMajor}.`
200+ );
37201
38202 - name : Setup Java
39203 uses : actions/setup-java@v5
0 commit comments