2f5a824c19
Release preparation now updates the runtime SDK and VS Code extension manifests and locks alongside the Rust and npm wrapper versions. The normal version gate checks the same records that the tag workflow requires, and the transactional fixture proves the expanded bump and rollback set.
359 lines
12 KiB
Bash
Executable File
359 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Bump every version-bearing file for a release in one shot.
|
|
#
|
|
# Usage: ./scripts/release/prepare-release.sh <new-version>
|
|
#
|
|
# Touches: Cargo.toml (workspace version), crates/*/Cargo.toml (internal
|
|
# codewhale-* dependency pins), npm/codewhale/package.json (version +
|
|
# codewhaleBinaryVersion), npm/runtime-sdk/package.json, the VS Code extension
|
|
# package and lock, the root npm lock workspace records, the remote-smoke default
|
|
# tag, README*.md install-tag examples when present, the public fact matrix's
|
|
# source-candidate version, Cargo.lock, crates/tui/CHANGELOG.md (via
|
|
# sync-changelog.sh), and web/lib/facts.generated.ts (via derive-facts.mjs).
|
|
#
|
|
# It does NOT write the CHANGELOG entry — add the `## [X.Y.Z] - YYYY-MM-DD`
|
|
# section first (see docs/RELEASE_CHECKLIST.md), then run this script, then
|
|
# let check-versions.sh (run at the end here) confirm everything agrees.
|
|
set -euo pipefail
|
|
|
|
new="${1:?usage: $0 <new-version>}"
|
|
if ! [[ "${new}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "error: '${new}' is not a plain X.Y.Z version" >&2
|
|
exit 1
|
|
fi
|
|
|
|
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
cd "${repo}"
|
|
|
|
# Release preparation spans generated files and two package managers. Preserve
|
|
# the exact starting bytes so any validation, generator, or downstream gate
|
|
# failure cannot strand a half-bumped checkout. The backup lives outside the
|
|
# repository and is removed on both success and failure.
|
|
transaction_dir="$(mktemp -d)"
|
|
transaction_active=1
|
|
transaction_existing="${transaction_dir}/existing"
|
|
transaction_missing="${transaction_dir}/missing"
|
|
: >"${transaction_existing}"
|
|
: >"${transaction_missing}"
|
|
|
|
transaction_paths=(
|
|
Cargo.toml
|
|
Cargo.lock
|
|
package-lock.json
|
|
npm/codewhale/package.json
|
|
npm/runtime-sdk/package.json
|
|
extensions/vscode/package.json
|
|
extensions/vscode/package-lock.json
|
|
scripts/remote-smoke/setup-vm.sh
|
|
docs/public-surface-facts.json
|
|
docs/INSTALL.md
|
|
README.md
|
|
README.zh-CN.md
|
|
README.ja-JP.md
|
|
README.vi.md
|
|
README.ko-KR.md
|
|
crates/tui/CHANGELOG.md
|
|
web/lib/facts.generated.ts
|
|
)
|
|
for manifest in crates/*/Cargo.toml; do
|
|
transaction_paths+=("${manifest}")
|
|
done
|
|
for path in "${transaction_paths[@]}"; do
|
|
if [[ -e "${path}" ]]; then
|
|
mkdir -p "${transaction_dir}/files/$(dirname "${path}")"
|
|
cp -p "${path}" "${transaction_dir}/files/${path}"
|
|
printf '%s\n' "${path}" >>"${transaction_existing}"
|
|
else
|
|
printf '%s\n' "${path}" >>"${transaction_missing}"
|
|
fi
|
|
done
|
|
|
|
finish_transaction() {
|
|
status=$?
|
|
trap - EXIT
|
|
if [[ "${transaction_active}" == "1" && "${status}" != "0" ]]; then
|
|
while IFS= read -r path; do
|
|
[[ -n "${path}" ]] || continue
|
|
cp -p "${transaction_dir}/files/${path}" "${path}"
|
|
done <"${transaction_existing}"
|
|
while IFS= read -r path; do
|
|
[[ -n "${path}" ]] || continue
|
|
rm -f -- "${path}"
|
|
done <"${transaction_missing}"
|
|
echo "Release preparation failed; restored the checkout's pre-run release files." >&2
|
|
fi
|
|
rm -rf -- "${transaction_dir}"
|
|
exit "${status}"
|
|
}
|
|
trap finish_transaction EXIT
|
|
|
|
old="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
|
|
if ! grep -q "^## \[${new}\]" CHANGELOG.md; then
|
|
echo "warning: CHANGELOG.md has no '## [${new}]' entry yet — add it before tagging" >&2
|
|
fi
|
|
|
|
if [[ "${old}" != "${new}" ]]; then
|
|
echo "Bumping ${old} -> ${new}"
|
|
|
|
OLD_VERSION="${old}" NEW_VERSION="${new}" python3 - <<'PY'
|
|
import json, os, pathlib, re, sys
|
|
|
|
old, new = os.environ["OLD_VERSION"], os.environ["NEW_VERSION"]
|
|
old_re = re.escape(old)
|
|
readmes = [
|
|
"README.md",
|
|
"README.zh-CN.md",
|
|
"README.ja-JP.md",
|
|
"README.vi.md",
|
|
"README.ko-KR.md",
|
|
]
|
|
|
|
def bump(path, pattern, repl, minimum):
|
|
p = pathlib.Path(path)
|
|
text = p.read_text()
|
|
out, n = re.subn(pattern, repl, text, flags=re.MULTILINE)
|
|
if n < minimum:
|
|
sys.exit(f"error: expected >= {minimum} replacement(s) in {path}, made {n}")
|
|
p.write_text(out)
|
|
print(f" {path}: {n} replacement(s)")
|
|
|
|
# Validate every versioned README install tag before writing any file. A README
|
|
# with no pinned tag is valid; if a tag exists, it must match the workspace so
|
|
# the release helper cannot silently preserve stale public install instructions.
|
|
release_tag_pattern = re.compile(r"--tag v([0-9]+\.[0-9]+\.[0-9]+)\b")
|
|
for readme in readmes:
|
|
versions = sorted(set(release_tag_pattern.findall(pathlib.Path(readme).read_text())))
|
|
stale = [version for version in versions if version != old]
|
|
if stale:
|
|
found = ", ".join(stale)
|
|
sys.exit(
|
|
f"error: {readme} has release tag version(s) {found}; expected {old}"
|
|
)
|
|
|
|
# 1) Workspace version.
|
|
bump("Cargo.toml", rf'^version = "{old_re}"$', f'version = "{new}"', 1)
|
|
|
|
# 2) Internal codewhale-* dependency pins in every crate manifest.
|
|
total = 0
|
|
for manifest in sorted(pathlib.Path("crates").glob("*/Cargo.toml")):
|
|
text = manifest.read_text()
|
|
out, n = re.subn(
|
|
rf'(codewhale-[a-z0-9-]+\s*=\s*\{{[^}}]*version = "){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
text,
|
|
)
|
|
if n:
|
|
manifest.write_text(out)
|
|
print(f" {manifest}: {n} pin(s)")
|
|
total += n
|
|
if total == 0:
|
|
sys.exit("error: no internal dependency pins were bumped — wrong old version?")
|
|
|
|
# 3) npm wrapper.
|
|
bump(
|
|
"npm/codewhale/package.json",
|
|
rf'("(?:version|codewhaleBinaryVersion)": "){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
2,
|
|
)
|
|
|
|
# The runtime SDK and VS Code extension are versioned release artifacts too.
|
|
bump(
|
|
"npm/runtime-sdk/package.json",
|
|
rf'^( "version": "){old_re}(",?)$',
|
|
rf"\g<1>{new}\g<2>",
|
|
1,
|
|
)
|
|
bump(
|
|
"extensions/vscode/package.json",
|
|
rf'^( "version": "){old_re}(",?)$',
|
|
rf"\g<1>{new}\g<2>",
|
|
1,
|
|
)
|
|
|
|
# 4) README install-tag examples (all translations, when present).
|
|
for readme in readmes:
|
|
p = pathlib.Path(readme)
|
|
text = p.read_text()
|
|
out, n = re.subn(rf"--tag v{old_re}\b", f"--tag v{new}", text)
|
|
if n:
|
|
p.write_text(out)
|
|
print(f" {readme}: {n} install-tag replacement(s)")
|
|
else:
|
|
print(f" {readme}: no versioned install-tag example; skipped")
|
|
|
|
# 5) Legacy numeric install/version snippets, if a branch still carries them.
|
|
# Current docs deliberately describe installed output generically, so zero
|
|
# matches is valid. If numeric forms exist, validate that they agree with
|
|
# the old workspace version before replacing them.
|
|
version_doc_files = [
|
|
"README.md",
|
|
"README.zh-CN.md",
|
|
"README.ja-JP.md",
|
|
"README.vi.md",
|
|
"README.ko-KR.md",
|
|
"docs/INSTALL.md",
|
|
]
|
|
for doc in version_doc_files:
|
|
p = pathlib.Path(doc)
|
|
text = p.read_text()
|
|
versions = sorted(set(re.findall(r"codewhale --version\s+#\s*([0-9]+\.[0-9]+\.[0-9]+)\b", text)))
|
|
stale = [version for version in versions if version != old]
|
|
if stale:
|
|
sys.exit(
|
|
f"error: {doc} has version-comment value(s) {', '.join(stale)}; expected {old}"
|
|
)
|
|
out, n = re.subn(
|
|
rf"(codewhale --version\s+#\s*){old_re}\b", rf"\g<1>{new}", text
|
|
)
|
|
if n:
|
|
p.write_text(out)
|
|
print(f" {doc}: {n} version-comment replacement(s)")
|
|
|
|
install = pathlib.Path("docs/INSTALL.md")
|
|
install_text = install.read_text()
|
|
pointer_versions = sorted(set(re.findall(r"wrapper is published at\s+v([0-9]+\.[0-9]+\.[0-9]+)\b", install_text)))
|
|
stale_pointers = [version for version in pointer_versions if version != old]
|
|
if stale_pointers:
|
|
sys.exit(
|
|
"error: docs/INSTALL.md has npm-wrapper publish pointer version(s) "
|
|
f"{', '.join(stale_pointers)}; expected {old}"
|
|
)
|
|
install_out, pointer_hits = re.subn(
|
|
rf"(wrapper is published at\s+)v{old_re}\b",
|
|
rf"\g<1>v{new}",
|
|
install_text,
|
|
)
|
|
if pointer_hits:
|
|
install.write_text(install_out)
|
|
print(f" docs/INSTALL.md: {pointer_hits} publish-pointer replacement(s)")
|
|
|
|
# 6) npm lock workspace records. Keep dependency records byte-stable.
|
|
lock = pathlib.Path("package-lock.json")
|
|
lock_text = lock.read_text()
|
|
lock_out, wrapper_lock_hits = re.subn(
|
|
rf'("npm/codewhale"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
lock_text,
|
|
count=1,
|
|
)
|
|
if wrapper_lock_hits != 1:
|
|
sys.exit(
|
|
"error: expected package-lock.json packages['npm/codewhale'].version "
|
|
f"to be {old}; made {wrapper_lock_hits} replacement(s)"
|
|
)
|
|
lock_out, sdk_lock_hits = re.subn(
|
|
rf'("npm/runtime-sdk"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
lock_out,
|
|
count=1,
|
|
)
|
|
if sdk_lock_hits != 1:
|
|
sys.exit(
|
|
"error: expected package-lock.json packages['npm/runtime-sdk'].version "
|
|
f"to be {old}; made {sdk_lock_hits} replacement(s)"
|
|
)
|
|
lock.write_text(lock_out)
|
|
print(" package-lock.json: 2 npm workspace replacements")
|
|
|
|
vscode_lock = pathlib.Path("extensions/vscode/package-lock.json")
|
|
vscode_lock_text = vscode_lock.read_text()
|
|
vscode_lock_json = json.loads(vscode_lock_text)
|
|
if vscode_lock_json.get("version") != old:
|
|
sys.exit(
|
|
"error: extensions/vscode/package-lock.json root version is "
|
|
f"{vscode_lock_json.get('version')!r}; expected {old!r}"
|
|
)
|
|
workspace_record = vscode_lock_json.get("packages", {}).get("", {})
|
|
if workspace_record.get("version") != old:
|
|
sys.exit(
|
|
"error: extensions/vscode/package-lock.json packages[''].version is "
|
|
f"{workspace_record.get('version')!r}; expected {old!r}"
|
|
)
|
|
vscode_lock_out, vscode_lock_hits = re.subn(
|
|
rf'("version"\s*:\s*"){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
vscode_lock_text,
|
|
count=2,
|
|
)
|
|
if vscode_lock_hits != 2:
|
|
sys.exit(
|
|
"error: expected two VS Code package-lock version replacements; "
|
|
f"made {vscode_lock_hits}"
|
|
)
|
|
vscode_lock_after = json.loads(vscode_lock_out)
|
|
if (
|
|
vscode_lock_after.get("version") != new
|
|
or vscode_lock_after.get("packages", {}).get("", {}).get("version") != new
|
|
):
|
|
sys.exit("error: VS Code package-lock version replacement hit wrong records")
|
|
vscode_lock.write_text(vscode_lock_out)
|
|
print(" extensions/vscode/package-lock.json: 2 workspace replacements")
|
|
|
|
# 7) Remote published-asset smoke defaults to the version being prepared.
|
|
bump(
|
|
"scripts/remote-smoke/setup-vm.sh",
|
|
rf'(RELEASE_TAG="\$\{{RELEASE_TAG:-v){old_re}(\}}")',
|
|
rf"\g<1>{new}\g<2>",
|
|
1,
|
|
)
|
|
|
|
# 8) Public facts distinguish source candidate from latest published release.
|
|
# Change only sourceCandidate.version; the published object and screenshot
|
|
# provenance must remain untouched until separately evidenced.
|
|
facts = pathlib.Path("docs/public-surface-facts.json")
|
|
facts_text = facts.read_text()
|
|
facts_json = json.loads(facts_text)
|
|
candidate = facts_json.get("sourceCandidate", {})
|
|
if candidate.get("version") != old:
|
|
sys.exit(
|
|
"error: docs/public-surface-facts.json sourceCandidate.version is "
|
|
f"{candidate.get('version')!r}; expected {old!r}"
|
|
)
|
|
published_before = facts_json.get("latestPublishedRelease")
|
|
facts_out, facts_hits = re.subn(
|
|
rf'("sourceCandidate"\s*:\s*\{{[\s\S]*?"version"\s*:\s*"){old_re}(")',
|
|
rf"\g<1>{new}\g<2>",
|
|
facts_text,
|
|
count=1,
|
|
)
|
|
if facts_hits != 1:
|
|
sys.exit("error: failed to update sourceCandidate.version exactly once")
|
|
facts_after = json.loads(facts_out)
|
|
if facts_after.get("latestPublishedRelease") != published_before:
|
|
sys.exit("error: release preparation must not change latestPublishedRelease")
|
|
facts.write_text(facts_out)
|
|
print(" docs/public-surface-facts.json: 1 source-candidate replacement")
|
|
PY
|
|
|
|
echo "Refreshing Cargo.lock..."
|
|
cargo update --workspace --offline >/dev/null
|
|
else
|
|
echo "Workspace is already at ${new}; refreshing generated release state and rerunning gates."
|
|
NEW_VERSION="${new}" python3 - <<'PY'
|
|
import json, os, pathlib, sys
|
|
|
|
expected = os.environ["NEW_VERSION"]
|
|
facts = pathlib.Path("docs/public-surface-facts.json")
|
|
candidate = json.loads(facts.read_text()).get("sourceCandidate", {})
|
|
actual = candidate.get("version")
|
|
if actual != expected:
|
|
sys.exit(
|
|
"error: docs/public-surface-facts.json sourceCandidate.version is "
|
|
f"{actual!r}; expected {expected!r}"
|
|
)
|
|
PY
|
|
fi
|
|
|
|
echo "Regenerating crates/tui/CHANGELOG.md slice..."
|
|
./scripts/sync-changelog.sh
|
|
|
|
echo "Regenerating web/lib/facts.generated.ts..."
|
|
node web/scripts/derive-facts.mjs
|
|
|
|
echo "Validating..."
|
|
./scripts/release/check-versions.sh
|
|
./scripts/release/check-ohos-deps.sh
|
|
transaction_active=0
|
|
echo "Done. Review 'git diff', commit, and follow docs/RELEASE_CHECKLIST.md."
|