| 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 -v "version[[:space:]]*=[[:space:]]*\"${workspace_version}\"" || true |
| 134 | )" |
| 135 | if [[ -n "${internal_dep_drift}" ]]; then |
| 136 | echo "::error::Internal codewhale-* path dependency versions must match workspace version ${workspace_version}:" >&2 |
| 137 | echo "${internal_dep_drift}" >&2 |
| 138 | fail=1 |
| 139 | fi |
| 140 | |
| 141 | # 5) Packaged TUI changelog slice (recent releases embedded in the binary). |
| 142 | if ! ./scripts/sync-changelog.sh --check >/dev/null 2>&1; then |
| 143 | echo "::error::crates/tui/CHANGELOG.md is out of date with the root CHANGELOG.md slice." >&2 |
| 144 | echo "Run: ./scripts/sync-changelog.sh" >&2 |
| 145 | fail=1 |
| 146 | fi |
| 147 | |
| 148 | # 6) Current candidate/release-note shape. Normal branch and release-candidate |
| 149 | # CI must accept an honest source candidate. Tag creation and public release |
| 150 | # workflows pass --require-dated-release so publication cannot proceed until |
| 151 | # the same entry has a real release date and tag-based compare link. |
| 152 | current_section="$( |
| 153 | awk -v version="${workspace_version}" ' |
| 154 | index($0, "## [" version "] - ") == 1 { in_section = 1; print; next } |
| 155 | in_section && /^## \[/ { exit } |
| 156 | in_section { print } |
| 157 | ' CHANGELOG.md |
| 158 | )" |
| 159 | if [[ -z "${current_section}" ]]; then |
| 160 | echo "::error::CHANGELOG.md must contain a section for ${workspace_version}." >&2 |
| 161 | fail=1 |
| 162 | else |
| 163 | dated_heading="## [${workspace_version}] - YYYY-MM-DD" |
| 164 | candidate_heading="## [${workspace_version}] - Unreleased candidate" |
| 165 | if [[ "${require_dated_release}" == "1" ]]; then |
| 166 | if ! grep -qE "^## \\[${workspace_version}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" <<<"${current_section}"; then |
| 167 | echo "::error::Publication requires CHANGELOG.md section ${workspace_version} to use '${dated_heading}'." >&2 |
| 168 | fail=1 |
| 169 | fi |
| 170 | elif ! grep -qE "^## \\[${workspace_version}\\] - ([0-9]{4}-[0-9]{2}-[0-9]{2}|Unreleased candidate)$" <<<"${current_section}"; then |
| 171 | echo "::error::CHANGELOG.md section ${workspace_version} must use '${candidate_heading}' or '${dated_heading}'." >&2 |
| 172 | fail=1 |
| 173 | fi |
| 174 | if ! grep -qE "^### (Added|Changed|Deprecated|Removed|Fixed|Security)$" <<<"${current_section}"; then |
| 175 | echo "::error::CHANGELOG.md section ${workspace_version} must contain at least one Keep a Changelog subsection." >&2 |
| 176 | fail=1 |
| 177 | fi |
| 178 | fi |
| 179 | |
| 180 | compare_line="$(grep -E "^\\[${workspace_version}\\]: " CHANGELOG.md || true)" |
| 181 | if [[ -z "${compare_line}" ]]; then |
| 182 | echo "::error::CHANGELOG.md must include a compare link for ${workspace_version}." >&2 |
| 183 | fail=1 |
| 184 | elif [[ "${require_dated_release}" == "1" ]] && |
| 185 | ! grep -qE "^\\[${workspace_version}\\]: https://github.com/Hmbown/CodeWhale/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.v${workspace_version}$" <<<"${compare_line}"; then |
| 186 | echo "::error::Publication requires the ${workspace_version} compare link to end at v${workspace_version}." >&2 |
| 187 | fail=1 |
| 188 | fi |
| 189 | |
| 190 | unreleased_section="$( |
| 191 | awk ' |
| 192 | index($0, "## [Unreleased]") == 1 { in_section = 1; print; next } |
| 193 | in_section && /^## \[/ { exit } |
| 194 | in_section { print } |
| 195 | ' CHANGELOG.md |
| 196 | )" |
| 197 | credit_sections="${current_section} |
| 198 | ${unreleased_section}" |
| 199 | |
| 200 | # 7) Contributor-credit cross-check for README additions on the release branch. |
| 201 | # This cannot prove every external PR author has been credited, but it does |
| 202 | # catch the common release-polish failure mode: adding a README contributor row |
| 203 | # without mentioning that credit/correction in the current release entry. While |
| 204 | # a release branch is still unbumped, `[Unreleased]` is also a valid credit |
| 205 | # surface. |
| 206 | previous_tag="" |
| 207 | current_tag="v${workspace_version}" |
| 208 | if [[ "${compare_line}" =~ compare/(v[0-9]+\.[0-9]+\.[0-9]+)\.\.\.${current_tag} ]]; then |
| 209 | previous_tag="${BASH_REMATCH[1]}" |
| 210 | fi |
| 211 | if [[ -n "${previous_tag}" ]]; then |
| 212 | if ! git rev-parse -q --verify "refs/tags/${previous_tag}" >/dev/null; then |
| 213 | git fetch --quiet --depth=1 origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}" || true |
| 214 | fi |
| 215 | if git rev-parse -q --verify "refs/tags/${previous_tag}" >/dev/null; then |
| 216 | if ! ./scripts/release/check-feature-release-notes.sh "${previous_tag}" HEAD; then |
| 217 | if [[ "${range_audit_advisory}" == "1" ]]; then |
| 218 | 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 |
| 219 | else |
| 220 | fail=1 |
| 221 | fi |
| 222 | fi |
| 223 | while IFS= read -r line; do |
| 224 | [[ -z "${line}" ]] && continue |
| 225 | handle="$(sed -E 's#.*github.com/([^)/]+).*#\1#' <<<"${line}")" |
| 226 | if [[ -n "${handle}" && "${handle}" != "${line}" ]]; then |
| 227 | if ! grep -Fq "github.com/${handle}" <<<"${credit_sections}" && ! grep -Fq "@${handle}" <<<"${credit_sections}"; then |
| 228 | if [[ "${range_audit_advisory}" == "1" ]]; then |
| 229 | 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 |
| 230 | else |
| 231 | echo "::error::README.md adds contributor @${handle}, but CHANGELOG.md ${workspace_version} or [Unreleased] does not mention that credit." >&2 |
| 232 | fail=1 |
| 233 | fi |
| 234 | fi |
| 235 | fi |
| 236 | done < <( |
| 237 | git diff "${previous_tag}..HEAD" -- README.md \ |
| 238 | | grep -E '^\+[-*] \*\*\[[^]]+\]\(https://github.com/[^)]+\)\*\*' || true |
| 239 | ) |
| 240 | else |
| 241 | # A gate that silently no-ops is worse than no gate: it reports success and |
| 242 | # is read as evidence. If the tag cannot be resolved -- the fetch above is |
| 243 | # `|| true` and a network blip is enough -- then the feature release-note |
| 244 | # receipt check and the contributor-credit check did not run at all, and |
| 245 | # this script must not imply that they passed. |
| 246 | 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 |
| 247 | if [[ "${CWC_ALLOW_MISSING_PREVIOUS_TAG:-}" == "1" ]]; then |
| 248 | echo "::warning::CWC_ALLOW_MISSING_PREVIOUS_TAG=1 set, continuing with those two checks UNRUN." >&2 |
| 249 | else |
| 250 | fail=1 |
| 251 | fi |
| 252 | fi |
| 253 | fi |
| 254 | |
| 255 | # 8) Security contact guard. |
| 256 | security_email="hmbown@gmail.com" |
| 257 | if ! grep -qF "${security_email}" .github/SECURITY.md; then |
| 258 | echo "::error::.github/SECURITY.md must list ${security_email} as the security contact." >&2 |
| 259 | fail=1 |
| 260 | fi |
| 261 | if grep -qF "hmbown.dev@gmail.com" .github/SECURITY.md; then |
| 262 | echo "::error::.github/SECURITY.md must not use the alternate personal fallback email; use ${security_email}." >&2 |
| 263 | fail=1 |
| 264 | fi |
| 265 | |
| 266 | # 9) Generated web facts carry the workspace version. The file is ignored and |
| 267 | # generated during web builds, so a clean CI checkout must derive it before this |
| 268 | # release guard can inspect it. |
| 269 | if [[ ! -f web/lib/facts.generated.ts ]]; then |
| 270 | node web/scripts/derive-facts.mjs |
| 271 | fi |
| 272 | facts_version="$(grep -oE '"version": "[0-9]+\.[0-9]+\.[0-9]+"' web/lib/facts.generated.ts | head -n1 | sed -E 's/.*"([0-9.]+)".*/\1/')" |
| 273 | if [[ "${facts_version}" != "${workspace_version}" ]]; then |
| 274 | node web/scripts/derive-facts.mjs |
| 275 | facts_version="$(grep -oE '"version": "[0-9]+\.[0-9]+\.[0-9]+"' web/lib/facts.generated.ts | head -n1 | sed -E 's/.*"([0-9.]+)".*/\1/')" |
| 276 | if [[ "${facts_version}" != "${workspace_version}" ]]; then |
| 277 | echo "::error::web/lib/facts.generated.ts version (${facts_version}) does not match workspace (${workspace_version}). Run: node web/scripts/derive-facts.mjs" >&2 |
| 278 | fail=1 |
| 279 | fi |
| 280 | fi |
| 281 | |
| 282 | # 10) README install-tag examples point at the current release. |
| 283 | for readme in README.md README.zh-CN.md README.ja-JP.md README.vi.md README.ko-KR.md; do |
| 284 | stale_tags="$(grep -nE -- "--tag v[0-9]+\.[0-9]+\.[0-9]+" "${readme}" | grep -v -- "--tag v${workspace_version}" || true)" |
| 285 | if [[ -n "${stale_tags}" ]]; then |
| 286 | echo "::error::${readme} has install examples pinned to an old tag (want v${workspace_version}):" >&2 |
| 287 | echo "${stale_tags}" >&2 |
| 288 | fail=1 |
| 289 | fi |
| 290 | done |
| 291 | |
| 292 | # 10b) Public install/version snippets stay on the current release (#3767). |
| 293 | # `codewhale --version # X.Y.Z` verify-your-install lines across README |
| 294 | # locales and docs/INSTALL.md, plus the docs/INSTALL.md npm-wrapper publish |
| 295 | # pointer ("published at vX.Y.Z"). These drifted while this gate still passed |
| 296 | # on a prior lane, so guard them explicitly. Narrowly scoped to those two |
| 297 | # snippet shapes to avoid flagging unrelated prose. |
| 298 | for doc in README.md README.zh-CN.md README.ja-JP.md README.vi.md README.ko-KR.md docs/INSTALL.md; do |
| 299 | [[ -f "${doc}" ]] || continue |
| 300 | stale_version_comments="$(grep -nE -- "codewhale --version[[:space:]]+#[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+" "${doc}" | grep -vE -- "#[[:space:]]*${workspace_version}([^0-9]|$)" || true)" |
| 301 | if [[ -n "${stale_version_comments}" ]]; then |
| 302 | echo "::error::${doc} has 'codewhale --version # X' snippet(s) not on ${workspace_version}:" >&2 |
| 303 | echo "${stale_version_comments}" >&2 |
| 304 | fail=1 |
| 305 | fi |
| 306 | done |
| 307 | |
| 308 | # The publish pointer can wrap onto the next line; also scan the line after the lead-in. |
| 309 | 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)" |
| 310 | if [[ -n "${wrapper_pointer_version}" && "${wrapper_pointer_version}" != "v${workspace_version}" ]]; then |
| 311 | echo "::error::docs/INSTALL.md npm-wrapper publish pointer is ${wrapper_pointer_version}, want v${workspace_version}." >&2 |
| 312 | fail=1 |
| 313 | fi |
| 314 | |
| 315 | 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)" |
| 316 | if [[ "${remote_smoke_tag}" != "v${workspace_version}" ]]; then |
| 317 | echo "::error::scripts/remote-smoke/setup-vm.sh defaults to ${remote_smoke_tag:-<missing>}, want v${workspace_version}." >&2 |
| 318 | fail=1 |
| 319 | fi |
| 320 | |
| 321 | # 11) App-server is not a standalone binary. |
| 322 | app_server_bins="$( |
| 323 | cargo metadata --locked --format-version 1 --no-deps \ |
| 324 | | node -e ' |
| 325 | const fs = require("fs"); |
| 326 | const metadata = JSON.parse(fs.readFileSync(0, "utf8")); |
| 327 | const pkg = metadata.packages.find((p) => p.name === "codewhale-app-server"); |
| 328 | if (!pkg) { |
| 329 | process.exit(2); |
| 330 | } |
| 331 | const bins = pkg.targets |
| 332 | .filter((target) => target.kind.includes("bin")) |
| 333 | .map((target) => target.name); |
| 334 | process.stdout.write(bins.join("\n")); |
| 335 | ' |
| 336 | )" |
| 337 | if [[ -n "${app_server_bins}" ]]; then |
| 338 | echo "::error::codewhale-app-server must stay library-only; use the codewhale-cli-owned 'codewhale app-server' entrypoint instead. Unexpected binary target(s):" >&2 |
| 339 | echo "${app_server_bins}" >&2 |
| 340 | fail=1 |
| 341 | fi |
| 342 | |
| 343 | # 13) Cargo.lock in sync. |
| 344 | if ! cargo metadata --locked --format-version 1 --no-deps >/dev/null 2>&1; then |
| 345 | 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 |
| 346 | fail=1 |
| 347 | fi |
| 348 | |
| 349 | if [[ "${fail}" -eq 0 ]]; then |
| 350 | echo "Version state OK: workspace=${workspace_version}, npm=${npm_version}, npm-binary=${npm_binary_version}, lockfile in sync." |
| 351 | fi |
| 352 | |
| 353 | exit "${fail}" |
| 354 |