Skip to content

Commit de271ed

Browse files
authored
ci: check that PR title and source code changes are aligned (#1414)
ci(versioning): add workflow to align PR description with the actual changes in the PR. chore: address PR comments. * Use cargo-metadata to parse the list of crates that has changed. * Add publish field to build-common. Co-authored-by: julio.gonzalez <[email protected]>
1 parent 893dc30 commit de271ed

File tree

2 files changed

+303
-1
lines changed

2 files changed

+303
-1
lines changed
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
name: semver-check
2+
permissions:
3+
contents: read
4+
pull-requests: read
5+
on:
6+
pull_request:
7+
types: ['opened', 'edited', 'reopened', 'synchronize']
8+
branches-ignore:
9+
- "v[0-9]+.[0-9]+.[0-9]+.[0-9]+"
10+
- release
11+
12+
env:
13+
CARGO_TERM_COLOR: always
14+
RUST_VERSION: stable
15+
16+
jobs:
17+
detect-changes:
18+
runs-on: ubuntu-latest
19+
outputs:
20+
changed_crates: ${{ steps.detect.outputs.crates }}
21+
has_rust_changes: ${{ steps.detect.outputs.has_changes }}
22+
steps:
23+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
24+
with:
25+
fetch-depth: 0
26+
persist-credentials: false
27+
28+
- name: Detect changed published crates
29+
id: detect
30+
run: |
31+
set -euo pipefail
32+
33+
# Get the base branch
34+
BASE_REF="${{ github.base_ref }}"
35+
git fetch origin "$BASE_REF" --depth=50
36+
37+
# Find all changed files
38+
CHANGED_FILES=$(git diff --name-only "origin/$BASE_REF"...HEAD)
39+
40+
# Get workspace members metadata using cargo-metadata
41+
# Filter to only workspace members that are publishable (publish != false and publish != [])
42+
# Extract workspace root and convert manifest paths to relative paths
43+
WORKSPACE_ROOT=$(cargo metadata --format-version=1 --no-deps | jq -r '.workspace_root')
44+
WORKSPACE_CRATES=$(cargo metadata --format-version=1 --no-deps | jq -c --arg root "$WORKSPACE_ROOT" '
45+
.packages[] |
46+
select(.source == null) |
47+
select(.publish == null or (.publish | type == "array" and length > 0)) |
48+
{name: .name, manifest_path: .manifest_path, relative_path: (.manifest_path | sub($root + "/"; ""))}
49+
')
50+
51+
# Array to store changed published crates
52+
CHANGED_CRATES=()
53+
54+
# Check each published crate for changes
55+
while IFS= read -r crate_info; do
56+
CRATE_NAME=$(echo "$crate_info" | jq -r '.name')
57+
RELATIVE_PATH=$(echo "$crate_info" | jq -r '.relative_path')
58+
CRATE_DIR=$(dirname "$RELATIVE_PATH")
59+
60+
# Check if any files in this crate directory changed
61+
if echo "$CHANGED_FILES" | grep -q "^${CRATE_DIR}/"; then
62+
echo "Detected change in published crate: $CRATE_NAME ($CRATE_DIR)"
63+
CHANGED_CRATES+=("$CRATE_NAME")
64+
fi
65+
done < <(echo "$WORKSPACE_CRATES")
66+
67+
# Output results
68+
if [[ ${#CHANGED_CRATES[@]} -eq 0 ]]; then
69+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
70+
echo "crates=" >> "$GITHUB_OUTPUT"
71+
echo "No published crates changed in this PR"
72+
else
73+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
74+
CRATES_JSON=$(printf '%s\n' "${CHANGED_CRATES[@]}" | jq -R . | jq -s -c .)
75+
echo "crates=$CRATES_JSON" >> "$GITHUB_OUTPUT"
76+
echo "Changed published crates: ${CHANGED_CRATES[*]}"
77+
fi
78+
79+
semver-check:
80+
needs: detect-changes
81+
if: needs.detect-changes.outputs.has_rust_changes == 'true'
82+
runs-on: ubuntu-latest
83+
outputs:
84+
semver_level: ${{ steps.semver.outputs.semver_level }}
85+
crates_checked: ${{ steps.semver.outputs.crates_checked }}
86+
steps:
87+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
88+
with:
89+
fetch-depth: 0
90+
persist-credentials: false
91+
92+
- name: Install Rust ${{ env.RUST_VERSION }}
93+
run: rustup install ${{ env.RUST_VERSION }} && rustup default ${{ env.RUST_VERSION }} && rustup install nightly --profile minimal
94+
95+
- name: Cache [rust]
96+
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # 2.8.1
97+
with:
98+
cache-targets: true
99+
100+
- name: Install dependencies
101+
run: |
102+
sudo apt update && sudo apt install -y libssl-dev # cargo-public-api dependency
103+
104+
- name: Install cargo-public-api
105+
uses: taiki-e/install-action@2c41309d51ede152b6f2ee6bf3b71e6dc9a8b7df # 2.49.27
106+
with:
107+
108+
109+
- name: Run semver checks on changed crates
110+
id: semver
111+
run: |
112+
set -euo pipefail
113+
114+
CHANGED_CRATES='${{ needs.detect-changes.outputs.changed_crates }}'
115+
116+
# Parse JSON array
117+
readarray -t CRATES < <(echo "$CHANGED_CRATES" | jq -r '.[]')
118+
119+
HIGHEST_LEVEL="none"
120+
CRATES_CHECKED=()
121+
122+
# Get the base branch for comparison
123+
BASE_REF="${{ github.base_ref }}"
124+
git fetch origin "$BASE_REF" --depth=50
125+
126+
for CRATE_NAME in "${CRATES[@]}"; do
127+
echo "========================================"
128+
echo "Checking semver for: $CRATE_NAME"
129+
echo "========================================"
130+
131+
# Try to run cargo-public-api diff against base branch
132+
set +e
133+
cargo public-api --package "$CRATE_NAME" diff "origin/$BASE_REF..HEAD" 2>&1 | tee api-output.txt
134+
EXIT_CODE=$?
135+
set -e
136+
137+
if [[ $EXIT_CODE -ne 0 ]]; then
138+
echo "Unexpected error for $CRATE_NAME (exit code: $EXIT_CODE)"
139+
continue
140+
fi
141+
142+
# Analyze the diff output
143+
LEVEL="none"
144+
145+
# Check for removed items (major change)
146+
if grep -q "Removed items from the public API$" api-output.txt; then
147+
if ! grep -A 2 "^Removed items from the public API$" api-output.txt | grep -q "^(none)$"; then
148+
LEVEL="major"
149+
echo "Detected removed items (major change)"
150+
fi
151+
fi
152+
153+
# Check for changed items (major change)
154+
if grep -q "^Changed items in the public API$" api-output.txt; then
155+
if ! grep -A 2 "^Changed items in the public API$" api-output.txt | grep -q "^(none)$"; then
156+
LEVEL="major"
157+
echo "Detected changed items (major change)"
158+
fi
159+
fi
160+
161+
# Check for added items (minor change) - only if not already major
162+
if [[ "$LEVEL" != "major" ]]; then
163+
if grep -q "Added items to the public API$" api-output.txt; then
164+
if ! grep -A 2 "^Added items to the public API$" api-output.txt | grep -q "^(none)"; then
165+
LEVEL="minor"
166+
echo "Detected added items (minor change)"
167+
fi
168+
fi
169+
fi
170+
171+
# If we detected changes, update the highest level
172+
if [[ "$LEVEL" != "none" ]]; then
173+
CRATES_CHECKED+=("$CRATE_NAME:$LEVEL")
174+
175+
# Update highest level
176+
if [[ "$LEVEL" == "major" ]]; then
177+
HIGHEST_LEVEL="major"
178+
elif [[ "$LEVEL" == "minor" ]] && [[ "$HIGHEST_LEVEL" != "major" ]]; then
179+
HIGHEST_LEVEL="minor"
180+
elif [[ "$HIGHEST_LEVEL" == "none" ]]; then
181+
HIGHEST_LEVEL="patch"
182+
fi
183+
else
184+
# No API changes detected, assume patch level
185+
if [[ "$HIGHEST_LEVEL" == "none" ]]; then
186+
HIGHEST_LEVEL="patch"
187+
fi
188+
fi
189+
done
190+
191+
# Save results to file for aggregate step
192+
echo "semver_level=$HIGHEST_LEVEL" >> "$GITHUB_OUTPUT"
193+
echo "crates_checked=${CRATES_CHECKED[*]}" >> "$GITHUB_OUTPUT"
194+
195+
validate:
196+
needs: [detect-changes, semver-check]
197+
if: needs.detect-changes.outputs.has_rust_changes == 'true'
198+
runs-on: ubuntu-latest
199+
steps:
200+
- name: Validate PR title against semver changes
201+
env:
202+
PR_TITLE: ${{ github.event.pull_request.title }}
203+
PR_BODY: ${{ github.event.pull_request.body }}
204+
SEMVER_LEVEL: ${{ needs.semver-check.outputs.semver_level }}
205+
CRATES_CHECKED: ${{ needs.semver-check.outputs.crates_checked }}
206+
run: |
207+
set -euo pipefail
208+
209+
echo "PR Title: $PR_TITLE"
210+
echo "Detected semver level: $SEMVER_LEVEL"
211+
echo "Crates with changes: $CRATES_CHECKED"
212+
213+
# Format: type(optional-scope): description
214+
# Breaking changes: type!: or type(scope)!: or BREAKING CHANGE: footer in body
215+
REGEX='^([a-z]+)(\([^)]+\))?(!)?: .+'
216+
if [[ "$PR_TITLE" =~ $REGEX ]]; then
217+
TYPE="${BASH_REMATCH[1]}"
218+
HAS_BREAKING_MARKER="${BASH_REMATCH[3]}"
219+
else
220+
echo "ERROR: Could not parse type from: $PR_TITLE"
221+
exit 1
222+
fi
223+
224+
# Check for BREAKING CHANGE: or BREAKING-CHANGE: in PR body
225+
HAS_BREAKING_FOOTER=""
226+
if echo "$PR_BODY" | grep -qE '^BREAKING[- ]CHANGE:'; then
227+
HAS_BREAKING_FOOTER="true"
228+
fi
229+
230+
# Consider it a breaking change if either marker is present
231+
IS_BREAKING_CHANGE=""
232+
if [[ -n "$HAS_BREAKING_MARKER" ]] || [[ -n "$HAS_BREAKING_FOOTER" ]]; then
233+
IS_BREAKING_CHANGE="true"
234+
fi
235+
236+
echo ""
237+
echo "Detected PR title type: $TYPE"
238+
echo "Breaking marker (!) present: ${HAS_BREAKING_MARKER:-no}"
239+
echo "Breaking footer present: ${HAS_BREAKING_FOOTER:-no}"
240+
echo "Is breaking change: ${IS_BREAKING_CHANGE:-no}"
241+
echo ""
242+
243+
VALIDATION_FAILED="false"
244+
245+
# Validation rules
246+
case "$TYPE" in
247+
fix)
248+
if [[ "$SEMVER_LEVEL" == "major" ]] || [[ "$SEMVER_LEVEL" == "minor" ]] || [[ "$SEMVER_LEVEL" == "none" ]]; then
249+
VALIDATION_FAILED="true"
250+
fi
251+
;;
252+
253+
feat)
254+
if [[ "$SEMVER_LEVEL" == "major" ]] && [[ -z "$IS_BREAKING_CHANGE" ]]; then
255+
VALIDATION_FAILED="true"
256+
elif [[ "$SEMVER_LEVEL" == "patch" ]] || [[ "$SEMVER_LEVEL" == "none" ]]; then
257+
VALIDATION_FAILED="true"
258+
fi
259+
;;
260+
261+
chore|ci|docs|style|test|build|perf)
262+
# Breaking change marker shouldn't be there.
263+
if [[ -n "$IS_BREAKING_CHANGE" ]]; then
264+
VALIDATION_FAILED="true"
265+
fi
266+
267+
# These should not change public API
268+
if [[ "$SEMVER_LEVEL" == "major" ]] || [[ "$SEMVER_LEVEL" == "minor" ]]; then
269+
VALIDATION_FAILED="true"
270+
fi
271+
;;
272+
273+
274+
refactor)
275+
if [[ "$SEMVER_LEVEL" == "major" ]] && [[ -z "$IS_BREAKING_CHANGE" ]]; then
276+
VALIDATION_FAILED="true"
277+
fi
278+
;;
279+
280+
revert)
281+
# Revert commits are allowed to have any semver level
282+
;;
283+
284+
*)
285+
echo "$TYPE not handled";
286+
VALIDATION_FAILED="true"
287+
;;
288+
esac
289+
290+
if [[ "$VALIDATION_FAILED" == "true" ]]; then
291+
echo ""
292+
echo "============================================"
293+
echo "❌ SEMVER VALIDATION FAILED"
294+
echo "============================================"
295+
echo ""
296+
echo "Details:"
297+
echo " PR Title: $PR_TITLE"
298+
echo " Detected semver level: $SEMVER_LEVEL"
299+
exit 1
300+
else
301+
exit 0
302+
fi

build-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition.workspace = true
77
version.workspace = true
88
rust-version.workspace = true
99
license.workspace = true
10-
publish=false
10+
publish = false
1111

1212
[features]
1313
default = []

0 commit comments

Comments
 (0)