Files
Genarrative/scripts/build-production-release.sh
kdletters cf9fb5ac40
Some checks failed
CI / verify (push) Has been cancelled
Add bootstrap secret flow to production Stdb builds
2026-05-08 22:58:09 +08:00

496 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
用法:
npm run build:production-release -- --name <version>
说明:
1. 生成生产发布包 build/<version>/。
2. 发布包只包含 systemd + Nginx 生产部署需要的文件,不再生成旧 start.sh / stop.sh / web-server.mjs。
3. 默认会构建主站、后台、api-server 与 SpacetimeDB wasm调试时可用 skip 参数复用已存在产物。
参数:
--name <version> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
--component <name> 构建组件: all, web, api-server, spacetime-module默认 all
--skip-web-build 跳过主站与后台构建,仅复制已有 dist 产物
--skip-api-build 跳过 api-server 构建,仅复制已有 release 二进制
--skip-spacetime-build 跳过 spacetime-module 构建,仅复制已有 wasm
--no-migration-bootstrap-secret
构建不带迁移引导密钥的 spacetime-module wasm
EOF
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[production-release] 缺少命令: ${command_name}" >&2
exit 1
fi
}
copy_required_file() {
local source_path="$1"
local target_path="$2"
local label="$3"
if [[ ! -f "${source_path}" ]]; then
echo "[production-release] 缺少 ${label}: ${source_path}" >&2
exit 1
fi
cp "${source_path}" "${target_path}"
}
copy_required_dir() {
local source_path="$1"
local target_path="$2"
local label="$3"
if [[ ! -d "${source_path}" ]]; then
echo "[production-release] 缺少 ${label}: ${source_path}" >&2
exit 1
fi
mkdir -p "$(dirname "${target_path}")"
rm -rf "${target_path}"
cp -R "${source_path}" "${target_path}"
}
write_sha256_file() {
local file_path="$1"
local checksum_path="${file_path}.sha256"
if [[ ! -f "${file_path}" ]]; then
echo "[production-release] 无法生成 checksum文件不存在: ${file_path}" >&2
exit 1
fi
(
cd "$(dirname "${file_path}")"
sha256sum "$(basename "${file_path}")"
) >"${checksum_path}"
}
generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
}
prepare_migration_bootstrap_secret() {
local secret_source="generated"
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
return
fi
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
echo "[production-release] 未启用迁移引导密钥。"
return
fi
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET:-}" ]]; then
MIGRATION_BOOTSTRAP_SECRET="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET}"
secret_source="environment"
else
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
fi
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
echo "[production-release] 迁移引导密钥至少需要 16 个字符。" >&2
exit 1
fi
echo "[production-release] 已准备迁移引导密钥: source=${secret_source}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
}
write_migration_bootstrap_secret_file() {
local target_path="${TARGET_DIR}/migration-bootstrap-secret.txt"
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
return
fi
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
return
fi
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET}" ]]; then
echo "[production-release] 迁移引导密钥为空,无法写入发布产物。" >&2
exit 1
fi
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${target_path}"
chmod 600 "${target_path}" 2>/dev/null || true
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=1
echo "[production-release] 已写入迁移引导密钥文件: ${target_path}"
}
write_release_manifest() {
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
RELEASE_VERSION="${BUILD_NAME}" \
RELEASE_SOURCE_BRANCH="${RELEASE_SOURCE_BRANCH}" \
RELEASE_SOURCE_COMMIT="${RELEASE_SOURCE_COMMIT}" \
RELEASE_BUILT_AT="${RELEASE_BUILT_AT}" \
RELEASE_COMPONENT="${COMPONENT}" \
RELEASE_INCLUDE_WEB="${BUILD_WEB}" \
RELEASE_INCLUDE_API="${BUILD_API}" \
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
node <<'NODE'
const fs = require('fs');
const artifacts = [];
if (process.env.RELEASE_INCLUDE_WEB === '1') {
artifacts.push({
component: 'web',
path: 'web.tar.gz',
checksum_path: 'web.tar.gz.sha256',
});
}
if (process.env.RELEASE_INCLUDE_API === '1') {
artifacts.push({
component: 'api-server',
path: 'api-server',
checksum_path: 'api-server.sha256',
});
}
if (process.env.RELEASE_INCLUDE_SPACETIME === '1') {
artifacts.push({
component: 'spacetime-module',
path: 'spacetime_module.wasm',
checksum_path: 'spacetime_module.wasm.sha256',
});
}
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
artifacts.push({
component: 'spacetime-module',
path: 'migration-bootstrap-secret.txt',
sensitive: true,
});
}
const manifest = {
version: process.env.RELEASE_VERSION,
source_branch: process.env.RELEASE_SOURCE_BRANCH,
source_commit: process.env.RELEASE_SOURCE_COMMIT,
built_at: process.env.RELEASE_BUILT_AT,
component_type: process.env.RELEASE_COMPONENT,
artifacts,
};
fs.writeFileSync(process.env.RELEASE_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
NODE
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
BUILD_ROOT="${REPO_ROOT}/build"
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
COMPONENT="all"
SKIP_WEB_BUILD=0
SKIP_API_BUILD=0
SKIP_SPACETIME_BUILD=0
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
BUILD_COMPLETED=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--name)
BUILD_NAME="${2:?缺少 --name 的值}"
shift 2
;;
--component)
COMPONENT="${2:?缺少 --component 的值}"
shift 2
;;
--skip-web-build)
SKIP_WEB_BUILD=1
shift
;;
--skip-api-build)
SKIP_API_BUILD=1
shift
;;
--skip-spacetime-build)
SKIP_SPACETIME_BUILD=1
shift
;;
--no-migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
shift
;;
*)
echo "[production-release] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
echo "[production-release] --name 只能包含数字、字母、点、下划线和短横线。" >&2
exit 1
fi
BUILD_WEB=0
BUILD_API=0
BUILD_SPACETIME=0
case "${COMPONENT}" in
all)
BUILD_WEB=1
BUILD_API=1
BUILD_SPACETIME=1
;;
web)
BUILD_WEB=1
;;
api|api-server)
COMPONENT="api-server"
BUILD_API=1
;;
stdb|spacetime|spacetime-module)
COMPONENT="spacetime-module"
BUILD_SPACETIME=1
;;
*)
echo "[production-release] --component 只能是 all, web, api-server 或 spacetime-module当前值: ${COMPONENT}" >&2
exit 1
;;
esac
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
WEB_DIR="${TARGET_DIR}/web"
ADMIN_WEB_DIR="${WEB_DIR}/admin"
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${SERVER_RS_DIR}/target}"
API_BINARY_SOURCE="${CARGO_TARGET_DIR}/x86_64-unknown-linux-gnu/release/api-server"
WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module.wasm"
RELEASE_SOURCE_BRANCH="${SOURCE_BRANCH:-${GIT_BRANCH:-}}"
RELEASE_SOURCE_BRANCH="${RELEASE_SOURCE_BRANCH#origin/}"
RELEASE_SOURCE_COMMIT="${SOURCE_COMMIT:-${GIT_COMMIT:-}}"
RELEASE_BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "${RELEASE_SOURCE_BRANCH}" ]]; then
RELEASE_SOURCE_BRANCH="$(git -C "${REPO_ROOT}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if [[ -z "${RELEASE_SOURCE_BRANCH}" || "${RELEASE_SOURCE_BRANCH}" == "HEAD" ]]; then
RELEASE_SOURCE_BRANCH="unknown"
fi
fi
if [[ -z "${RELEASE_SOURCE_COMMIT}" ]]; then
RELEASE_SOURCE_COMMIT="$(git -C "${REPO_ROOT}" rev-parse HEAD 2>/dev/null || true)"
if [[ -z "${RELEASE_SOURCE_COMMIT}" ]]; then
RELEASE_SOURCE_COMMIT="unknown"
fi
fi
cleanup_partial_build() {
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
echo "[production-release] 清理未完成发布包: ${TARGET_DIR}" >&2
rm -rf "${TARGET_DIR}"
fi
}
trap cleanup_partial_build EXIT
if [[ -e "${TARGET_DIR}" ]]; then
echo "[production-release] 目标目录已存在: ${TARGET_DIR}" >&2
exit 1
fi
require_command node
require_command node
require_command sha256sum
if [[ "${BUILD_API}" -eq 1 && "${SKIP_API_BUILD}" -ne 1 ]]; then
require_command cargo
fi
if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
require_command cargo
fi
if [[ "${BUILD_WEB}" -eq 1 && "${SKIP_WEB_BUILD}" -ne 1 ]]; then
require_command node
require_command npm
fi
if [[ "${BUILD_WEB}" -eq 1 ]]; then
require_command tar
fi
mkdir -p "${TARGET_DIR}"
echo "[production-release] 发布包目录: ${TARGET_DIR}"
echo "[production-release] 构建组件: ${COMPONENT}"
prepare_migration_bootstrap_secret
if [[ "${BUILD_WEB}" -eq 1 ]]; then
mkdir -p "${WEB_DIR}"
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
echo "[production-release] 构建主站静态资源 -> ${WEB_DIR}"
(
cd "${REPO_ROOT}"
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
)
echo "[production-release] 构建后台静态资源 -> ${ADMIN_WEB_DIR}"
(
cd "${REPO_ROOT}"
MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir
)
else
copy_required_dir "${REPO_ROOT}/dist" "${WEB_DIR}" "主站 dist"
copy_required_dir "${REPO_ROOT}/apps/admin-web/dist" "${ADMIN_WEB_DIR}" "后台 dist"
fi
if [[ ! -f "${WEB_DIR}/maintenance.html" ]]; then
cat >"${WEB_DIR}/maintenance.html" <<'MAINTENANCE_HTML'
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>服务维护中</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #101418;
color: #f3f7fb;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
max-width: 28rem;
padding: 2rem;
text-align: center;
}
h1 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
}
p {
margin: 0;
color: #b6c2cf;
line-height: 1.7;
}
</style>
</head>
<body>
<main>
<h1>服务维护中</h1>
<p>我们正在更新系统,稍后请重新访问。</p>
</main>
</body>
</html>
MAINTENANCE_HTML
fi
echo "[production-release] 打包 Web 静态资源 -> ${TARGET_DIR}/web.tar.gz"
tar -czf "${TARGET_DIR}/web.tar.gz" -C "${TARGET_DIR}" web
write_sha256_file "${TARGET_DIR}/web.tar.gz"
fi
if [[ "${BUILD_API}" -eq 1 && "${SKIP_API_BUILD}" -ne 1 ]]; then
echo "[production-release] 构建 api-server -> x86_64-unknown-linux-gnu"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p api-server \
--release \
--target x86_64-unknown-linux-gnu \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
if [[ "${BUILD_API}" -eq 1 ]]; then
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
chmod +x "${TARGET_DIR}/api-server"
write_sha256_file "${TARGET_DIR}/api-server"
fi
if [[ "${BUILD_SPACETIME}" -eq 1 && "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
echo "[production-release] 构建 spacetime-module -> wasm32-unknown-unknown"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p spacetime-module \
--release \
--target wasm32-unknown-unknown \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
write_migration_bootstrap_secret_file
fi
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy"
cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh"
cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh"
cp "${SCRIPT_DIR}/deploy/jenkins-inbound-agent-start.sh" "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh"
cp "${SCRIPT_DIR}/deploy/install-jenkins-inbound-agent.sh" "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
cp "${SCRIPT_DIR}/deploy/jenkins-agent-reverse-tunnel.ps1" "${TARGET_DIR}/scripts/jenkins-agent-reverse-tunnel.ps1"
cp "${SCRIPT_DIR}/deploy/jenkins-local-controller-watchdog.ps1" "${TARGET_DIR}/scripts/jenkins-local-controller-watchdog.ps1"
chmod +x \
"${TARGET_DIR}/scripts/maintenance-on.sh" \
"${TARGET_DIR}/scripts/maintenance-off.sh" \
"${TARGET_DIR}/scripts/maintenance-status.sh" \
"${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" \
"${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh"
copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
copy_required_dir "${REPO_ROOT}/deploy/env" "${TARGET_DIR}/deploy/env" "生产环境示例"
cat >"${TARGET_DIR}/README.md" <<EOF
# Genarrative Production Release
版本:\`${BUILD_NAME}\`
## 内容
- \`web/\`:主站静态资源,\`web/admin/\` 为后台静态资源,\`web/maintenance.html\` 为维护页。
- \`web.tar.gz\` / \`web.tar.gz.sha256\`Web 发布流水线使用的静态资源压缩包与校验文件。
- \`api-server\`:生产 Linux release 可执行文件。
- \`spacetime_module.wasm\`SpacetimeDB 模块 wasm。
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text授权完成后不要长期留在公开归档中。
- \`*.sha256\`:发布产物 checksum用于部署前校验。
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
- \`deploy/\`systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
## 生产部署口径
本发布包不包含旧一体化 \`start.sh\`、\`stop.sh\` 或 \`web-server.mjs\`。
生产环境由 systemd 托管 \`spacetimedb.service\` 与 \`genarrative-api.service\`,由 Nginx 托管 \`web/\`。
EOF
write_release_manifest
BUILD_COMPLETED=1
echo "[production-release] 完成: ${TARGET_DIR}"