#!/bin/sh # 主机侧 git 取包脚本:deploy.py --source git 时由 VM 传过来执行。 # # 为什么需要它(而不是在 live 里 git pull): # live 是 tar 解包树、没有 .git,运行中服务 + 部分 checkout + # 脏文件会直接污染生产;正确形态永远是 staging 取包、校验、 # 再交给已有的 host_deploy.sh 做换目录 + 备份 + 健康 + 回滚。 # 本脚本只做取包与校验,换目录逻辑一律复用 host_deploy.sh, # 不复制、不漂移(host_deploy.sh 本身不改,tar 路径不受影响)。 # # 调用(VM 侧 deploy.py 传过来,绝不由人工在主机上拼路径): # sh host_git_fetch.sh [key_path] [host_deploy_script] # repo:只接受 SSH 形式,例如 git@github.com:openqj/codex-app-server.git; # HTTPS 需要 token,不在本模式内,直接拒绝(不要把 token # 拼进 URL 或写进本脚本)。 # commit:40 位完整 sha,短 sha 拒绝(防歧义);必须是已推到 # GitHub 的提交,否则主机拉不到——VM 侧 deploy.py 在 # 传脚本前先核 commit 在 origin/main 里,主机侧再核 # checkout 后的 HEAD,任何一步不符都退出、live 不动。 # key_path:主机上的 GitHub 只读 deploy key 私钥路径,默认 # /root/.ssh/id_codex_github;权限必须是 0600,否则 # ssh 会直接忽略该密钥,脚本先验权限再动手。 # host_deploy_script:已传到主机的 host_deploy.sh 路径;本脚本 # 用取到的代码在本地建一个与 VM 发布包同格式的 tar # (根目录含 DEPLOYED_COMMIT 与 RELEASE.json,成员名 # 不带 ./ 前缀,否则 host_deploy.sh 的 tar -O # DEPLOYED_COMMIT 找不到成员),算 sha 后调它完成换目录。 # # 间歇劣化容忍(2026-10-26、2026-10-29 实测:主机到 GitHub 的 # 通路间歇劣化,15 秒超时后 2.8 秒又通,不是永久封禁): # 每路取包用 timeout 限时(默认 120 秒),失败重试(默认 3 次, # 间隔 10 秒);重试耗尽才退出,live 在此之前绝不动。 # 取包失败的唯一后果是本次部署失败,旧代码原地不动;此时 # 可改走 tar 路径(deploy.py 默认 --source tar)再推一次。 # # 可覆盖的环境变量(仅用于本地仿真测试,生产用默认值): # CODEX_GIT_WORKDIR:取包工作区根,默认 /tmp/codex-app-server-git # CODEX_GIT_TIMEOUT:单次 clone 超时秒数,默认 120 # CODEX_GIT_ATTEMPTS:取包重试次数,默认 3 # CODEX_GIT_SLEEP:重试间隔秒数,默认 10 # 密钥:本脚本只读密钥文件路径,不读、不打印密钥内容;repo 与 # commit 不是密钥,可以出现在日志里。 set -eu REPO=${1:-} COMMIT=${2:-} KEY_PATH=${3:-/root/.ssh/id_codex_github} HOST_DEPLOY=${4:-} if [ -z "$REPO" ] || [ -z "$COMMIT" ] || [ -z "$HOST_DEPLOY" ]; then echo "用法:sh host_git_fetch.sh [key_path] [host_deploy_script]" >&2 exit 2 fi # --- 门禁:repo 只接受 SSH 形式,commit 只接受 40 位完整 sha -------- # 例外(仅仿真测试):CODEX_GIT_ALLOW_LOCAL=1 且 repo 是本地目录时, # 允许本地克隆代替 GitHub——deploy.py 从不设这个变量,生产永远 # 走 SSH 门禁;测试用它代替联网,避免单测依赖 GitHub 与 deploy key。 if [ "${CODEX_GIT_ALLOW_LOCAL:-0}" = "1" ] && [ -d "$REPO" ]; then : else case "$REPO" in git@github.com:*|ssh://git@github.com/*) ;; *) echo "git 源只接受 SSH 形式(deploy key),HTTPS 需要 token,不在本模式内:" >&2 echo " repo=$REPO" >&2 exit 2 ;; esac fi case "$COMMIT" in *[!0-9a-f]*|?????????????????????????????????????????*) echo "commit 必须是 40 位完整 sha,短 sha 或非法字符拒绝部署:" >&2 echo " commit=$COMMIT" >&2 exit 2 ;; esac # 上面的 case 只能排除非法字符与超长,长度不足 40 位时再补一刀。 if [ "${#COMMIT}" -ne 40 ]; then echo "commit 必须是 40 位完整 sha,长度不符拒绝部署:$COMMIT" >&2 exit 2 fi # --- 门禁:deploy key 必须存在且权限 0600 ------------------------------ if [ ! -f "$KEY_PATH" ]; then echo "GitHub deploy key 不存在,拒绝部署(旧代码不动):" >&2 echo " key=$KEY_PATH(主机上生成密钥对后,把公钥以只读方式加到" >&2 echo " GitHub 仓库 Settings > Deploy keys,私钥权限 chmod 600)" >&2 exit 2 fi KEY_MODE=$(stat -c %a "$KEY_PATH" 2>/dev/null || stat -f %Lp "$KEY_PATH") if [ "$KEY_MODE" != "600" ]; then echo "GitHub deploy key 权限必须是 0600,否则 ssh 会忽略它,拒绝部署:" >&2 echo " key=$KEY_PATH mode=$KEY_MODE(chmod 600 $KEY_PATH 后重推)" >&2 exit 2 fi if [ ! -f "$HOST_DEPLOY" ]; then echo "host_deploy.sh 不存在,拒绝部署(旧代码不动):$HOST_DEPLOY" >&2 exit 2 fi WORKDIR=${CODEX_GIT_WORKDIR:-/tmp/codex-app-server-git} GIT_TIMEOUT=${CODEX_GIT_TIMEOUT:-120} GIT_ATTEMPTS=${CODEX_GIT_ATTEMPTS:-3} GIT_SLEEP=${CODEX_GIT_SLEEP:-10} GIT_DIR="$WORKDIR/repo" TARBALL="$WORKDIR/codex-app-server-$COMMIT.tar.gz" mkdir -p "$WORKDIR" # --- 取包:clone + checkout + HEAD 校验,重试耗尽才退出 -------------- attempt=1 FETCHED=0 while [ "$attempt" -le "$GIT_ATTEMPTS" ]; do echo "git 取包尝试 $attempt/$GIT_ATTEMPTS:repo=$REPO commit=$COMMIT" rm -rf "$GIT_DIR" if timeout "$GIT_TIMEOUT" env \ GIT_SSH_COMMAND="ssh -i $KEY_PATH -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30" \ git clone --quiet "$REPO" "$GIT_DIR" \ && git -C "$GIT_DIR" checkout --quiet "$COMMIT" \ && [ "$(git -C "$GIT_DIR" rev-parse HEAD)" = "$COMMIT" ]; then FETCHED=1 break fi echo "git 取包尝试 $attempt 失败,$GIT_SLEEP 秒后重试(live 未动)" >&2 rm -rf "$GIT_DIR" attempt=$((attempt + 1)) if [ "$attempt" -le "$GIT_ATTEMPTS" ]; then sleep "$GIT_SLEEP" fi done if [ "$FETCHED" != "1" ]; then echo "git 取包重试耗尽,拒绝部署(旧代码不动);可改走 tar 路径再推一次。" >&2 exit 1 fi # --- 本地建包:与 VM 发布包同格式,交给 host_deploy.sh 换目录 -------- printf '%s\n' "$COMMIT" > "$GIT_DIR/DEPLOYED_COMMIT" # RELEASE.json 用 python3 生成(主机已有 python3,host_deploy 的 # health 检查同样依赖它);日期用 UTC,避免时区歧义。 COMMIT="$COMMIT" REPO="$REPO" python3 -c ' import json, os from datetime import datetime, timezone print(json.dumps({ "commit": os.environ["COMMIT"], "repo": os.environ["REPO"], "source": "git", "built_at": datetime.now(timezone.utc).isoformat(), }, indent=2, sort_keys=True)) ' > "$GIT_DIR/RELEASE.json" # 成员名必须不带 ./ 前缀(见文件头注释),且绝不能把 .git 打进包。 tar --exclude=.git --transform 's,^\./,,' \ -czf "$TARBALL" -C "$GIT_DIR" . TARBALL_SHA=$(sha256sum "$TARBALL" | awk '{print $1}') echo "本地建包完成:tarball=$TARBALL sha=$TARBALL_SHA" # 取包目录不再需要,包与 host_deploy 的 staging 才是上线输入。 rm -rf "$GIT_DIR" # host_deploy.sh 负责换目录、备份、健康、回滚,并打印 RELEASE_OK; # 它的退出码与输出原样透传,deploy.py 靠 RELEASE_OK + 二次核对验收。 exec sh "$HOST_DEPLOY" "$TARBALL" "$TARBALL_SHA" "$COMMIT"