返回 CodeWhale
check-versions.sh
根目录 / scripts / release / check-versions.sh
1 #!/usr/bin/env bash
2 # Fails CI if version state is inconsistent across the workspace, npm
3 # wrapper, and Cargo.lock. Run on every push/PR so silent drift can't ship.
4 #
5 # Checks performed:
6 # 1. No `crates/*/Cargo.toml` carries a literal `version = "x.y.z"`; every
7 # crate must inherit `version.workspace = true`.
8 # 2. Every crate inherits the workspace MSRV through
9 # `rust-version.workspace = true`.
10 # 3. Release package manifests, the npm wrapper's binary pin, and their
11 # workspace lock records match the workspace `version` in the root
12 # `Cargo.toml`.
13 # (`npm/deepseek-tui/` still exists only as an unpublished compatibility
14 # notice and must stay private.)
15 # 4. Internal `codewhale-*` path dependency pins match the workspace version.
16 # 5. The TUI crate's packaged changelog copy matches root `CHANGELOG.md`.
17 # 6. The current version has either an explicit source-candidate entry or a
18 # dated Keep a Changelog release entry and a matching compare link.
19 # 7. README contributor additions are mentioned in the current release entry.
20 # 8. `.github/SECURITY.md` keeps the dedicated security contact.
21 # 9. Generated website facts carry the workspace version.
22 # 10. Public install and version snippets point at the current release.
23 # 11. `codewhale-app-server` stays library-only; the shipped app-server
24 # entrypoint belongs to `codewhale-cli`.
25 # 12. Issue-linked feature commits have a durable changelog receipt.
26 # 13. `Cargo.lock` is in sync with the manifests (`cargo metadata --locked`
27 # fails if not).
28 set -euo pipefail
29
30 require_dated_release=0
31 # Checks 7 and 12 audit a *commit range* (previous tag..HEAD), not the working
32 # tree. Debt left by an already-merged commit therefore reddens this gate for
33 # every unrelated open pull request, and the PR that gets blamed is innocent.
34 # The per-PR CI job passes --range-audit-advisory so that class of failure
35 # reports without blocking; every release path (release-candidate.yml,
36 # auto-tag.yml, release.yml, prepare-release.sh) still runs them blocking, so
37 # nothing can be published without its receipt.
38 range_audit_advisory=0
39 while [[ "$#" -gt 0 ]]; do
40 case "$1" in
41 --require-dated-release) require_dated_release=1; shift ;;
42 --range-audit-advisory) range_audit_advisory=1; shift ;;
43 *)
44 echo "Usage: $0 [--require-dated-release] [--range-audit-advisory]" >&2
45 exit 2
46 ;;
47 esac
48 done
49 if [[ "${require_dated_release}" == "1" && "${range_audit_advisory}" == "1" ]]; then
50 echo "::error::--range-audit-advisory must not be combined with --require-dated-release; publication requires the range audit to block." >&2
51 exit 2
52 fi
53
54 cd "$(dirname "$0")/../.."
55
56 fail=0
57
58 # 1) Literal versions in crate manifests.
59 literals="$(grep -nE '^version = "' crates/*/Cargo.toml || true)"
60 if [[ -n "${literals}" ]]; then
61 echo "::error::Crate manifests must use 'version.workspace = true', not literal versions:" >&2
62 echo "${literals}" >&2
63 fail=1
64 fi
65
66 # 2) Workspace MSRV inheritance. A workspace value is not included in package
67 # metadata unless every member opts in, which would silently omit the MSRV from
68 # all crates.io manifests.
69 missing_rust_version=""
70 for manifest in crates/*/Cargo.toml; do
71 if ! grep -qxF 'rust-version.workspace = true' "${manifest}"; then
72 missing_rust_version+="${manifest}"$'\n'
73 fi
74 done
75 if [[ -n "${missing_rust_version}" ]]; then
76 echo "::error::Every crate manifest must inherit the workspace MSRV with 'rust-version.workspace = true':" >&2
77 printf '%s' "${missing_rust_version}" >&2
78 fail=1
79 fi
80
81 # 3) Workspace ↔ npm package.json/version + binary pin ↔ root package lock.
82 workspace_version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
83 npm_version="$(node -p "require('./npm/codewhale/package.json').version")"
84 npm_binary_version="$(node -p "require('./npm/codewhale/package.json').codewhaleBinaryVersion ?? ''")"
85 if [[ "${workspace_version}" != "${npm_version}" ]]; then
86 echo "::error::npm/codewhale/package.json version (${npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2
87 fail=1
88 fi
89 if ! ./scripts/release/check-npm-binary-version.sh \
90 "${workspace_version}" "${npm_binary_version}"; then
91 fail=1
92 fi
93 lock_npm_version="$(node -p "require('./package-lock.json').packages?.['npm/codewhale']?.version ?? ''")"
94 if [[ "${workspace_version}" != "${lock_npm_version}" ]]; then
95 echo "::error::package-lock.json npm/codewhale version (${lock_npm_version:-<missing>}) does not match workspace Cargo.toml (${workspace_version})." >&2
96 echo "Run: npm install --package-lock-only --ignore-scripts" >&2
97 fail=1
98 fi
99 runtime_sdk_version="$(node -p "require('./npm/runtime-sdk/package.json').version")"
100 lock_runtime_sdk_version="$(node -p "require('./package-lock.json').packages?.['npm/runtime-sdk']?.version ?? ''")"
101 vscode_version="$(node -p "require('./extensions/vscode/package.json').version")"
102 vscode_lock_version="$(node -p "require('./extensions/vscode/package-lock.json').version ?? ''")"
103 vscode_lock_workspace_version="$(node -p "require('./extensions/vscode/package-lock.json').packages?.['']?.version ?? ''")"
104 for pair in \
105 "npm/runtime-sdk/package.json:${runtime_sdk_version}" \
106 "package-lock.json npm/runtime-sdk:${lock_runtime_sdk_version}" \
107 "extensions/vscode/package.json:${vscode_version}" \
108 "extensions/vscode/package-lock.json:${vscode_lock_version}" \
109 "extensions/vscode/package-lock.json workspace:${vscode_lock_workspace_version}"; do
110 label="${pair%%:*}"
111 actual="${pair#*:}"
112 if [[ "${actual}" != "${workspace_version}" ]]; then
113 echo "::error::${label} version (${actual:-<missing>}) does not match workspace Cargo.toml (${workspace_version})." >&2
114 fail=1
115 fi
116 done
117 if [[ -f npm/deepseek-tui/package.json ]]; then
118 legacy_private="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').private)")"
119 legacy_publish_config="$(node -p "Boolean(require('./npm/deepseek-tui/package.json').publishConfig)")"
120 if [[ "${legacy_private}" != "true" ]]; then
121 echo "::error::npm/deepseek-tui/package.json must stay private so the legacy package is not republished." >&2
122 fail=1
123 fi
124 if [[ "${legacy_publish_config}" == "true" ]]; then
125 echo "::error::npm/deepseek-tui/package.json must not define publishConfig; the legacy package is deprecated." >&2
126 fail=1
127 fi
128 fi
129
130 # 4) Internal path dependency pins.
131 internal_dep_drift="$(
132 grep -nE 'codewhale-[a-z-]+[[:space:]]*=[[:space:]]*\{[^}]*version[[:space:]]*=[[:space:]]*"' crates/*/Cargo.toml \
133 | grep -vF 'codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.1.0" }' \
134 | grep -v "version[[:space:]]*=[[:space:]]*\"${workspace_version}\"" || true
135 )"
136 if [[ -n "${internal_dep_drift}" ]]; then
137 echo "::error::Internal codewhale-* path dependency versions must match workspace version ${workspace_version}:" >&2
138 echo "${internal_dep_drift}" >&2
139 fail=1
140 fi
141
142 # 5) Packaged TUI changelog slice (recent releases embedded in the binary).
143 if ! ./scripts/sync-changelog.sh --check >/dev/null 2>&1; then
144 echo "::error::crates/tui/CHANGELOG.md is out of date with the root CHANGELOG.md slice." >&2
145 echo "Run: ./scripts/sync-changelog.sh" >&2
146 fail=1
147 fi
148
149 # 6) Current candidate/release-note shape. Normal branch CI must accept an
150 # honest source candidate. The release candidate, tag creation and public
151 # release workflows pass --require-dated-release so publication cannot proceed
152 # until the same entry has a real release date and tag-based compare link (an
153 # RC receipt only counts for the exact tag SHA, so it must see the dated entry).
154 current_section="$(
155 awk -v version="${workspace_version}" '
156 index($0, "## [" version "] - ") == 1 { in_section = 1; print; next }
157 in_section && /^## \[/ { exit }
158 in_section { print }
159 ' CHANGELOG.md
160 )"
161 if [[ -z "${current_section}" ]]; then
162 echo "::error::CHANGELOG.md must contain a section for ${workspace_version}." >&2
163 fail=1
164 else
165 dated_heading="## [${workspace_version}] - YYYY-MM-DD"
166 candidate_heading="## [${workspace_version}] - Unreleased candidate"
167 if [[ "${require_dated_release}" == "1" ]]; then
168 if ! grep -qE "^## \\[${workspace_version}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" <<<"${current_section}"; then
169 echo "::error::Publication requires CHANGELOG.md section ${workspace_version} to use '${dated_heading}'." >&2
170 fail=1
171 fi
172 elif ! grep -qE "^## \\[${workspace_version}\\] - ([0-9]{4}-[0-9]{2}-[0-9]{2}|Unreleased candidate)$" <<<"${current_section}"; then
173 echo "::error::CHANGELOG.md section ${workspace_version} must use '${candidate_heading}' or '${dated_heading}'." >&2
174 fail=1
175 fi
176 if ! grep -qE "^### (Added|Changed|Deprecated|Removed|Fixed|Security)$" <<<"${current_section}"; then
177 echo "::error::CHANGELOG.md section ${workspace_version} must contain at least one Keep a Changelog subsection." >&2
178 fail=1
179 fi
180 fi
181
182 compare_line="$(grep -E "^\\[${workspace_version}\\]: " CHANGELOG.md || true)"
183 if [[ -z "${compare_line}" ]]; then
184 echo "::error::CHANGELOG.md must include a compare link for ${workspace_version}." >&2
185 fail=1
186 elif [[ "${require_dated_release}" == "1" ]] &&
187 ! grep -qE "^\\[${workspace_version}\\]: https://github.com/codewhale-hq/CodeWhale/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.v${workspace_version}$" <<<"${compare_line}"; then
188 echo "::error::Publication requires the ${workspace_version} compare link to end at v${workspace_version}." >&2
189 fail=1
190 fi
191
192 unreleased_section="$(
193 awk '
194 index($0, "## [Unreleased]") == 1 { in_section = 1; print; next }
195 in_section && /^## \[/ { exit }
196 in_section { print }
197 ' CHANGELOG.md
198 )"
199 credit_sections="${current_section}
200 ${unreleased_section}"
201
202 # 7) Contributor-credit cross-check for README additions on the release branch.
203 # This cannot prove every external PR author has been credited, but it does
204 # catch the common release-polish failure mode: adding a README contributor row
205 # without mentioning that credit/correction in the current release entry. While
206 # a release branch is still unbumped, `[Unreleased]` is also a valid credit
207 # surface.
208 previous_tag=""
209 current_tag="v${workspace_version}"
210 if [[ "${compare_line}" =~ compare/(v[0-9]+\.[0-9]+\.[0-9]+)\.\.\.${current_tag} ]]; then
211 previous_tag="${BASH_REMATCH[1]}"
212 fi
213 if [[ -n "${previous_tag}" ]]; then
214 if ! git rev-parse -q --verify "refs/tags/${previous_tag}" >/dev/null; then
215 git fetch --quiet --depth=1 origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}" || true
216 fi
217 if git rev-parse -q --verify "refs/tags/${previous_tag}" >/dev/null; then
218 if ! ./scripts/release/check-feature-release-notes.sh "${previous_tag}" HEAD; then
219 if [[ "${range_audit_advisory}" == "1" ]]; then
220 echo "::warning::Missing feature release-note receipt(s) above. Advisory here because this audits already-merged commits in ${previous_tag}..HEAD, not this change. It blocks every release path; fix it before the next release." >&2
221 else
222 fail=1
223 fi
224 fi
225 while IFS= read -r line; do
226 [[ -z "${line}" ]] && continue
227 handle="$(sed -E 's#.*github.com/([^)/]+).*#\1#' <<<"${line}")"
228 if [[ -n "${handle}" && "${handle}" != "${line}" ]]; then
229 if ! grep -Fq "github.com/${handle}" <<<"${credit_sections}" && ! grep -Fq "@${handle}" <<<"${credit_sections}"; then
230 if [[ "${range_audit_advisory}" == "1" ]]; then
231 echo "::warning::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} or [Unreleased] does not mention that credit. Advisory here; blocking on every release path." >&2
232 else
233 echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} or [Unreleased] does not mention that credit." >&2
234 fail=1
235 fi
236 fi
237 fi
238 done < <(
239 git diff "${previous_tag}..HEAD" -- README.md \
240 | grep -E '^\+[-*] \*\*\[[^]]+\]\(https://github.com/[^)]+\)\*\*' || true
241 )
242 else
243 # A gate that silently no-ops is worse than no gate: it reports success and
244 # is read as evidence. If the tag cannot be resolved -- the fetch above is
245 # `|| true` and a network blip is enough -- then the feature release-note
246 # receipt check and the contributor-credit check did not run at all, and
247 # this script must not imply that they passed.
248 echo "::error::Cannot resolve refs/tags/${previous_tag}, so the feature release-note receipt and contributor-credit checks did not run. Fetch the tag and re-run; do not treat this as a pass." >&2
249 if [[ "${CWC_ALLOW_MISSING_PREVIOUS_TAG:-}" == "1" ]]; then
250 echo "::warning::CWC_ALLOW_MISSING_PREVIOUS_TAG=1 set, continuing with those two checks UNRUN." >&2
251 else
252 fail=1
253 fi
254 fi
255 fi
256
257 # 8) Security contact guard.
258 security_email="hmbown@gmail.com"
259 if ! grep -qF "${security_email}" .github/SECURITY.md; then
260 echo "::error::.github/SECURITY.md must list ${security_email} as the security contact." >&2
261 fail=1
262 fi
263 if grep -qF "hmbown.dev@gmail.com" .github/SECURITY.md; then
264 echo "::error::.github/SECURITY.md must not use the alternate personal fallback email; use ${security_email}." >&2
265 fail=1
266 fi
267
268 # 9) Generated web facts carry the workspace version. The file is ignored and
269 # generated during web builds, so a clean CI checkout must derive it before this
270 # release guard can inspect it.
271 if [[ ! -f web/lib/facts.generated.ts ]]; then
272 node web/scripts/derive-facts.mjs
273 fi
274 facts_version="$(grep -oE '"version": "[0-9]+\.[0-9]+\.[0-9]+"' web/lib/facts.generated.ts | head -n1 | sed -E 's/.*"([0-9.]+)".*/\1/')"
275 if [[ "${facts_version}" != "${workspace_version}" ]]; then
276 node web/scripts/derive-facts.mjs
277 facts_version="$(grep -oE '"version": "[0-9]+\.[0-9]+\.[0-9]+"' web/lib/facts.generated.ts | head -n1 | sed -E 's/.*"([0-9.]+)".*/\1/')"
278 if [[ "${facts_version}" != "${workspace_version}" ]]; then
279 echo "::error::web/lib/facts.generated.ts version (${facts_version}) does not match workspace (${workspace_version}). Run: node web/scripts/derive-facts.mjs" >&2
280 fail=1
281 fi
282 fi
283
284 # 10) README install-tag examples point at the current release.
285 for readme in README.md README.zh-CN.md README.ja-JP.md README.vi.md README.ko-KR.md; do
286 stale_tags="$(grep -nE -- "--tag v[0-9]+\.[0-9]+\.[0-9]+" "${readme}" | grep -v -- "--tag v${workspace_version}" || true)"
287 if [[ -n "${stale_tags}" ]]; then
288 echo "::error::${readme} has install examples pinned to an old tag (want v${workspace_version}):" >&2
289 echo "${stale_tags}" >&2
290 fail=1
291 fi
292 done
293
294 # 10b) Public install/version snippets stay on the current release (#3767).
295 # `codewhale --version # X.Y.Z` verify-your-install lines across README
296 # locales and docs/INSTALL.md, plus the docs/INSTALL.md npm-wrapper publish
297 # pointer ("published at vX.Y.Z"). These drifted while this gate still passed
298 # on a prior lane, so guard them explicitly. Narrowly scoped to those two
299 # snippet shapes to avoid flagging unrelated prose.
300 for doc in README.md README.zh-CN.md README.ja-JP.md README.vi.md README.ko-KR.md docs/INSTALL.md; do
301 [[ -f "${doc}" ]] || continue
302 stale_version_comments="$(grep -nE -- "codewhale --version[[:space:]]+#[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+" "${doc}" | grep -vE -- "#[[:space:]]*${workspace_version}([^0-9]|$)" || true)"
303 if [[ -n "${stale_version_comments}" ]]; then
304 echo "::error::${doc} has 'codewhale --version # X' snippet(s) not on ${workspace_version}:" >&2
305 echo "${stale_version_comments}" >&2
306 fail=1
307 fi
308 done
309
310 # The publish pointer can wrap onto the next line; also scan the line after the lead-in.
311 wrapper_pointer_version="$(grep -A1 -E -- "wrapper is published at" docs/INSTALL.md | grep -oE -- "v[0-9]+\.[0-9]+\.[0-9]+" | head -n1 || true)"
312 if [[ -n "${wrapper_pointer_version}" && "${wrapper_pointer_version}" != "v${workspace_version}" ]]; then
313 echo "::error::docs/INSTALL.md npm-wrapper publish pointer is ${wrapper_pointer_version}, want v${workspace_version}." >&2
314 fail=1
315 fi
316
317 remote_smoke_tag="$(grep -oE 'RELEASE_TAG:-v[0-9]+\.[0-9]+\.[0-9]+' scripts/remote-smoke/setup-vm.sh | head -n1 | sed 's/.*:-//' || true)"
318 if [[ "${remote_smoke_tag}" != "v${workspace_version}" ]]; then
319 echo "::error::scripts/remote-smoke/setup-vm.sh defaults to ${remote_smoke_tag:-<missing>}, want v${workspace_version}." >&2
320 fail=1
321 fi
322
323 # 11) App-server is not a standalone binary.
324 app_server_bins="$(
325 cargo metadata --locked --format-version 1 --no-deps \
326 | node -e '
327 const fs = require("fs");
328 const metadata = JSON.parse(fs.readFileSync(0, "utf8"));
329 const pkg = metadata.packages.find((p) => p.name === "codewhale-app-server");
330 if (!pkg) {
331 process.exit(2);
332 }
333 const bins = pkg.targets
334 .filter((target) => target.kind.includes("bin"))
335 .map((target) => target.name);
336 process.stdout.write(bins.join("\n"));
337 '
338 )"
339 if [[ -n "${app_server_bins}" ]]; then
340 echo "::error::codewhale-app-server must stay library-only; use the codewhale-cli-owned 'codewhale app-server' entrypoint instead. Unexpected binary target(s):" >&2
341 echo "${app_server_bins}" >&2
342 fail=1
343 fi
344
345 # 13) Cargo.lock in sync.
346 if ! cargo metadata --locked --format-version 1 --no-deps >/dev/null 2>&1; then
347 echo "::error::Cargo.lock is out of sync with the manifests. Run 'cargo update -p codewhale-tui' or 'cargo build' and commit the result." >&2
348 fail=1
349 fi
350
351 if [[ "${fail}" -eq 0 ]]; then
352 echo "Version state OK: workspace=${workspace_version}, npm=${npm_version}, npm-binary=${npm_binary_version}, lockfile in sync."
353 fi
354
355 exit "${fail}"
356
356 lines BASH