返回 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), the root npm lock workspace record, the remote-smoke
9 # default tag, README*.md install-tag examples when present, the public fact
10 # matrix's source-candidate version, Cargo.lock, crates/tui/CHANGELOG.md (via
11 # sync-changelog.sh), and web/lib/facts.generated.ts (via derive-facts.mjs).
12 #
13 # It does NOT write the CHANGELOG entry — add the `## [X.Y.Z] - YYYY-MM-DD`
14 # section first (see docs/RELEASE_CHECKLIST.md), then run this script, then
15 # let check-versions.sh (run at the end here) confirm everything agrees.
16 set -euo pipefail
17
18 new="${1:?usage: $0 <new-version>}"
19 if ! [[ "${new}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
20 echo "error: '${new}' is not a plain X.Y.Z version" >&2
21 exit 1
22 fi
23
24 repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
25 cd "${repo}"
26
27 # Release preparation spans generated files and two package managers. Preserve
28 # the exact starting bytes so any validation, generator, or downstream gate
29 # failure cannot strand a half-bumped checkout. The backup lives outside the
30 # repository and is removed on both success and failure.
31 transaction_dir="$(mktemp -d)"
32 transaction_active=1
33 transaction_existing="${transaction_dir}/existing"
34 transaction_missing="${transaction_dir}/missing"
35 : >"${transaction_existing}"
36 : >"${transaction_missing}"
37
38 transaction_paths=(
39 Cargo.toml
40 Cargo.lock
41 package-lock.json
42 npm/codewhale/package.json
43 scripts/remote-smoke/setup-vm.sh
44 docs/public-surface-facts.json
45 docs/INSTALL.md
46 README.md
47 README.zh-CN.md
48 README.ja-JP.md
49 README.vi.md
50 README.ko-KR.md
51 crates/tui/CHANGELOG.md
52 web/lib/facts.generated.ts
53 )
54 for manifest in crates/*/Cargo.toml; do
55 transaction_paths+=("${manifest}")
56 done
57 for path in "${transaction_paths[@]}"; do
58 if [[ -e "${path}" ]]; then
59 mkdir -p "${transaction_dir}/files/$(dirname "${path}")"
60 cp -p "${path}" "${transaction_dir}/files/${path}"
61 printf '%s\n' "${path}" >>"${transaction_existing}"
62 else
63 printf '%s\n' "${path}" >>"${transaction_missing}"
64 fi
65 done
66
67 finish_transaction() {
68 status=$?
69 trap - EXIT
70 if [[ "${transaction_active}" == "1" && "${status}" != "0" ]]; then
71 while IFS= read -r path; do
72 [[ -n "${path}" ]] || continue
73 cp -p "${transaction_dir}/files/${path}" "${path}"
74 done <"${transaction_existing}"
75 while IFS= read -r path; do
76 [[ -n "${path}" ]] || continue
77 rm -f -- "${path}"
78 done <"${transaction_missing}"
79 echo "Release preparation failed; restored the checkout's pre-run release files." >&2
80 fi
81 rm -rf -- "${transaction_dir}"
82 exit "${status}"
83 }
84 trap finish_transaction EXIT
85
86 old="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
87 if ! grep -q "^## \[${new}\]" CHANGELOG.md; then
88 echo "warning: CHANGELOG.md has no '## [${new}]' entry yet — add it before tagging" >&2
89 fi
90
91 if [[ "${old}" != "${new}" ]]; then
92 echo "Bumping ${old} -> ${new}"
93
94 OLD_VERSION="${old}" NEW_VERSION="${new}" python3 - <<'PY'
95 import json, os, pathlib, re, sys
96
97 old, new = os.environ["OLD_VERSION"], os.environ["NEW_VERSION"]
98 old_re = re.escape(old)
99 readmes = [
100 "README.md",
101 "README.zh-CN.md",
102 "README.ja-JP.md",
103 "README.vi.md",
104 "README.ko-KR.md",
105 ]
106
107 def bump(path, pattern, repl, minimum):
108 p = pathlib.Path(path)
109 text = p.read_text()
110 out, n = re.subn(pattern, repl, text, flags=re.MULTILINE)
111 if n < minimum:
112 sys.exit(f"error: expected >= {minimum} replacement(s) in {path}, made {n}")
113 p.write_text(out)
114 print(f" {path}: {n} replacement(s)")
115
116 # Validate every versioned README install tag before writing any file. A README
117 # with no pinned tag is valid; if a tag exists, it must match the workspace so
118 # the release helper cannot silently preserve stale public install instructions.
119 release_tag_pattern = re.compile(r"--tag v([0-9]+\.[0-9]+\.[0-9]+)\b")
120 for readme in readmes:
121 versions = sorted(set(release_tag_pattern.findall(pathlib.Path(readme).read_text())))
122 stale = [version for version in versions if version != old]
123 if stale:
124 found = ", ".join(stale)
125 sys.exit(
126 f"error: {readme} has release tag version(s) {found}; expected {old}"
127 )
128
129 # 1) Workspace version.
130 bump("Cargo.toml", rf'^version = "{old_re}"$', f'version = "{new}"', 1)
131
132 # 2) Internal codewhale-* dependency pins in every crate manifest.
133 total = 0
134 for manifest in sorted(pathlib.Path("crates").glob("*/Cargo.toml")):
135 text = manifest.read_text()
136 out, n = re.subn(
137 rf'(codewhale-[a-z0-9-]+\s*=\s*\{{[^}}]*version = "){old_re}(")',
138 rf"\g<1>{new}\g<2>",
139 text,
140 )
141 if n:
142 manifest.write_text(out)
143 print(f" {manifest}: {n} pin(s)")
144 total += n
145 if total == 0:
146 sys.exit("error: no internal dependency pins were bumped — wrong old version?")
147
148 # 3) npm wrapper.
149 bump(
150 "npm/codewhale/package.json",
151 rf'("(?:version|codewhaleBinaryVersion)": "){old_re}(")',
152 rf"\g<1>{new}\g<2>",
153 2,
154 )
155
156 # 4) README install-tag examples (all translations, when present).
157 for readme in readmes:
158 p = pathlib.Path(readme)
159 text = p.read_text()
160 out, n = re.subn(rf"--tag v{old_re}\b", f"--tag v{new}", text)
161 if n:
162 p.write_text(out)
163 print(f" {readme}: {n} install-tag replacement(s)")
164 else:
165 print(f" {readme}: no versioned install-tag example; skipped")
166
167 # 5) Legacy numeric install/version snippets, if a branch still carries them.
168 # Current docs deliberately describe installed output generically, so zero
169 # matches is valid. If numeric forms exist, validate that they agree with
170 # the old workspace version before replacing them.
171 version_doc_files = [
172 "README.md",
173 "README.zh-CN.md",
174 "README.ja-JP.md",
175 "README.vi.md",
176 "README.ko-KR.md",
177 "docs/INSTALL.md",
178 ]
179 for doc in version_doc_files:
180 p = pathlib.Path(doc)
181 text = p.read_text()
182 versions = sorted(set(re.findall(r"codewhale --version\s+#\s*([0-9]+\.[0-9]+\.[0-9]+)\b", text)))
183 stale = [version for version in versions if version != old]
184 if stale:
185 sys.exit(
186 f"error: {doc} has version-comment value(s) {', '.join(stale)}; expected {old}"
187 )
188 out, n = re.subn(
189 rf"(codewhale --version\s+#\s*){old_re}\b", rf"\g<1>{new}", text
190 )
191 if n:
192 p.write_text(out)
193 print(f" {doc}: {n} version-comment replacement(s)")
194
195 install = pathlib.Path("docs/INSTALL.md")
196 install_text = install.read_text()
197 pointer_versions = sorted(set(re.findall(r"wrapper is published at\s+v([0-9]+\.[0-9]+\.[0-9]+)\b", install_text)))
198 stale_pointers = [version for version in pointer_versions if version != old]
199 if stale_pointers:
200 sys.exit(
201 "error: docs/INSTALL.md has npm-wrapper publish pointer version(s) "
202 f"{', '.join(stale_pointers)}; expected {old}"
203 )
204 install_out, pointer_hits = re.subn(
205 rf"(wrapper is published at\s+)v{old_re}\b",
206 rf"\g<1>v{new}",
207 install_text,
208 )
209 if pointer_hits:
210 install.write_text(install_out)
211 print(f" docs/INSTALL.md: {pointer_hits} publish-pointer replacement(s)")
212
213 # 6) Root npm lock workspace record. Keep the rest of the lock byte-stable.
214 lock = pathlib.Path("package-lock.json")
215 lock_text = lock.read_text()
216 lock_out, lock_hits = re.subn(
217 rf'("npm/codewhale"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
218 rf"\g<1>{new}\g<2>",
219 lock_text,
220 count=1,
221 )
222 if lock_hits != 1:
223 sys.exit(
224 "error: expected package-lock.json packages['npm/codewhale'].version "
225 f"to be {old}; made {lock_hits} replacement(s)"
226 )
227 lock.write_text(lock_out)
228 print(" package-lock.json: 1 npm workspace replacement")
229
230 # 7) Remote published-asset smoke defaults to the version being prepared.
231 bump(
232 "scripts/remote-smoke/setup-vm.sh",
233 rf'(RELEASE_TAG="\$\{{RELEASE_TAG:-v){old_re}(\}}")',
234 rf"\g<1>{new}\g<2>",
235 1,
236 )
237
238 # 8) Public facts distinguish source candidate from latest published release.
239 # Change only sourceCandidate.version; the published object and screenshot
240 # provenance must remain untouched until separately evidenced.
241 facts = pathlib.Path("docs/public-surface-facts.json")
242 facts_text = facts.read_text()
243 facts_json = json.loads(facts_text)
244 candidate = facts_json.get("sourceCandidate", {})
245 if candidate.get("version") != old:
246 sys.exit(
247 "error: docs/public-surface-facts.json sourceCandidate.version is "
248 f"{candidate.get('version')!r}; expected {old!r}"
249 )
250 published_before = facts_json.get("latestPublishedRelease")
251 facts_out, facts_hits = re.subn(
252 rf'("sourceCandidate"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
253 rf"\g<1>{new}\g<2>",
254 facts_text,
255 count=1,
256 )
257 if facts_hits != 1:
258 sys.exit("error: failed to update sourceCandidate.version exactly once")
259 facts_after = json.loads(facts_out)
260 if facts_after.get("latestPublishedRelease") != published_before:
261 sys.exit("error: release preparation must not change latestPublishedRelease")
262 facts.write_text(facts_out)
263 print(" docs/public-surface-facts.json: 1 source-candidate replacement")
264 PY
265
266 echo "Refreshing Cargo.lock..."
267 cargo update --workspace --offline >/dev/null
268 else
269 echo "Workspace is already at ${new}; refreshing generated release state and rerunning gates."
270 NEW_VERSION="${new}" python3 - <<'PY'
271 import json, os, pathlib, sys
272
273 expected = os.environ["NEW_VERSION"]
274 facts = pathlib.Path("docs/public-surface-facts.json")
275 candidate = json.loads(facts.read_text()).get("sourceCandidate", {})
276 actual = candidate.get("version")
277 if actual != expected:
278 sys.exit(
279 "error: docs/public-surface-facts.json sourceCandidate.version is "
280 f"{actual!r}; expected {expected!r}"
281 )
282 PY
283 fi
284
285 echo "Regenerating crates/tui/CHANGELOG.md slice..."
286 ./scripts/sync-changelog.sh
287
288 echo "Regenerating web/lib/facts.generated.ts..."
289 node web/scripts/derive-facts.mjs
290
291 echo "Validating..."
292 ./scripts/release/check-versions.sh
293 ./scripts/release/check-ohos-deps.sh
294 transaction_active=0
295 echo "Done. Review 'git diff', commit, and follow docs/RELEASE_CHECKLIST.md."
296
296 lines BASH