Skip to content

Commit 73ca6a5

Browse files
rluvatonclaudembutrovich
authored
ci: add breaking change detector (#21499)
## Which issue does this PR close? Partially closes: - #21406 ## Rationale for this change detect breaking changes ## What changes are included in this PR? add new github workflow ## Are these changes tested? Looks like it is working ## Are there any user-facing changes? no --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Matt Butrovich <mbutrovich@users.noreply.github.com>
1 parent 61fe692 commit 73ca6a5

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# Detect semver-incompatible (breaking) API changes in crates modified by a PR.
19+
#
20+
# Only public workspace crates that have file changes are checked.
21+
# Internal crates (benchmarks, test-utils, sqllogictest, doc) are excluded.
22+
#
23+
# If breaking changes are found, a sticky comment is posted on the PR.
24+
# The comment is removed automatically once the issues are resolved.
25+
26+
name: "Detect breaking changes"
27+
28+
on:
29+
pull_request:
30+
branches:
31+
- main
32+
33+
permissions:
34+
contents: read
35+
36+
jobs:
37+
check-semver:
38+
name: Check semver
39+
runs-on: ubuntu-latest
40+
outputs:
41+
logs: ${{ steps.check_semver.outputs.logs }}
42+
# Default to "success" so the comment job clears any stale comment
43+
# when the check step is skipped (e.g. no published crates changed).
44+
result: ${{ steps.check_semver.outputs.result || 'success' }}
45+
steps:
46+
- name: Checkout
47+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
48+
with:
49+
fetch-depth: 0
50+
51+
# For fork PRs, `origin` points to the fork, not the upstream repo.
52+
# Explicitly fetch the base branch from the upstream repo so we have
53+
# a valid baseline ref for both diff and semver-checks.
54+
- name: Fetch base branch
55+
env:
56+
BASE_REF: ${{ github.base_ref }}
57+
REPO: ${{ github.repository }}
58+
run: git fetch "https://github.com/${REPO}.git" "${BASE_REF}:refs/remotes/origin/${BASE_REF}"
59+
60+
- name: Determine changed crates
61+
id: changed_crates
62+
env:
63+
BASE_REF: ${{ github.base_ref }}
64+
run: |
65+
PACKAGES=$(ci/scripts/changed_crates.sh changed-crates "origin/${BASE_REF}")
66+
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
67+
echo "Changed crates: $PACKAGES"
68+
69+
- name: Install cargo-semver-checks
70+
if: steps.changed_crates.outputs.packages != ''
71+
uses: taiki-e/install-action@94cb46f8d6e437890146ffbd78a778b78e623fb2 # v2.74.0
72+
with:
73+
tool: cargo-semver-checks
74+
75+
- name: Run cargo-semver-checks
76+
id: check_semver
77+
if: steps.changed_crates.outputs.packages != ''
78+
env:
79+
BASE_REF: ${{ github.base_ref }}
80+
PACKAGES: ${{ steps.changed_crates.outputs.packages }}
81+
run: |
82+
set +e
83+
# `tee` lets cargo's output stream live into the Actions log
84+
# while we also keep a copy for the PR comment.
85+
ci/scripts/changed_crates.sh semver-check "origin/${BASE_REF}" $PACKAGES \
86+
2>&1 | tee /tmp/semver-output.txt
87+
EXIT_CODE=${PIPESTATUS[0]}
88+
{
89+
echo "logs<<EOF"
90+
sed 's/\x1b\[[0-9;]*m//g' /tmp/semver-output.txt
91+
echo "EOF"
92+
} >> "$GITHUB_OUTPUT"
93+
# Pass the result through an output instead of failing the job:
94+
# a detected breaking change should surface as a PR comment, not a
95+
# red check, so PR authors aren't confused by an intentional break.
96+
if [ "$EXIT_CODE" -eq 0 ]; then
97+
echo "result=success" >> "$GITHUB_OUTPUT"
98+
else
99+
echo "result=failure" >> "$GITHUB_OUTPUT"
100+
fi
101+
102+
# Post or remove a sticky comment on the PR based on the semver check result.
103+
comment-on-pr:
104+
name: Comment on pull request
105+
runs-on: ubuntu-latest
106+
needs: check-semver
107+
if: always()
108+
permissions:
109+
contents: read
110+
pull-requests: write
111+
steps:
112+
- name: Checkout
113+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
114+
with:
115+
sparse-checkout: ci/scripts
116+
117+
- name: Update PR comment
118+
env:
119+
GH_TOKEN: ${{ github.token }}
120+
REPO: ${{ github.repository }}
121+
PR_NUMBER: ${{ github.event.pull_request.number }}
122+
CHECK_RESULT: ${{ needs.check-semver.outputs.result }}
123+
SEMVER_LOGS: ${{ needs.check-semver.outputs.logs }}
124+
run: |
125+
ci/scripts/changed_crates.sh comment \
126+
"$REPO" "$PR_NUMBER" "$CHECK_RESULT" "$SEMVER_LOGS"

ci/scripts/changed_crates.sh

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env bash
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
# Helper script for the breaking-changes-detector workflow.
20+
#
21+
# Subcommands:
22+
# changed-crates <base_ref>
23+
# Print space-separated list of crate names whose files changed vs base_ref.
24+
# Only published workspace members (those without `publish = false`) are
25+
# considered.
26+
#
27+
# semver-check <base_ref> <packages...>
28+
# Run cargo-semver-checks for the given packages against base_ref.
29+
# Output and exit code are passed through unchanged; the caller is
30+
# responsible for capturing/formatting them.
31+
#
32+
# comment <repo> <pr_number> <check_result> [logs]
33+
# Upsert or delete a sticky PR comment based on check_result.
34+
# check_result: "success" deletes any existing comment,
35+
# anything else upserts the comment with the provided logs.
36+
# Requires GH_TOKEN to be set.
37+
38+
set -euo pipefail
39+
40+
MARKER="<!-- semver-check-comment -->"
41+
42+
# ── changed-crates ──────────────────────────────────────────────────
43+
cmd_changed_crates() {
44+
local base_ref="${1:?Usage: changed_crates.sh changed-crates <base_ref>}"
45+
46+
# 1. Files changed between the PR and the base branch.
47+
local changed_files
48+
changed_files=$(git diff --name-only "${base_ref}...HEAD")
49+
50+
# 2. Every publishable workspace member, one per line as
51+
# "<crate-name> <crate-dir>". `publish = false` in Cargo.toml shows
52+
# up as `"publish": []` in cargo metadata, so filtering on that
53+
# excludes internal crates without a manual exclusion list.
54+
local crates
55+
crates=$(cargo metadata --no-deps --format-version 1 | jq -r '
56+
(.workspace_root + "/") as $root
57+
| .packages[]
58+
| select(.publish != [])
59+
| "\(.name) \(.manifest_path | ltrimstr($root) | rtrimstr("/Cargo.toml"))"
60+
')
61+
62+
# 3. Keep crates whose directory contains a changed file.
63+
while read -r name dir; do
64+
if grep -q "^${dir}/" <<<"$changed_files"; then
65+
echo "$name"
66+
fi
67+
done <<<"$crates" | xargs
68+
}
69+
70+
# ── semver-check ────────────────────────────────────────────────────
71+
cmd_semver_check() {
72+
local base_ref="${1:?Usage: changed_crates.sh semver-check <base_ref> <packages...>}"
73+
shift
74+
75+
local args=()
76+
for pkg in "$@"; do
77+
args+=(--package "$pkg")
78+
done
79+
80+
cargo semver-checks --baseline-rev "$base_ref" "${args[@]}"
81+
}
82+
83+
# ── comment ─────────────────────────────────────────────────────────
84+
cmd_comment() {
85+
local repo="${1:?Usage: changed_crates.sh comment <repo> <pr_number> <check_result> [logs]}"
86+
local pr_number="${2:?}"
87+
local check_result="${3:?}"
88+
local logs="${4:-}"
89+
90+
# Find existing comment with our marker
91+
local comment_id
92+
comment_id=$(gh api "repos/${repo}/issues/${pr_number}/comments" \
93+
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1)
94+
95+
echo "existing breaking change comment id $comment_id"
96+
97+
if [ "$check_result" = "success" ]; then
98+
# Delete the comment if one exists
99+
if [ -n "$comment_id" ]; then
100+
echo "result is success, so deleting breaking change comment"
101+
gh api "repos/${repo}/issues/comments/${comment_id}" --method DELETE
102+
else
103+
echo "result is success and no previous comment to delete"
104+
fi
105+
else
106+
local body="${MARKER}
107+
Thank you for opening this pull request!
108+
109+
Reviewer note: [cargo-semver-checks](https://github.com/obi1kenobi/cargo-semver-checks) reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).
110+
111+
<details>
112+
<summary>Details</summary>
113+
114+
\`\`\`
115+
${logs}
116+
\`\`\`
117+
118+
</details>"
119+
120+
if [ -n "$comment_id" ]; then
121+
echo "comment already exists, updating content"
122+
gh api "repos/${repo}/issues/comments/${comment_id}" \
123+
--method PATCH --field body="$body"
124+
else
125+
echo "no comment with breaking changes, creating a new one"
126+
gh api "repos/${repo}/issues/${pr_number}/comments" \
127+
--method POST --field body="$body"
128+
fi
129+
fi
130+
}
131+
132+
# ── main ────────────────────────────────────────────────────────────
133+
cmd="${1:?Usage: changed_crates.sh <changed-crates|semver-check|comment> [args...]}"
134+
shift
135+
136+
case "$cmd" in
137+
changed-crates) cmd_changed_crates "$@" ;;
138+
semver-check) cmd_semver_check "$@" ;;
139+
comment) cmd_comment "$@" ;;
140+
*) echo "Unknown command: $cmd" >&2; exit 1 ;;
141+
esac

0 commit comments

Comments
 (0)