返回 CodeWhale
prepare-release.sh
根目录 / scripts / release / prepare-release.sh
1 #!/usr/bin/env bash
2 # Bump every version-bearing file for a release in one shot.
3 #
4 # Usage: ./scripts/release/prepare-release.sh <new-version>
5 #
6 # Touches: Cargo.toml (workspace version), crates/*/Cargo.toml (internal
7 # codewhale-* dependency pins), npm/codewhale/package.json (version +
8 # codewhaleBinaryVersion), npm/runtime-sdk/package.json, the VS Code extension
9 # package and lock, the root npm lock workspace records, the remote-smoke default
10 # tag, README*.md install-tag examples when present, the public fact matrix's
11 # source-candidate version, Cargo.lock, crates/tui/CHANGELOG.md (via
12 # sync-changelog.sh), web/lib/facts.generated.ts (via derive-facts.mjs), and
13 # web/lib/changelog.generated.ts (via derive-changelog.mjs).
14 #
15 # It does NOT write the CHANGELOG entry — add the `## [X.Y.Z] - YYYY-MM-DD`
16 # section first (see docs/RELEASE_CHECKLIST.md), then run this script, then
17 # let check-versions.sh (run at the end here) confirm everything agrees.
18 set -euo pipefail
19
20 new="${1:?usage: $0 <new-version>}"
21 if ! [[ "${new}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
22 echo "error: '${new}' is not a plain X.Y.Z version" >&2
23 exit 1
24 fi
25
26 repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
27 cd "${repo}"
28
29 # Release preparation spans generated files and two package managers. Preserve
30 # the exact starting bytes so any validation, generator, or downstream gate
31 # failure cannot strand a half-bumped checkout. The backup lives outside the
32 # repository and is removed on both success and failure.
33 transaction_dir="$(mktemp -d)"
34 transaction_active=1
35 transaction_existing="${transaction_dir}/existing"
36 transaction_missing="${transaction_dir}/missing"
37 : >"${transaction_existing}"
38 : >"${transaction_missing}"
39
40 transaction_paths=(
41 Cargo.toml
42 Cargo.lock
43 package-lock.json
44 npm/codewhale/package.json
45 npm/runtime-sdk/package.json
46 extensions/vscode/package.json
47 extensions/vscode/package-lock.json
48 scripts/remote-smoke/setup-vm.sh
49 docs/public-surface-facts.json
50 docs/INSTALL.md
51 README.md
52 README.zh-CN.md
53 README.ja-JP.md
54 README.vi.md
55 README.ko-KR.md
56 crates/tui/CHANGELOG.md
57 web/lib/facts.generated.ts
58 web/lib/changelog.generated.ts
59 )
60 for manifest in crates/*/Cargo.toml; do
61 transaction_paths+=("${manifest}")
62 done
63 for path in "${transaction_paths[@]}"; do
64 if [[ -e "${path}" ]]; then
65 mkdir -p "${transaction_dir}/files/$(dirname "${path}")"
66 cp -p "${path}" "${transaction_dir}/files/${path}"
67 printf '%s\n' "${path}" >>"${transaction_existing}"
68 else
69 printf '%s\n' "${path}" >>"${transaction_missing}"
70 fi
71 done
72
73 finish_transaction() {
74 status=$?
75 trap - EXIT
76 if [[ "${transaction_active}" == "1" && "${status}" != "0" ]]; then
77 while IFS= read -r path; do
78 [[ -n "${path}" ]] || continue
79 cp -p "${transaction_dir}/files/${path}" "${path}"
80 done <"${transaction_existing}"
81 while IFS= read -r path; do
82 [[ -n "${path}" ]] || continue
83 rm -f -- "${path}"
84 done <"${transaction_missing}"
85 echo "Release preparation failed; restored the checkout's pre-run release files." >&2
86 fi
87 rm -rf -- "${transaction_dir}"
88 exit "${status}"
89 }
90 trap finish_transaction EXIT
91
92 old="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
93 if ! grep -q "^## \[${new}\]" CHANGELOG.md; then
94 echo "warning: CHANGELOG.md has no '## [${new}]' entry yet — add it before tagging" >&2
95 fi
96
97 if [[ "${old}" != "${new}" ]]; then
98 echo "Bumping ${old} -> ${new}"
99
100 OLD_VERSION="${old}" NEW_VERSION="${new}" python3 - <<'PY'
101 import json, os, pathlib, re, sys
102
103 old, new = os.environ["OLD_VERSION"], os.environ["NEW_VERSION"]
104 old_re = re.escape(old)
105 readmes = [
106 "README.md",
107 "README.zh-CN.md",
108 "README.ja-JP.md",
109 "README.vi.md",
110 "README.ko-KR.md",
111 ]
112
113 def bump(path, pattern, repl, minimum):
114 p = pathlib.Path(path)
115 text = p.read_text()
116 out, n = re.subn(pattern, repl, text, flags=re.MULTILINE)
117 if n < minimum:
118 sys.exit(f"error: expected >= {minimum} replacement(s) in {path}, made {n}")
119 p.write_text(out)
120 print(f" {path}: {n} replacement(s)")
121
122 # Validate every versioned README install tag before writing any file. A README
123 # with no pinned tag is valid; if a tag exists, it must match the workspace so
124 # the release helper cannot silently preserve stale public install instructions.
125 release_tag_pattern = re.compile(r"--tag v([0-9]+\.[0-9]+\.[0-9]+)\b")
126 for readme in readmes:
127 versions = sorted(set(release_tag_pattern.findall(pathlib.Path(readme).read_text())))
128 stale = [version for version in versions if version != old]
129 if stale:
130 found = ", ".join(stale)
131 sys.exit(
132 f"error: {readme} has release tag version(s) {found}; expected {old}"
133 )
134
135 # 1) Workspace version.
136 bump("Cargo.toml", rf'^version = "{old_re}"$', f'version = "{new}"', 1)
137
138 # 2) Internal codewhale-* dependency pins in every crate manifest.
139 total = 0
140 for manifest in sorted(pathlib.Path("crates").glob("*/Cargo.toml")):
141 text = manifest.read_text()
142 out, n = re.subn(
143 rf'(codewhale-[a-z0-9-]+\s*=\s*\{{[^}}]*version = "){old_re}(")',
144 rf"\g<1>{new}\g<2>",
145 text,
146 )
147 if n:
148 manifest.write_text(out)
149 print(f" {manifest}: {n} pin(s)")
150 total += n
151 if total == 0:
152 sys.exit("error: no internal dependency pins were bumped — wrong old version?")
153
154 # 3) npm wrapper.
155 bump(
156 "npm/codewhale/package.json",
157 rf'("(?:version|codewhaleBinaryVersion)": "){old_re}(")',
158 rf"\g<1>{new}\g<2>",
159 2,
160 )
161
162 # The runtime SDK and VS Code extension are versioned release artifacts too.
163 bump(
164 "npm/runtime-sdk/package.json",
165 rf'^( "version": "){old_re}(",?)$',
166 rf"\g<1>{new}\g<2>",
167 1,
168 )
169 bump(
170 "extensions/vscode/package.json",
171 rf'^( "version": "){old_re}(",?)$',
172 rf"\g<1>{new}\g<2>",
173 1,
174 )
175
176 # 4) README install-tag examples (all translations, when present).
177 for readme in readmes:
178 p = pathlib.Path(readme)
179 text = p.read_text()
180 out, n = re.subn(rf"--tag v{old_re}\b", f"--tag v{new}", text)
181 if n:
182 p.write_text(out)
183 print(f" {readme}: {n} install-tag replacement(s)")
184 else:
185 print(f" {readme}: no versioned install-tag example; skipped")
186
187 # 5) Legacy numeric install/version snippets, if a branch still carries them.
188 # Current docs deliberately describe installed output generically, so zero
189 # matches is valid. If numeric forms exist, validate that they agree with
190 # the old workspace version before replacing them.
191 version_doc_files = [
192 "README.md",
193 "README.zh-CN.md",
194 "README.ja-JP.md",
195 "README.vi.md",
196 "README.ko-KR.md",
197 "docs/INSTALL.md",
198 ]
199 for doc in version_doc_files:
200 p = pathlib.Path(doc)
201 text = p.read_text()
202 versions = sorted(set(re.findall(r"codewhale --version\s+#\s*([0-9]+\.[0-9]+\.[0-9]+)\b", text)))
203 stale = [version for version in versions if version != old]
204 if stale:
205 sys.exit(
206 f"error: {doc} has version-comment value(s) {', '.join(stale)}; expected {old}"
207 )
208 out, n = re.subn(
209 rf"(codewhale --version\s+#\s*){old_re}\b", rf"\g<1>{new}", text
210 )
211 if n:
212 p.write_text(out)
213 print(f" {doc}: {n} version-comment replacement(s)")
214
215 install = pathlib.Path("docs/INSTALL.md")
216 install_text = install.read_text()
217 pointer_versions = sorted(set(re.findall(r"wrapper is published at\s+v([0-9]+\.[0-9]+\.[0-9]+)\b", install_text)))
218 stale_pointers = [version for version in pointer_versions if version != old]
219 if stale_pointers:
220 sys.exit(
221 "error: docs/INSTALL.md has npm-wrapper publish pointer version(s) "
222 f"{', '.join(stale_pointers)}; expected {old}"
223 )
224 install_out, pointer_hits = re.subn(
225 rf"(wrapper is published at\s+)v{old_re}\b",
226 rf"\g<1>v{new}",
227 install_text,
228 )
229 # web/lib/public-surface-contract.test.ts asserts docs/INSTALL.md contains
230 # `v${FACTS.version} source candidate`, so the phrase has to track the bump or
231 # Web Frontend goes red on the next push.
232 install_out, candidate_hits = re.subn(
233 rf"\bv{old_re}( source candidate)",
234 rf"v{new}\g<1>",
235 install_out,
236 )
237 if candidate_hits > 1:
238 sys.exit(
239 "error: docs/INSTALL.md names 'v{0} source candidate' {1} times; "
240 "exactly one occurrence is expected".format(old, candidate_hits)
241 )
242 if pointer_hits or candidate_hits:
243 install.write_text(install_out)
244 print(
245 f" docs/INSTALL.md: {pointer_hits} publish-pointer replacement(s), "
246 f"{candidate_hits} source-candidate replacement(s)"
247 )
248
249 # 6) npm lock workspace records. Keep dependency records byte-stable.
250 lock = pathlib.Path("package-lock.json")
251 lock_text = lock.read_text()
252 lock_out, wrapper_lock_hits = re.subn(
253 rf'("npm/codewhale"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
254 rf"\g<1>{new}\g<2>",
255 lock_text,
256 count=1,
257 )
258 if wrapper_lock_hits != 1:
259 sys.exit(
260 "error: expected package-lock.json packages['npm/codewhale'].version "
261 f"to be {old}; made {wrapper_lock_hits} replacement(s)"
262 )
263 lock_out, sdk_lock_hits = re.subn(
264 rf'("npm/runtime-sdk"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
265 rf"\g<1>{new}\g<2>",
266 lock_out,
267 count=1,
268 )
269 if sdk_lock_hits != 1:
270 sys.exit(
271 "error: expected package-lock.json packages['npm/runtime-sdk'].version "
272 f"to be {old}; made {sdk_lock_hits} replacement(s)"
273 )
274 lock.write_text(lock_out)
275 print(" package-lock.json: 2 npm workspace replacements")
276
277 vscode_lock = pathlib.Path("extensions/vscode/package-lock.json")
278 vscode_lock_text = vscode_lock.read_text()
279 vscode_lock_json = json.loads(vscode_lock_text)
280 if vscode_lock_json.get("version") != old:
281 sys.exit(
282 "error: extensions/vscode/package-lock.json root version is "
283 f"{vscode_lock_json.get('version')!r}; expected {old!r}"
284 )
285 workspace_record = vscode_lock_json.get("packages", {}).get("", {})
286 if workspace_record.get("version") != old:
287 sys.exit(
288 "error: extensions/vscode/package-lock.json packages[''].version is "
289 f"{workspace_record.get('version')!r}; expected {old!r}"
290 )
291 vscode_lock_out, vscode_lock_hits = re.subn(
292 rf'("version"\s*:\s*"){old_re}(")',
293 rf"\g<1>{new}\g<2>",
294 vscode_lock_text,
295 count=2,
296 )
297 if vscode_lock_hits != 2:
298 sys.exit(
299 "error: expected two VS Code package-lock version replacements; "
300 f"made {vscode_lock_hits}"
301 )
302 vscode_lock_after = json.loads(vscode_lock_out)
303 if (
304 vscode_lock_after.get("version") != new
305 or vscode_lock_after.get("packages", {}).get("", {}).get("version") != new
306 ):
307 sys.exit("error: VS Code package-lock version replacement hit wrong records")
308 vscode_lock.write_text(vscode_lock_out)
309 print(" extensions/vscode/package-lock.json: 2 workspace replacements")
310
311 # 7) Remote published-asset smoke defaults to the version being prepared.
312 bump(
313 "scripts/remote-smoke/setup-vm.sh",
314 rf'(RELEASE_TAG="\$\{{RELEASE_TAG:-v){old_re}(\}}")',
315 rf"\g<1>{new}\g<2>",
316 1,
317 )
318
319 # 8) Public facts distinguish source candidate from latest published release.
320 # Change only sourceCandidate.version; the published object and screenshot
321 # provenance must remain untouched until separately evidenced.
322 facts = pathlib.Path("docs/public-surface-facts.json")
323 facts_text = facts.read_text()
324 facts_json = json.loads(facts_text)
325 candidate = facts_json.get("sourceCandidate", {})
326 if candidate.get("version") != old:
327 sys.exit(
328 "error: docs/public-surface-facts.json sourceCandidate.version is "
329 f"{candidate.get('version')!r}; expected {old!r}"
330 )
331 published_before = facts_json.get("latestPublishedRelease")
332 facts_out, facts_hits = re.subn(
333 rf'("sourceCandidate"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
334 rf"\g<1>{new}\g<2>",
335 facts_text,
336 count=1,
337 )
338 if facts_hits != 1:
339 sys.exit("error: failed to update sourceCandidate.version exactly once")
340 # The trust matrix states the telemetry posture in prose that names the source
341 # candidate, and web/lib/public-surface-contract.test.ts asserts the two agree
342 # (`Codewhale ${matrix.sourceCandidate.version} counts anonymous usage by
343 # default`). Bumping the field without the sentence reddens Web Frontend on the
344 # next push, which is how it broke at 0.9.14.
345 facts_out, telemetry_hits = re.subn(
346 rf"(Codewhale ){old_re}( counts anonymous usage by default)",
347 rf"\g<1>{new}\g<2>",
348 facts_out,
349 )
350 if telemetry_hits > 1:
351 sys.exit(
352 "error: the trust.telemetry source-candidate sentence appears {0} times; "
353 "exactly one occurrence is expected".format(telemetry_hits)
354 )
355 facts_after = json.loads(facts_out)
356 if facts_after.get("latestPublishedRelease") != published_before:
357 sys.exit("error: release preparation must not change latestPublishedRelease")
358 facts.write_text(facts_out)
359 print(" docs/public-surface-facts.json: 1 source-candidate replacement")
360 PY
361
362 echo "Refreshing Cargo.lock..."
363 cargo update --workspace --offline >/dev/null
364 else
365 echo "Workspace is already at ${new}; refreshing generated release state and rerunning gates."
366 NEW_VERSION="${new}" python3 - <<'PY'
367 import json, os, pathlib, sys
368
369 expected = os.environ["NEW_VERSION"]
370 facts = pathlib.Path("docs/public-surface-facts.json")
371 candidate = json.loads(facts.read_text()).get("sourceCandidate", {})
372 actual = candidate.get("version")
373 if actual != expected:
374 sys.exit(
375 "error: docs/public-surface-facts.json sourceCandidate.version is "
376 f"{actual!r}; expected {expected!r}"
377 )
378 PY
379 fi
380
381 echo "Regenerating crates/tui/CHANGELOG.md slice..."
382 ./scripts/sync-changelog.sh
383
384 echo "Regenerating web/lib/facts.generated.ts..."
385 node web/scripts/derive-facts.mjs
386
387 # Release prep always edits CHANGELOG.md, and derive-changelog.mjs is the only
388 # thing that turns it into the file the /changelog route reads. Leaving this
389 # out reddened CI on 2026-09-10 twice, because the drift gate only fires after
390 # a push. Keep it beside the other regenerators.
391 echo "Regenerating web/lib/changelog.generated.ts..."
392 node web/scripts/derive-changelog.mjs
393
394 echo "Validating..."
395 ./scripts/release/check-versions.sh
396 ./scripts/release/check-ohos-deps.sh
397 transaction_active=0
398 echo "Done. Review 'git diff', commit, and follow docs/RELEASE_CHECKLIST.md."
399
399 lines BASH