mirror of
https://github.com/feigeCode/navop.git
synced 2026-11-06 00:07:34 +00:00
产品拆分后工作区包名从 main 改成 navop_app,beta-release.sh 还在按 main 找 lock 条目,rsync 阶段直接报 <missing>。改成精确改写 navop_app / navop / navop-shell / navop-data 四个成员版本(cargo metadata --no-deps 不会重算 成员版本,靠它刷 lock 本来就不成立),并同步更新提示文案。
388 lines
14 KiB
Bash
Executable File
388 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
|
||
# 用法:
|
||
# script/beta-release.sh --base 0.23.0 --notes-file /private/tmp/navop-v0.23.0-beta.1-notes.md
|
||
# script/beta-release.sh --next --notes-file /private/tmp/navop-v0.23.0-beta.2-notes.md
|
||
# script/beta-release.sh --next --notes-file ... --dry-run
|
||
#
|
||
# 作用:在 dev 分支上一条命令发测试版。脚本会把 crates/navop_app/Cargo.toml 与三个
|
||
# app crate(apps/navop、apps/navop-shell、apps/navop-data)的
|
||
# [package].version 统一改成 X.Y.Z-beta.N(--base 从 beta.1 起步,--next
|
||
# 递增当前 beta 序号),同步 Cargo.lock 与 CHANGELOG.md,提交后创建并推送
|
||
# v* tag。统一版本:一个 tag 同发三个产品,版本必须一起 bump。
|
||
#
|
||
# tag 推送会触发 release-trigger.yml -> release.yml。预发布 tag 自动走 beta
|
||
# 通道:GitHub Release 打上 --prerelease,R2 更新清单写到
|
||
# updates/beta/latest.json,稳定清单 updates/latest.json 完全不受影响。
|
||
#
|
||
# 前置条件(缺一不可):
|
||
# 1. 当前分支是 dev(beta 版本线只走 dev,main 只承载稳定发版);
|
||
# 2. 工作区干净(--dry-run 只提示不中止);
|
||
# 3. 本地与远端都不存在同名 tag;
|
||
# 4. --notes-file 指向包含双语段落的发布说明(release.yml 会硬校验
|
||
# CHANGELOG.md 里的条目,缺了连构建都不会启动)。
|
||
#
|
||
# 版本线约定:beta 的 base 是「下一个稳定版」,例如 0.23.0-beta.N -> 0.23.0,
|
||
# 不新开 0.22.1-beta 这类补丁线。
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
||
cd "${REPO_ROOT}"
|
||
|
||
BASE=""
|
||
NEXT="false"
|
||
NOTES_FILE=""
|
||
DRY_RUN="false"
|
||
REMOTE="${REMOTE:-origin}"
|
||
MIRROR_REMOTE="${MIRROR_REMOTE:-cnb}"
|
||
MAIN_MANIFEST="${MAIN_MANIFEST:-${REPO_ROOT}/crates/navop_app/Cargo.toml}"
|
||
APP_MANIFESTS=(
|
||
"${REPO_ROOT}/apps/navop/Cargo.toml"
|
||
"${REPO_ROOT}/apps/navop-shell/Cargo.toml"
|
||
"${REPO_ROOT}/apps/navop-data/Cargo.toml"
|
||
)
|
||
LOCKFILE="${LOCKFILE:-${REPO_ROOT}/Cargo.lock}"
|
||
CHANGELOG="${CHANGELOG:-CHANGELOG.md}"
|
||
COMMITTED="false"
|
||
TAGGED="false"
|
||
PUSHED="false"
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
用法:
|
||
script/beta-release.sh --base <X.Y.Z> --notes-file <PATH> [--dry-run]
|
||
script/beta-release.sh --next --notes-file <PATH> [--dry-run]
|
||
|
||
参数:
|
||
--base <X.Y.Z> 把版本设为 <X.Y.Z>-beta.1(<X.Y.Z> 必须是下个稳定版)
|
||
--next 把当前 <X.Y.Z>-beta.N 递增为 <X.Y.Z>-beta.(N+1)
|
||
--notes-file <PATH> 发布说明文件(必填,changelog.py upsert 的输入)
|
||
--dry-run 只打印将要执行的操作,不写任何文件、不提交、不打 tag
|
||
-h, --help 显示本帮助
|
||
|
||
环境变量:
|
||
REMOTE 推送远端,默认 origin(tag 必须推到 GitHub 才会触发 release-trigger.yml)
|
||
MIRROR_REMOTE 镜像远端,默认 cnb(成功后同步推 dev 与 tag,失败只警告)
|
||
MAIN_MANIFEST Cargo.toml 路径,默认 crates/navop_app/Cargo.toml
|
||
LOCKFILE Cargo.lock 路径,默认 Cargo.lock
|
||
CHANGELOG CHANGELOG.md 路径,默认 CHANGELOG.md
|
||
|
||
示例:
|
||
script/beta-release.sh --base 0.23.0 \
|
||
--notes-file /private/tmp/navop-v0.23.0-beta.1-notes.md
|
||
EOF
|
||
}
|
||
|
||
die() {
|
||
echo "错误:$*" >&2
|
||
exit 1
|
||
}
|
||
|
||
on_error() {
|
||
local code=$?
|
||
trap - ERR
|
||
echo "" >&2
|
||
echo "错误:beta 发布流程中止(exit ${code}),未完成的部分可按下面回滚:" >&2
|
||
if [[ "${PUSHED}" == "true" ]]; then
|
||
echo " git push ${REMOTE} --delete ${TAG} # 删除已推送的远端 tag" >&2
|
||
fi
|
||
if [[ "${TAGGED}" == "true" ]]; then
|
||
echo " git tag -d ${TAG}" >&2
|
||
fi
|
||
if [[ "${COMMITTED}" == "true" ]]; then
|
||
echo " git reset --hard HEAD~1 # 回退版本 + changelog 提交" >&2
|
||
echo " git push --force-with-lease ${REMOTE} dev # 若 dev 已推送" >&2
|
||
else
|
||
echo " git checkout -- ${MAIN_MANIFEST} ${LOCKFILE} ${CHANGELOG}" >&2
|
||
fi
|
||
exit "${code}"
|
||
}
|
||
trap on_error ERR
|
||
|
||
need_value() {
|
||
if [[ $# -lt 2 || -z "${2:-}" ]]; then
|
||
die "参数 $1 需要一个值。"
|
||
fi
|
||
}
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--base)
|
||
need_value "$@"
|
||
BASE="$2"
|
||
shift 2
|
||
;;
|
||
--next)
|
||
NEXT="true"
|
||
shift
|
||
;;
|
||
--notes-file)
|
||
need_value "$@"
|
||
NOTES_FILE="$2"
|
||
shift 2
|
||
;;
|
||
--dry-run)
|
||
DRY_RUN="true"
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
usage
|
||
exit 0
|
||
;;
|
||
*)
|
||
echo "错误:未知参数 $1" >&2
|
||
usage >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# 1. 参数与发布说明校验(正式执行前全部做完,避免写了一半再失败)。
|
||
if [[ "${NEXT}" == "true" && -n "${BASE}" ]]; then
|
||
die "--base 与 --next 只能二选一。"
|
||
fi
|
||
if [[ "${NEXT}" == "false" && -z "${BASE}" ]]; then
|
||
die "必须指定 --base <X.Y.Z> 或 --next。例如:script/beta-release.sh --base 0.23.0 --notes-file <PATH>"
|
||
fi
|
||
if [[ -n "${BASE}" && ! "${BASE}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
die "--base 必须是稳定版本号 X.Y.Z(例如 0.23.0):beta 的 base 是下一个稳定版,不是 0.22.1 这样的补丁线。"
|
||
fi
|
||
if [[ -z "${NOTES_FILE}" ]]; then
|
||
echo "错误:缺少 --notes-file。beta 发布必须提供双语发布说明,release.yml 会硬校验 CHANGELOG.md 条目。" >&2
|
||
echo "示例:script/beta-release.sh --base 0.23.0 --notes-file /private/tmp/navop-v0.23.0-beta.1-notes.md" >&2
|
||
exit 1
|
||
fi
|
||
if [[ ! -f "${NOTES_FILE}" || ! -s "${NOTES_FILE}" ]]; then
|
||
die "发布说明文件不存在或为空:${NOTES_FILE}"
|
||
fi
|
||
|
||
read_manifest_version() {
|
||
local manifest="${1:-${MAIN_MANIFEST}}"
|
||
awk -F'"' '
|
||
/^\[package\]$/ { in_package = 1; next }
|
||
/^\[/ { in_package = 0 }
|
||
in_package && /^version = "/ { print $2; exit }
|
||
' "${manifest}"
|
||
}
|
||
|
||
read_lock_version() {
|
||
local package_name="${1:-navop_app}"
|
||
awk -v want="${package_name}" '
|
||
/^\[\[package\]\]$/ { in_package = 1; name = ""; next }
|
||
in_package && /^name = "/ { split($0, parts, "\""); name = parts[2] }
|
||
in_package && /^version = "/ {
|
||
split($0, parts, "\"")
|
||
if (name == want) { print parts[2]; exit }
|
||
}
|
||
' "${LOCKFILE}"
|
||
}
|
||
|
||
current_version="$(read_manifest_version)"
|
||
if [[ -z "${current_version}" ]]; then
|
||
die "无法从 ${MAIN_MANIFEST} 读取 [package].version。"
|
||
fi
|
||
for app_manifest in "${APP_MANIFESTS[@]}"; do
|
||
app_version="$(read_manifest_version "${app_manifest}")"
|
||
if [[ -z "${app_version}" ]]; then
|
||
die "无法从 ${app_manifest} 读取 [package].version。"
|
||
fi
|
||
if [[ "${app_version}" != "${current_version}" ]]; then
|
||
die "统一版本校验失败:${app_manifest} 为 ${app_version},navop_app 为 ${current_version}。请先手动对齐再发 beta。"
|
||
fi
|
||
done
|
||
echo "统一版本校验通过:navop_app 与三个 app crate 均为 ${current_version}"
|
||
|
||
# 2. 计算目标版本。
|
||
if [[ "${NEXT}" == "true" ]]; then
|
||
if [[ ! "${current_version}" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\.([0-9]+)$ ]]; then
|
||
die "当前版本 ${current_version} 不是 X.Y.Z-beta.N,--next 只能递增 beta 序号;首次发测试版请用 --base X.Y.Z。"
|
||
fi
|
||
BASE="${BASH_REMATCH[1]}"
|
||
VERSION="${BASE}-beta.$((10#${BASH_REMATCH[2]} + 1))"
|
||
else
|
||
case "${current_version}" in
|
||
"${BASE}-beta."*)
|
||
die "当前已是 ${current_version},继续发测试版请用 --next。"
|
||
;;
|
||
esac
|
||
VERSION="${BASE}-beta.1"
|
||
fi
|
||
TAG="v${VERSION}"
|
||
|
||
echo "本次 beta 发布:${VERSION}(当前 ${current_version},tag ${TAG})"
|
||
|
||
# 3. 前置校验:分支、工作区、tag 唯一性。
|
||
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||
if [[ "${BRANCH}" != "dev" ]]; then
|
||
die "beta 版本线必须走 dev(当前:${BRANCH})。稳定版请走 dev -> main 发布 PR + script/release-tag.sh。"
|
||
fi
|
||
echo "分支校验通过:dev"
|
||
|
||
if [[ -n "$(git status --porcelain)" ]]; then
|
||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||
echo "警告:工作区不干净,--dry-run 继续预览;正式执行前请先提交或暂存变更。" >&2
|
||
else
|
||
die "工作区不干净,请先提交或暂存变更(git status --porcelain 必须为空)。"
|
||
fi
|
||
fi
|
||
|
||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||
die "本地已存在标签 ${TAG}。如需重发测试版,请用 --next 递增序号。"
|
||
fi
|
||
if git ls-remote --exit-code --tags "${REMOTE}" "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||
die "远端 ${REMOTE} 已存在标签 ${TAG}。如需重发测试版,请用 --next 递增序号。"
|
||
fi
|
||
if [[ -n "${MIRROR_REMOTE}" && "${MIRROR_REMOTE}" != "${REMOTE}" ]] \
|
||
&& git ls-remote --exit-code --tags "${MIRROR_REMOTE}" "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||
die "镜像远端 ${MIRROR_REMOTE} 已存在标签 ${TAG}。如需重发测试版,请用 --next 递增序号。"
|
||
fi
|
||
echo "tag 唯一性校验通过:${TAG} 在本地、${REMOTE} 与 ${MIRROR_REMOTE} 均不存在"
|
||
|
||
# 4. --dry-run:只打印计划,绝不写文件/提交/打 tag。
|
||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||
echo ""
|
||
echo "== --dry-run:以下操作不会执行 =="
|
||
echo " 1. 四个 manifest 的 [package].version: ${current_version} -> ${VERSION}"
|
||
echo " - ${MAIN_MANIFEST}"
|
||
for app_manifest in "${APP_MANIFESTS[@]}"; do
|
||
echo " - ${app_manifest}"
|
||
done
|
||
echo " 2. ${LOCKFILE}: 同步 navop_app 与三个 app 包版本到 ${VERSION}"
|
||
echo " 3. CHANGELOG.md: python3 script/changelog.py upsert --tag ${TAG} --date $(date +%F) --notes-file ${NOTES_FILE} --changelog ${CHANGELOG}"
|
||
echo " 4. git add 四个 manifest ${LOCKFILE} ${CHANGELOG}"
|
||
echo " 5. git commit -m \"chore(release): 发布 ${TAG} 测试版\""
|
||
echo " 6. git tag -a ${TAG} -m ${TAG}"
|
||
echo " 7. git push ${REMOTE} dev && git push ${REMOTE} ${TAG}(镜像 ${MIRROR_REMOTE})"
|
||
echo ""
|
||
echo "本次 tag:${TAG}"
|
||
echo "通道:beta"
|
||
echo "R2 更新清单将写到:updates/beta/latest.json"
|
||
echo "稳定清单 updates/latest.json 不会受影响。"
|
||
exit 0
|
||
fi
|
||
|
||
# 5. 改写 navop_app 与三个 app crate 的 [package].version(统一版本)。
|
||
for bump_manifest in "${MAIN_MANIFEST}" "${APP_MANIFESTS[@]}"; do
|
||
python3 - "${bump_manifest}" "${VERSION}" <<'PY'
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
manifest_path = Path(sys.argv[1])
|
||
version = sys.argv[2]
|
||
lines = manifest_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||
|
||
in_package = False
|
||
updated = False
|
||
for index, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
if stripped == "[package]":
|
||
in_package = True
|
||
continue
|
||
if in_package and stripped.startswith("["):
|
||
in_package = False
|
||
if in_package and re.match(r'^version = "', line):
|
||
ending = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else ""
|
||
lines[index] = f'version = "{version}"{ending}'
|
||
updated = True
|
||
break
|
||
|
||
if not updated:
|
||
raise SystemExit(f"[package].version not found in {manifest_path}")
|
||
|
||
manifest_path.write_text("".join(lines), encoding="utf-8")
|
||
PY
|
||
done
|
||
|
||
# 6. 同步 Cargo.lock:工作区成员的版本由本脚本精确改写。
|
||
# 注意:`cargo metadata --no-deps` 不会重算 lock 里的成员版本,不能靠它;
|
||
# 成员条目没有 checksum,直接改 version 字段是安全的,改完由下面的回读校验兜底。
|
||
python3 - "${LOCKFILE}" "${VERSION}" <<'PY'
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
lock_path = Path(sys.argv[1])
|
||
version = sys.argv[2]
|
||
names = {"navop_app", "navop", "navop-shell", "navop-data"}
|
||
lines = lock_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||
|
||
in_package = False
|
||
package_name = ""
|
||
updated = set()
|
||
for index, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
if stripped == "[[package]]":
|
||
in_package = True
|
||
package_name = ""
|
||
continue
|
||
if stripped.startswith("[") and stripped != "[[package]]":
|
||
in_package = False
|
||
if in_package:
|
||
name_match = re.match(r'^name = "([^"]*)"$', stripped)
|
||
if name_match:
|
||
package_name = name_match.group(1)
|
||
if package_name in names and re.match(r'^version = "', stripped):
|
||
ending = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else ""
|
||
lines[index] = f'version = "{version}"{ending}'
|
||
updated.add(package_name)
|
||
|
||
missing = names - updated
|
||
if missing:
|
||
raise SystemExit(f'packages not found in lockfile: {sorted(missing)}')
|
||
|
||
lock_path.write_text("".join(lines), encoding="utf-8")
|
||
PY
|
||
|
||
for lock_package in navop_app navop navop-shell navop-data; do
|
||
lock_version="$(read_lock_version "${lock_package}")"
|
||
if [[ "${lock_version}" != "${VERSION}" ]]; then
|
||
die "${LOCKFILE} 中 ${lock_package} 包版本为 ${lock_version:-<missing>},未能同步到 ${VERSION}。"
|
||
fi
|
||
done
|
||
echo "Cargo.lock 已同步:navop_app / navop / navop-shell / navop-data -> ${VERSION}"
|
||
|
||
# 7. 写入 CHANGELOG.md 条目并回读校验(release.yml 硬校验的就是这条)。
|
||
python3 script/changelog.py upsert \
|
||
--tag "${TAG}" \
|
||
--date "$(date +%F)" \
|
||
--notes-file "${NOTES_FILE}" \
|
||
--changelog "${CHANGELOG}"
|
||
python3 script/changelog.py validate \
|
||
--tag "${TAG}" \
|
||
--changelog "${CHANGELOG}"
|
||
echo "CHANGELOG.md 已写入并校验:${TAG}"
|
||
|
||
# 8. 提交、打 tag、推送。
|
||
git add -- "${MAIN_MANIFEST}" "${APP_MANIFESTS[@]}" "${LOCKFILE}" "${CHANGELOG}"
|
||
git commit -m "chore(release): 发布 ${TAG} 测试版"
|
||
COMMITTED="true"
|
||
|
||
git tag -a "${TAG}" -m "${TAG}"
|
||
TAGGED="true"
|
||
|
||
echo "推送 dev 与 tag 到 ${REMOTE} ..."
|
||
git push "${REMOTE}" dev
|
||
git push "${REMOTE}" "${TAG}"
|
||
PUSHED="true"
|
||
|
||
# tag 只有落到 GitHub 才会触发 release-trigger.yml;镜像远端同步一份,
|
||
# 推不动只警告,不影响已经在 GitHub 上开始的发布。
|
||
if [[ -n "${MIRROR_REMOTE}" && "${MIRROR_REMOTE}" != "${REMOTE}" ]]; then
|
||
if git push "${MIRROR_REMOTE}" dev && git push "${MIRROR_REMOTE}" "${TAG}"; then
|
||
echo "已同步 dev 与 ${TAG} 到镜像远端 ${MIRROR_REMOTE}。"
|
||
else
|
||
echo "警告:镜像远端 ${MIRROR_REMOTE} 同步失败,不影响 GitHub 侧的发布流程。" >&2
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
echo "完成:已推送 ${TAG},release-trigger.yml -> release.yml 将按 beta 通道执行。"
|
||
echo "本次 tag:${TAG}"
|
||
echo "通道:beta(GitHub Release 会标记 --prerelease)"
|
||
echo "R2 更新清单将写到:updates/beta/latest.json"
|
||
echo "稳定清单 updates/latest.json 不会受影响;老客户端仍只读稳定清单。"
|
||
echo ""
|
||
echo "beta 稳定后:走正常 dev -> main 发布 PR,把版本改成 ${BASE} 后运行 script/release-tag.sh v${BASE}。"
|