Skip to content

Commit fdc6038

Browse files
committed
Add workflow to align PR description with the actual changes in the PR.
1 parent 23b0837 commit fdc6038

File tree

1 file changed

+299
-0
lines changed

1 file changed

+299
-0
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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: 1.84.1
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+
# Array to store changed published crates
41+
CHANGED_CRATES=()
42+
43+
# Read workspace members from Cargo.toml
44+
while IFS= read -r crate_path; do
45+
# Skip empty lines
46+
if [[ -z "$crate_path" ]]; then
47+
continue
48+
fi
49+
50+
# Check if any files in this crate directory changed
51+
if echo "$CHANGED_FILES" | grep -q "^${crate_path}/"; then
52+
CRATE_MANIFEST="${crate_path}/Cargo.toml"
53+
54+
# Skip if Cargo.toml doesn't exist
55+
if [[ ! -f "$CRATE_MANIFEST" ]]; then
56+
continue
57+
fi
58+
59+
# Check if crate has "publish = false"
60+
if grep -q "^publish = false" "$CRATE_MANIFEST"; then
61+
echo "Skipping unpublished crate: $crate_path"
62+
continue
63+
fi
64+
65+
# Extract crate name
66+
CRATE_NAME=$(grep "^name = " "$CRATE_MANIFEST" | head -1 | sed 's/name = "\(.*\)"/\1/')
67+
68+
if [[ -n "$CRATE_NAME" ]]; then
69+
echo "Detected change in published crate: $CRATE_NAME ($crate_path)"
70+
CHANGED_CRATES+=("$CRATE_NAME")
71+
fi
72+
fi
73+
done < <(sed -n 's/^ "\(.*\)",\?$/\1/p' Cargo.toml)
74+
75+
# Output results
76+
if [[ ${#CHANGED_CRATES[@]} -eq 0 ]]; then
77+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
78+
echo "crates=" >> "$GITHUB_OUTPUT"
79+
echo "No published crates changed in this PR"
80+
else
81+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
82+
# Create JSON array
83+
CRATES_JSON=$(printf '%s\n' "${CHANGED_CRATES[@]}" | jq -R . | jq -s .)
84+
echo "crates=$CRATES_JSON" >> "$GITHUB_OUTPUT"
85+
echo "Changed published crates: ${CHANGED_CRATES[*]}"
86+
fi
87+
88+
semver-check:
89+
needs: detect-changes
90+
if: needs.detect-changes.outputs.has_rust_changes == 'true'
91+
runs-on: ubuntu-latest
92+
outputs:
93+
semver_level: ${{ steps.aggregate.outputs.semver_level }}
94+
failed_crates: ${{ steps.aggregate.outputs.failed_crates }}
95+
skipped_crates: ${{ steps.aggregate.outputs.skipped_crates }}
96+
steps:
97+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
98+
with:
99+
fetch-depth: 0
100+
persist-credentials: false
101+
102+
- name: Install Rust ${{ env.RUST_VERSION }}
103+
run: rustup install ${{ env.RUST_VERSION }} && rustup default ${{ env.RUST_VERSION }}
104+
105+
- name: Cache [rust]
106+
uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # 2.8.1
107+
with:
108+
cache-targets: true
109+
110+
- name: Install cargo-semver-checks
111+
run: |
112+
cargo install cargo-semver-checks --locked --version 0.44.0
113+
114+
- name: Run semver checks on changed crates
115+
id: semver
116+
run: |
117+
set -euo pipefail
118+
119+
CHANGED_CRATES='${{ needs.detect-changes.outputs.changed_crates }}'
120+
121+
# Parse JSON array
122+
readarray -t CRATES < <(echo "$CHANGED_CRATES" | jq -r '.[]')
123+
124+
HIGHEST_LEVEL="none"
125+
CRATES_CHECKED=()
126+
127+
for CRATE_NAME in "${CRATES[@]}"; do
128+
echo "========================================"
129+
echo "Checking semver for: $CRATE_NAME"
130+
echo "========================================"
131+
132+
# Try to run cargo-semver-checks
133+
set +e
134+
cargo semver-checks check-release --package "$CRATE_NAME" --verbose 2>&1 | tee semver-output.txt
135+
EXIT_CODE=$?
136+
set -e
137+
138+
if [[ $EXIT_CODE -ne 0 ]]; then
139+
# Check if failure is due to crate not being published
140+
if grep -qE "not found in registry|baseline not found|no baseline found|could not find .* in registry" semver-output.txt; then
141+
echo "$CRATE_NAME not published or no baseline available"
142+
continue
143+
fi
144+
145+
if grep -q "--- failure " semver-output.txt; then
146+
if grep -qE "major change|breaking change|function_missing|struct_missing|enum_variant_missing" semver-output.txt; then
147+
HIGHEST_LEVEL="major"
148+
CRATES_CHECKED+=("$CRATE_NAME:major")
149+
elif grep -qE "minor change|function_added|struct_added" semver-output.txt; then
150+
if [[ "$HIGHEST_LEVEL" != "major" ]]; then
151+
HIGHEST_LEVEL="minor"
152+
fi
153+
CRATES_CHECKED+=("$CRATE_NAME:minor")
154+
else
155+
if [[ "$HIGHEST_LEVEL" == "none" ]]; then
156+
HIGHEST_LEVEL="patch"
157+
fi
158+
CRATES_CHECKED+=("$CRATE_NAME:patch")
159+
fi
160+
else
161+
echo "Unexpected error for $CRATE_NAME (exit code: $EXIT_CODE)"
162+
fi
163+
else
164+
if [[ "$HIGHEST_LEVEL" == "none" ]]; then
165+
HIGHEST_LEVEL="patch"
166+
fi
167+
fi
168+
done
169+
170+
# Save results to file for aggregate step
171+
echo "$HIGHEST_LEVEL" > semver-level.txt
172+
printf '%s\n' "${CRATES_CHECKED[@]}" > failed-crates.txt || true
173+
174+
- name: Aggregate results
175+
id: aggregate
176+
run: |
177+
HIGHEST_LEVEL=$(cat semver-level.txt)
178+
CRATES_CHECKED=$(cat failed-crates.txt | tr '\n' ' ' || echo "")
179+
180+
echo "semver_level=$HIGHEST_LEVEL" >> "$GITHUB_OUTPUT"
181+
echo "failed_crates=$CRATES_CHECKED" >> "$GITHUB_OUTPUT"
182+
183+
echo "Final aggregate semver level: $HIGHEST_LEVEL"
184+
echo "Failed crates: $CRATES_CHECKED"
185+
186+
validate:
187+
needs: [detect-changes, semver-check]
188+
if: needs.detect-changes.outputs.has_rust_changes == 'true'
189+
runs-on: ubuntu-latest
190+
steps:
191+
- name: Validate PR title against semver changes
192+
env:
193+
PR_TITLE: ${{ github.event.pull_request.title }}
194+
SEMVER_LEVEL: ${{ needs.semver-check.outputs.semver_level }}
195+
CRATES_CHECKED: ${{ needs.semver-check.outputs.failed_crates }}
196+
SKIPPED_CRATES: ${{ needs.semver-check.outputs.skipped_crates }}
197+
run: |
198+
set -euo pipefail
199+
200+
echo "PR Title: $PR_TITLE"
201+
echo "Detected semver level: $SEMVER_LEVEL"
202+
echo "Crates with changes: $CRATES_CHECKED"
203+
echo "Skipped crates: $SKIPPED_CRATES"
204+
205+
Format: type(optional-scope): description
206+
Breaking changes: type!: or type(scope)!:
207+
if [[ "$PR_TITLE" =~ ^([a-z]+)(\([^)]+\))?(!)?:\ .+ ]]; then
208+
TYPE="${BASH_REMATCH[1]}"
209+
HAS_BREAKING_MARKER="${BASH_REMATCH[3]}"
210+
else
211+
echo "ERROR: Could not parse type from: $PR_TITLE"
212+
exit 1
213+
fi
214+
215+
echo ""
216+
echo "Detected PR title type: $TYPE"
217+
echo "Breaking marker (!) present: ${HAS_BREAKING_MARKER:-no}"
218+
echo ""
219+
220+
VALIDATION_FAILED="false"
221+
222+
# Validation rules
223+
case "$TYPE" in
224+
fix)
225+
# fix: should only have patch-level changes
226+
if [[ "$SEMVER_LEVEL" == "major" ]] || [[ "$SEMVER_LEVEL" == "minor" ]]; then
227+
VALIDATION_FAILED="true"
228+
fi
229+
;;
230+
231+
feat)
232+
if [[ "$SEMVER_LEVEL" == "major" ]] && [[ -z "$HAS_BREAKING_MARKER" ]]; then
233+
VALIDATION_FAILED="true"
234+
elif [[ "$SEMVER_LEVEL" == "patch" ]]; then
235+
VALIDATION_FAILED="true"
236+
fi
237+
;;
238+
239+
chore|docs|style|test|ci|build|perf)
240+
# These should not change public API
241+
if [[ "$SEMVER_LEVEL" == "major" ]] || [[ "$SEMVER_LEVEL" == "minor" ]]; then
242+
VALIDATION_FAILED="true"
243+
fi
244+
;;
245+
246+
refactor)
247+
# Should we allow refactors to introduce breaking changes.
248+
if [[ "$SEMVER_LEVEL" == "major" ]] && [[ -z "$HAS_BREAKING_MARKER" ]]; then
249+
VALIDATION_FAILED="true"
250+
fi
251+
;;
252+
253+
revert)
254+
# Revert commits are allowed to have any semver level
255+
echo "Revert commits are exempt from semver validation"
256+
;;
257+
258+
*)
259+
# "refactor" type fall through this category, should we be more specific about it?.
260+
echo "Commit type '$TYPE', applying lenient validation"
261+
# Only fail on undeclared breaking changes
262+
if [[ "$SEMVER_LEVEL" == "major" ]] && [[ -z "$HAS_BREAKING_MARKER" ]]; then
263+
VALIDATION_FAILED="true"
264+
fi
265+
;;
266+
esac
267+
268+
# Check for breaking marker with non-breaking changes
269+
if [[ -n "$HAS_BREAKING_MARKER" ]] && [[ "$SEMVER_LEVEL" != "major" ]]; then
270+
VALIDATION_FAILED="true"
271+
fi
272+
273+
# Final result
274+
if [[ "$VALIDATION_FAILED" == "true" ]]; then
275+
echo ""
276+
echo "============================================"
277+
echo "❌ SEMVER VALIDATION FAILED"
278+
echo "============================================"
279+
echo ""
280+
echo "$ERROR_MESSAGE"
281+
echo ""
282+
echo "Details:"
283+
echo " PR Title: $PR_TITLE"
284+
echo " Detected semver level: $SEMVER_LEVEL"
285+
echo " Crates with API changes: $CRATES_CHECKED"
286+
echo ""
287+
echo "To fix this:"
288+
echo " 1. Update your PR title to match the detected changes, OR"
289+
echo " 2. Adjust your code changes to match the PR title intent"
290+
echo "============================================"
291+
exit 1
292+
else
293+
echo ""
294+
echo "============================================"
295+
echo "✅ SEMVER VALIDATION PASSED"
296+
echo "============================================"
297+
echo "PR title type matches detected API changes"
298+
exit 0
299+
fi

0 commit comments

Comments
 (0)