#!/usr/bin/env bash # 在 mini-desktop 的全新目录准备一个 commit-pinned staging RC。 # 本脚本只克隆、构建和留证,不切换 active、不停止或启动任何服务。 set -euo pipefail RC_COMMIT="${RC_COMMIT:-}" RC_HOST="${RC_HOST:-root@100.64.0.7}" RC_ROOT="${RC_ROOT:-/root/game-staging/releases}" RC_REPO_URL="${RC_REPO_URL:-$(git remote get-url origin 2>/dev/null || true)}" if [[ ! "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then printf 'RC_COMMIT 必须是完整 40 位小写提交哈希。\n' >&2 exit 2 fi if [ -z "$RC_REPO_URL" ]; then printf '无法解析仓库地址,请显式设置 RC_REPO_URL。\n' >&2 exit 2 fi printf '准备 staging RC:host=%s commit=%s\n' "$RC_HOST" "$RC_COMMIT" printf '注意:只构建候选,不切流、不起服。\n' # 参数经 bash 位置参数传入;远端脚本使用单引号 heredoc,避免本机提前展开。 ssh -o ProxyCommand=none "$RC_HOST" bash -s -- "$RC_COMMIT" "$RC_ROOT" "$RC_REPO_URL" <<'REMOTE_SCRIPT' set -euo pipefail commit="$1" root="$2" repo_url="$3" release="$root/$commit" evidence="$release/evidence" status_file="$release/RC_STATUS" if [ -e "$release" ]; then printf '拒绝复用既有 RC 目录:%s\n' "$release" >&2 exit 3 fi mkdir -p "$root" umask 077 git clone --no-checkout "$repo_url" "$release" printf 'preparing\n' >"$status_file" on_error() { code=$? printf 'failed exit=%s at=%s\n' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$status_file" printf 'RC 准备失败,现场保留用于排障:%s\n' "$release" >&2 exit "$code" } trap on_error ERR cd "$release" git fetch --no-tags origin "$commit" git checkout --detach "$commit" actual="$(git rev-parse HEAD)" [ "$actual" = "$commit" ] || { printf '提交不一致:expected=%s actual=%s\n' "$commit" "$actual" >&2; exit 4; } [ -z "$(git status --porcelain --untracked-files=no)" ] || { printf '新检出存在 tracked 脏改动,拒绝构建。\n' >&2; exit 4; } mkdir -p "$evidence" previous="none" if [ -L "$root/active" ]; then previous="$(readlink -f "$root/active" || true)" [ -n "$previous" ] || previous="broken-active-symlink" elif [ -L "$root/current" ]; then # 兼容 active 指针启用前的旧部署;新激活脚本只维护 canonical active。 previous="$(readlink -f "$root/current" || true)" [ -n "$previous" ] || previous="broken-current-symlink" fi printf '%s\n' "$previous" >"$evidence/rollback-target.txt" # 配置只记录来源、文件哈希和键名,绝不复制或输出密钥值。 { printf 'captured_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'rc_commit=%s\n' "$commit" printf 'previous_release=%s\n' "$previous" if [ -f /root/huijing-dev.env ]; then printf 'huijing_dev_env_sha256=%s\n' "$(sha256sum /root/huijing-dev.env | awk '{print $1}')" printf 'huijing_dev_env_keys=' awk -F= '/^[A-Za-z_][A-Za-z0-9_]*=/{print $1}' /root/huijing-dev.env | sort | paste -sd, - else printf 'huijing_dev_env=missing\n' fi printf 'backend_staging_yaml_sha256=%s\n' "$(sha256sum game-cloud/huijing-server/src/main/resources/application-staging.yaml | awk '{print $1}')" printf 'studio_staging_env_sha256=%s\n' "$(sha256sum game-studio/.env.staging | awk '{print $1}')" printf 'admin_stage_env_sha256=%s\n' "$(sha256sum game-admin/.env.stage | awk '{print $1}')" } >"$evidence/config-snapshot.txt" printf '[1/7] 构建 Java 后端(构建阶段跳过全量测试,下一步单独执行安全关键集)\n' (cd game-cloud && mvn clean install -pl huijing-server -am -DskipTests) printf '[2/7] 运行安全关键 Java 测试\n' security_java_tests=( DogfoodStartupValidatorTest DogfoodSecurityStartupValidatorTest DogfoodRbacSeedTest TokenAuthenticationFilterTest AuthenticationEntryPointImplTest PassportServiceImplTest ProjectServiceImplTest RuntimePackageServiceImplTest RuntimePackageApiImplDogfoodTest AdminRuntimeControllerDogfoodTest AppFeedControllerDogfoodTest AdControllerDogfoodTest TradeControllerDogfoodTest AdminNewapiQuotaControllerTest NewapiQuotaClaimConsumerTest NewapiQuotaServiceImplTest ) security_test_selector="$(IFS=,; printf '%s' "${security_java_tests[*]}")" ( cd game-cloud mvn test -pl huijing-server -am \ -Dtest="$security_test_selector" -Dsurefire.failIfNoSpecifiedTests=false ) >"$evidence/security-java-tests.log" 2>&1 # 不能只相信 Maven 退出码:逐个核对 Surefire XML,防止选择器漂移后零测试仍签发 RC。 verify_security_java_reports() { python3 - "$evidence/security-java-tests-summary.tsv" "${security_java_tests[@]}" <<'PY' import pathlib,sys,xml.etree.ElementTree as ET output=pathlib.Path(sys.argv[1]) expected=sys.argv[2:] rows={} for report in pathlib.Path("game-cloud").glob("**/target/surefire-reports/TEST-*.xml"): root=ET.parse(report).getroot() name=root.attrib.get("name","").rsplit(".",1)[-1] if name not in expected: continue tests=int(root.attrib.get("tests","0")) failures=int(root.attrib.get("failures","0")) errors=int(root.attrib.get("errors","0")) skipped=int(root.attrib.get("skipped","0")) if name in rows: raise SystemExit(f"安全测试报告重复: {name}") rows[name]=(tests,failures,errors,skipped) missing=[name for name in expected if name not in rows] if missing: raise SystemExit("安全测试报告缺失: "+",".join(missing)) for name,(tests,failures,errors,skipped) in rows.items(): if tests < 1 or failures or errors: raise SystemExit(f"安全测试未通过: {name} tests={tests} failures={failures} errors={errors}") with output.open("w",encoding="utf-8") as handle: handle.write("test_class\ttests\tfailures\terrors\tskipped\n") for name in expected: handle.write(name+"\t"+"\t".join(map(str,rows[name]))+"\n") PY chmod 600 "$evidence/security-java-tests-summary.tsv" } verify_security_java_reports printf '[3/7] 构建 game-studio staging\n' (cd game-studio && npm ci && npm run build -- --mode staging) printf '[4/7] 构建 game-admin stage\n' corepack prepare pnpm@9.15.4 --activate (cd game-admin && pnpm install --frozen-lockfile && pnpm build:stage) # 前端制品可被所有内测用户读取,任何默认口令、模拟令牌或预置密钥标识都必须拒绝签发。 for build_dir in game-studio/dist game-admin/dist-stage; do if rg -n -i 'admin123|mock-token|bootstrap-admin\.token|DOGFOOD_PASSWORD_SEED' "$build_dir" \ >"$evidence/frontend-secret-scan.log"; then printf '前端制品包含禁止发布的凭据或模拟令牌:%s\n' "$build_dir" >&2 exit 5 fi done printf 'FRONTEND_SECRET_SCAN_PASS\n' >"$evidence/frontend-secret-scan.log" chmod 600 "$evidence/frontend-secret-scan.log" printf '[5/7] 准备 commit 内独立 Python 虚拟环境\n' UV_BIN="${UV_BIN:-/root/.local/bin/uv}" [ -x "$UV_BIN" ] || { printf '缺少 uv:%s\n' "$UV_BIN" >&2; exit 5; } (cd cheap-worker && "$UV_BIN" venv .venv && "$UV_BIN" pip install --python .venv/bin/python -r requirements.txt) (cd tier2/gen-worker && "$UV_BIN" venv .venv && "$UV_BIN" pip install --python .venv/bin/python -r requirements.txt) printf '[6/7] 运行发布工具回归测试\n' release_tool_tests=( deploy/tests/test-staging-rc.sh deploy/tests/test-provision-dogfood-users.sh deploy/tests/test-provision-dogfood-nacos-transaction.sh deploy/tests/test-import-newapi-pool.sh deploy/tests/test-dogfood-smoke.sh ) ( export PATH="$release/cheap-worker/.venv/bin:$PATH" for test_script in "${release_tool_tests[@]}"; do printf 'RUN %s\n' "$test_script" bash "$test_script" done printf 'RUN game-runtime/tools/test_newapi_pool_provision.py\n' python3 -m unittest game-runtime/tools/test_newapi_pool_provision.py printf 'RUN deploy/tests/test-dogfood-reviewer-security.py\n' python3 -m unittest deploy/tests/test-dogfood-reviewer-security.py printf 'RUN deploy/dogfood-ops/tests/test_dogfood_ops.py\n' python3 -m unittest deploy/dogfood-ops/tests/test_dogfood_ops.py printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=3\n' "${#release_tool_tests[@]}" ) >"$evidence/release-tool-tests.log" 2>&1 printf '[7/7] 记录制品与启动坐标\n' { printf 'backend_jar=%s/game-cloud/huijing-server/target/huijing-server.jar\n' "$release" printf 'studio_dist=%s/game-studio/dist\n' "$release" printf 'admin_dist=%s/game-admin/dist-stage\n' "$release" printf 'cheap_agent_python=%s/cheap-worker/.venv/bin/python\n' "$release" printf 'cheap_agent_entry=%s/cheap-worker/cheap_service_app.py\n' "$release" printf 'cheap_worker_entry=%s/cheap-worker/worker_service.py\n' "$release" printf 'tier2_python=%s/tier2/gen-worker/.venv/bin/python\n' "$release" printf 'tier2_entry=%s/tier2/gen-worker/service/app.py\n' "$release" } >"$evidence/artifact-paths.env" { sha256sum game-cloud/huijing-server/target/huijing-server.jar find game-studio/dist game-admin/dist-stage -type f -print0 | sort -z | xargs -0 sha256sum } >"$evidence/artifacts.sha256" { printf 'schema=staging-rc-prepare-verification/1\n' printf 'rc_commit=%s\n' "$commit" printf 'release_tool_shell_tests=PASS\n' printf 'release_tool_shell_test_count=%s\n' "${#release_tool_tests[@]}" printf 'newapi_pool_python_tests=PASS\n' printf 'dogfood_reviewer_python_tests=PASS\n' printf 'dogfood_ops_python_tests=PASS\n' printf 'backend_build=PASS\n' printf 'backend_security_tests=PASS\n' printf 'backend_security_test_count=%s\n' "${#security_java_tests[@]}" printf 'security_java_tests_sha256=%s\n' "$(sha256sum "$evidence/security-java-tests.log" | awk '{print $1}')" printf 'security_java_tests_summary_sha256=%s\n' "$(sha256sum "$evidence/security-java-tests-summary.tsv" | awk '{print $1}')" printf 'studio_build=PASS\n' printf 'admin_build=PASS\n' printf 'frontend_secret_scan=PASS\n' printf 'frontend_secret_scan_sha256=%s\n' "$(sha256sum "$evidence/frontend-secret-scan.log" | awk '{print $1}')" } >"$evidence/verification-summary.txt" [ -z "$(git status --porcelain --untracked-files=no)" ] || { printf '构建修改了 tracked 文件,拒绝将其标为 ready。\n' >&2; exit 6; } printf 'ready commit=%s built_at=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$status_file" chmod -R go-rwx "$evidence" "$status_file" trap - ERR printf 'RC_PREPARE_PASS release=%s\n' "$release" printf 'rollback_target=%s\n' "$previous" printf '下一步由统一部署窗口读取 evidence/artifact-paths.env,探活通过后再原子切换 active。\n' REMOTE_SCRIPT