Skip to content

Commit d9e6ad6

Browse files
fix(release): gate publishing on complete changelogs (#11047)
* fix(release): validate release notes before publishing * fix(release): gate publishing on complete changelogs * fix(release): dry-run packages before publishing
1 parent edd57bd commit d9e6ad6

8 files changed

Lines changed: 424 additions & 58 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
repository_root="$(git rev-parse --show-toplevel)"
6+
validator="${repository_root}/.github/scripts/validate-release-changelogs.sh"
7+
temporary_root="$(mktemp -d)"
8+
fixture="${temporary_root}/repository"
9+
output="${temporary_root}/output"
10+
11+
trap 'rm -rf "$temporary_root"' EXIT
12+
13+
mkdir -p "$fixture" "$output"
14+
git -C "$fixture" init --quiet
15+
git -C "$fixture" config user.email release-validator@example.com
16+
git -C "$fixture" config user.name "Release Validator"
17+
18+
write_file() {
19+
local path="$1"
20+
local content="$2"
21+
22+
mkdir -p "$(dirname "${fixture}/${path}")"
23+
printf '%s\n' "$content" > "${fixture}/${path}"
24+
}
25+
26+
commit_fixture() {
27+
local message="$1"
28+
29+
git -C "$fixture" add --all
30+
git -C "$fixture" commit --quiet --message "$message"
31+
}
32+
33+
expect_failure() {
34+
if (cd "$fixture" && "$validator" HEAD^ HEAD >/dev/null 2>&1); then
35+
echo "expected validation to fail: $1" >&2
36+
exit 1
37+
fi
38+
}
39+
40+
write_file zebrad/Cargo.toml '[package]
41+
name = "zebrad"
42+
version = "1.0.0"'
43+
write_file zebra-example/Cargo.toml '[package]
44+
name = "zebra-example"
45+
version = "2.0.0"'
46+
write_file CHANGELOG.md '# Changelog
47+
48+
## [Zebra 1.0.0]
49+
50+
- Initial release.'
51+
write_file zebra-example/CHANGELOG.md '# Changelog
52+
53+
## [2.0.0]
54+
55+
- Initial release.'
56+
commit_fixture base
57+
58+
write_file zebrad/Cargo.toml '[package]
59+
name = "zebrad"
60+
version = "1.1.0"'
61+
write_file zebra-example/Cargo.toml '[package]
62+
name = "zebra-example"
63+
version = "2.0.1"'
64+
write_file CHANGELOG.md '# Changelog
65+
66+
## [Zebra 1.1.0](https://example.com/v1.1.0) - 2026-07-22
67+
68+
### Fixed
69+
70+
- Fixed release behavior.
71+
72+
## [Zebra 1.0.0]
73+
74+
- Initial release.'
75+
write_file zebra-example/CHANGELOG.md '# Changelog
76+
77+
## [2.0.1] - 2026-07-22
78+
79+
- Updated the dependency.
80+
81+
## [2.0.0]
82+
83+
- Initial release.'
84+
commit_fixture valid-release
85+
86+
(
87+
cd "$fixture"
88+
"$validator" HEAD^ HEAD "${output}/notes.md" "${output}/expected.tsv"
89+
)
90+
grep -q '^## \[Zebra 1\.1\.0\]' "${output}/notes.md"
91+
diff -u <(printf 'zebra-example\t2.0.1\nzebrad\t1.1.0\n') "${output}/expected.tsv"
92+
93+
write_file zebrad/Cargo.toml '[package]
94+
name = "zebrad"
95+
version = "1.1.1"'
96+
write_file CHANGELOG.md '# Changelog
97+
98+
## [Zebra 1.1.1]
99+
100+
## [Zebra 1.1.0]
101+
102+
- Previous release.'
103+
commit_fixture heading-only
104+
expect_failure "heading-only section"
105+
106+
write_file zebrad/Cargo.toml '[package]
107+
name = "zebrad"
108+
version = "1.1.2"'
109+
write_file CHANGELOG.md '# Changelog
110+
111+
## [Zebra 1.1.20]
112+
113+
- Near-match heading.
114+
115+
## [Zebra 1.1.0]
116+
117+
- Previous release.'
118+
commit_fixture near-match
119+
expect_failure "near-match heading"
120+
121+
write_file zebra-example/Cargo.toml '[package]
122+
name = "zebra-example"
123+
version = "2.0.2"'
124+
write_file zebra-example/CHANGELOG.md '# Changelog
125+
126+
## [2.0.2]
127+
128+
- Updated another dependency.
129+
130+
## [2.0.1]
131+
132+
- Previous release.'
133+
commit_fixture library-only
134+
135+
(
136+
cd "$fixture"
137+
"$validator" HEAD^ HEAD "${output}/notes.md" "${output}/expected.tsv"
138+
)
139+
test ! -e "${output}/notes.md"
140+
diff -u <(printf 'zebra-example\t2.0.2\n') "${output}/expected.tsv"
141+
142+
write_file zebra-example/Cargo.toml '[package]
143+
name = "zebra-example"
144+
version = "2.0.3"'
145+
commit_fixture missing-library-heading
146+
expect_failure "missing library heading"
147+
148+
write_file zebrad/Cargo.toml '[package]
149+
name = "zebrad"
150+
version = "1.1.2"
151+
description = "Metadata-only change"'
152+
commit_fixture metadata-only
153+
expect_failure "Release PR without a version change"
154+
155+
write_file zebra-example/Cargo.toml '[package]
156+
name = "zebra-example"
157+
version = "2.0.4"'
158+
git -C "$fixture" rm --quiet zebra-example/CHANGELOG.md
159+
commit_fixture missing-changelog
160+
expect_failure "missing changelog"
161+
162+
echo "release changelog validator tests passed"
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
if [[ $# -lt 2 || $# -gt 4 ]]; then
6+
echo "usage: $0 <base-revision> <release-revision> [zebrad-notes-output] [expected-releases-output]" >&2
7+
exit 2
8+
fi
9+
10+
base_revision="$1"
11+
release_revision="$2"
12+
zebrad_notes_output="${3:-}"
13+
expected_releases_output="${4:-}"
14+
15+
if [[ -n "$zebrad_notes_output" ]]; then
16+
rm -f "$zebrad_notes_output"
17+
fi
18+
if [[ -n "$expected_releases_output" ]]; then
19+
rm -f "$expected_releases_output"
20+
fi
21+
22+
read_package_field() {
23+
local revision="$1"
24+
local manifest="$2"
25+
local field="$3"
26+
27+
git show "${revision}:${manifest}" 2>/dev/null | awk -v field="$field" '
28+
/^\[package\][[:space:]]*$/ {
29+
in_package = 1
30+
next
31+
}
32+
/^\[/ {
33+
in_package = 0
34+
}
35+
in_package && $0 ~ "^[[:space:]]*" field "[[:space:]]*=" {
36+
if (found) {
37+
next
38+
}
39+
value = substr($0, index($0, "=") + 1)
40+
sub(/^[[:space:]]*"/, "", value)
41+
sub(/".*$/, "", value)
42+
print value
43+
found = 1
44+
}
45+
'
46+
}
47+
48+
validate_changelog_section() {
49+
local revision="$1"
50+
local changelog="$2"
51+
local heading="$3"
52+
local output="$4"
53+
54+
git show "${revision}:${changelog}" | awk -v heading="$heading" '
55+
/^##[[:space:]]+/ {
56+
if (in_section) {
57+
in_section = 0
58+
}
59+
if (!found && index($0, heading) == 1) {
60+
suffix = substr($0, length(heading) + 1)
61+
if (suffix == "" || suffix ~ /^[[:space:]]/ || suffix ~ /^\(/) {
62+
found = 1
63+
in_section = 1
64+
}
65+
}
66+
}
67+
in_section {
68+
print
69+
}
70+
END {
71+
if (!found) {
72+
exit 1
73+
}
74+
}
75+
' > "$output"
76+
77+
awk '
78+
NR > 1 && NF && $0 !~ /^[[:space:]]*#/ && $0 !~ /^[[:space:]]*<!--/ {
79+
found = 1
80+
}
81+
END {
82+
exit !found
83+
}
84+
' "$output"
85+
}
86+
87+
temporary_section="$(mktemp)"
88+
trap 'rm -f "$temporary_section"' EXIT
89+
90+
release_count=0
91+
failed=false
92+
93+
while IFS= read -r -d '' manifest; do
94+
package="$(read_package_field "$release_revision" "$manifest" name)"
95+
version="$(read_package_field "$release_revision" "$manifest" version)"
96+
previous_version="$(read_package_field "$base_revision" "$manifest" version || true)"
97+
98+
if [[ -z "$package" || -z "$version" || "$version" == "$previous_version" ]]; then
99+
continue
100+
fi
101+
102+
release_count=$((release_count + 1))
103+
104+
if [[ -n "$expected_releases_output" ]]; then
105+
printf '%s\t%s\n' "$package" "$version" >> "$expected_releases_output"
106+
fi
107+
108+
if [[ "$package" == "zebrad" ]]; then
109+
changelog="CHANGELOG.md"
110+
heading="## [Zebra ${version}]"
111+
else
112+
changelog="${manifest%/Cargo.toml}/CHANGELOG.md"
113+
heading="## [${version}]"
114+
fi
115+
116+
if ! git cat-file -e "${release_revision}:${changelog}" 2>/dev/null; then
117+
echo "::error title=Missing release changelog::${package} ${version} requires ${changelog}." >&2
118+
failed=true
119+
continue
120+
fi
121+
122+
if ! validate_changelog_section "$release_revision" "$changelog" "$heading" "$temporary_section"; then
123+
echo "::error title=Incomplete release changelog::${changelog} must contain a non-empty '${heading}' section for ${package} ${version}." >&2
124+
failed=true
125+
continue
126+
fi
127+
128+
if [[ "$package" == "zebrad" && -n "$zebrad_notes_output" ]]; then
129+
cp "$temporary_section" "$zebrad_notes_output"
130+
fi
131+
132+
echo "Validated ${package} ${version} in ${changelog}."
133+
done < <(git diff --name-only -z --diff-filter=AM "$base_revision" "$release_revision" -- '**/Cargo.toml')
134+
135+
if [[ "$release_count" -eq 0 ]]; then
136+
echo "::error title=Empty Release PR::No package version changes were found between ${base_revision} and ${release_revision}." >&2
137+
exit 1
138+
fi
139+
140+
if [[ "$failed" == "true" ]]; then
141+
exit 1
142+
fi

.github/workflows/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ graph TB
4646
Coverage[coverage.yml]
4747
DockerCfg[test-docker.yml]
4848
CrateBuild[test-crates.yml]
49+
PRGate[pr-gate.yml]
4950
Docs[book.yml]
5051
Security[zizmor.yml]
5152
end
@@ -60,8 +61,8 @@ graph TB
6061
end
6162
6263
%% Trigger wiring
63-
PR --> Unit & Lint & DockerCfg & CrateBuild & IT & Security
64-
Push --> Unit & Lint & Coverage & Docs & Security & ReleaseWorkflow
64+
PR --> Unit & Lint & DockerCfg & CrateBuild & PRGate & IT & Security
65+
Push --> Unit & Lint & Coverage & PRGate & Docs & Security & ReleaseWorkflow
6566
ReleaseWorkflow --> ReleaseEvent
6667
ReleaseEvent --> ReleaseBinaries & DeployNodes
6768
Schedule --> IT
@@ -77,7 +78,7 @@ graph TB
7778
classDef trigger fill:#95a5a6,stroke:#95a5a6,color:white
7879
class BuildDocker primary
7980
class ReleaseWorkflow,ReleaseBinaries primary
80-
class Unit,Lint,Coverage,DockerCfg,CrateBuild,Docs,Security secondary
81+
class Unit,Lint,Coverage,DockerCfg,CrateBuild,PRGate,Docs,Security secondary
8182
class IT,FindDisks,Deploy,DeployNodes,Cleanup secondary
8283
class PR,Push,ReleaseEvent,Schedule,Manual trigger
8384
```
@@ -155,6 +156,7 @@ _The diagram above illustrates the parallel execution patterns in our CI/CD syst
155156
- **Coverage** (`coverage.yml`): llvm-cov with nextest, uploads to Codecov
156157
- **Test Docker Config** (`test-docker.yml`): Validates zebrad configs against built test image
157158
- **Test Crate Build** (`test-crates.yml`): Builds each crate under various feature sets
159+
- **PR Gate** (`pr-gate.yml`): Validates PR declarations, changelog policy, API compatibility, and generated Release PR changelogs
158160
- **Docs (Book + internal)** (`book.yml`): Builds mdBook and internal rustdoc, publishes to Pages
159161
- **Security Analysis** (`zizmor.yml`): GitHub Actions security lint (SARIF)
160162
- **Release** (`release.yml`): Creates/updates release-plz Release PRs, then publishes crates, tags, and one app-authored `zebrad` GitHub Release after a Release PR merge

.github/workflows/lint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ jobs:
258258
find . -type f -name '*.sh' \
259259
-not -path './target/*' \
260260
-print0 | xargs -0 -r shellcheck --color=always
261+
- name: Test release changelog validator
262+
run: .github/scripts/test-validate-release-changelogs.sh
261263

262264
lint:
263265
runs-on: ubuntu-latest

.github/workflows/pr-gate.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,32 @@ jobs:
203203
exit 1
204204
fi
205205
206+
release-readiness:
207+
if: >-
208+
github.event_name == 'pull_request' &&
209+
github.event.pull_request.head.repo.full_name == github.repository &&
210+
startsWith(github.event.pull_request.head.ref, 'release-plz-') &&
211+
contains(github.event.pull_request.labels.*.name, 'A-release')
212+
runs-on: ubuntu-latest
213+
timeout-minutes: 5
214+
steps:
215+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
216+
with:
217+
fetch-depth: 2
218+
persist-credentials: false
219+
- name: Validate release changelogs
220+
run: |
221+
# actions/checkout provides the synthetic merge commit, whose first parent is current main.
222+
.github/scripts/validate-release-changelogs.sh HEAD^1 HEAD
223+
206224
pr-gate-result:
207225
runs-on: ubuntu-latest
208226
if: always()
209-
needs: [changes, semver-checks, changelog-gate]
227+
needs: [changes, semver-checks, changelog-gate, release-readiness]
210228
timeout-minutes: 5
211229
steps:
212230
- name: Decide whether the needed jobs succeeded or failed
213231
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
214232
with:
215233
jobs: ${{ toJSON(needs) }}
216-
allowed-skips: semver-checks, changelog-gate
234+
allowed-skips: semver-checks, changelog-gate, release-readiness

0 commit comments

Comments
 (0)