Files
host e8e256f6e5 deploy: add git-pull source for host deployment
Host can now fetch code directly from GitHub instead of receiving a VM-built tarball, bypassing the VM->host scp/kex failures and stale-tar risk. Design: VM sends only two small scripts (host_git_fetch.sh + host_deploy.sh, sha-verified via the existing transfer chain, now with a chunk_key fallback that does not need SSH_PASSWORD); host clones via a read-only deploy key (SSH only, HTTPS refused, key must exist with mode 0600), checks out the exact 40-char commit, verifies HEAD, builds a tar in the same format as the VM release (DEPLOYED_COMMIT + RELEASE.json, member names without ./ prefix so host_deploy.sh tar -O works), then calls the unchanged host_deploy.sh for staging swap, backup, health and rollback. Git fetch tolerates the known intermittent host->GitHub degradation: 120s timeout, 3 attempts, 10s sleep, live untouched until host_deploy runs. VM gates: clean tree, tests, commit must be on origin/main, SSH repo only, full sha only; --source tar remains the default. Tests: deploy suite OK, including 6 new host_git_fetch simulations (success with clean tar members, unknown commit, key permissions/missing, HTTPS refusal, short sha) and 4 new deploy.py git-mode helpers plus chunk_key fallback; git-mode dry-run verified against HEAD 630d14f.
2026-10-29 03:37:55 +08:00

519 lines
24 KiB
Python
Raw Permalink 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 python3
"""统一发布入口:取代手工的 deploy.sh + scp_run + chunk_send + grep 验真。
为什么需要它(此前三次栽过的坑,见 deploy/README.md):
1. 通用 tar 路径 /tmp/codex-app-server-code.tar.gz:一次 scp 失败,
旧包还躺在原路径,下一步手工跑主机脚本就把旧代码“部署成功”了。
2. 传输不验 sha:scp 断在半路、chunk 首片写反,主机都可能拿到错包。
3. 主机脚本多份:deploy.sh 内嵌版、deploy_*_host.sh 各自演进,行为漂移。
4. health 通过不等于新代码上线:旧代码的 health 照样 ok=true。
本脚本把以上四点变成硬门禁,任何一步不符都退出非零、绝不打印成功:
- 产物唯一命名:codex-app-server-<commit>.tar.gz + .sha256,
远端路径 /tmp/codex-app-server-releases/<commit>.tar.gz,旧包无法冒充新包。
- 产物来自 git archive(已提交的 HEAD),默认拒绝工作区脏文件;
tar 根目录带 DEPLOYED_COMMIT 清单,主机侧先验清单再动代码。
- 传输链自动 fallback:scp(SSH_PROXY_CMD)-> ssh-helpers/scp_run.py
-> ssh-helpers/chunk_send.py(base64 分片),每种方式传完都先在远端
跑 sha256sum 比对,不符就删掉该次产物再试下一种,绝不把错包交给主机脚本。
- 主机侧只用 deploy/host_deploy.sh 这一份(VM 侧随包一起传过去,
不依赖主机旧代码里的版本),它做 staging 换目录 + 回滚。
- 上线证据双重核对:主机脚本打印的 RELEASE_OK 里的 commit,
再加 VM 侧独立 ssh cat live 的 DEPLOYED_COMMIT,两者都必须等于期望 commit。
用法:
python3 deploy/deploy.py <host> [user] [--ref HEAD] [--dry-run]
python3 deploy/deploy.py <host> --build-only [--out-dir DIR]
python3 deploy/deploy.py <host> --tarball <path> --commit <sha>
python3 deploy/deploy.py <host> --skip-tests # CI 里测试已跑过时用
python3 deploy/deploy.py <host> --source git [--repo <ssh-url>]
# git 源:VM 不建 tar、不传大包,只传两个小脚本(host_git_fetch.sh
# + host_deploy.sh)并让主机用只读 deploy key 直拉 GitHub;
# 主机在 staging 里 checkout 精确 commit、验 HEAD、本地建包,
# 再调 host_deploy.sh 换目录(tar 路径仍是默认,不改默认前
# 必须显式 --source git)。主机凭证不在本脚本里:deploy key
# 私钥只放主机(默认 /root/.ssh/id_codex_github,0600),
# 公钥以只读方式加到 GitHub 仓库 Settings > Deploy keys。
密钥规则:本脚本绝不接受密码参数、不读任何 env 文件、不打印任何密钥。
SSH 密码只由调用方已有的通道处理(key,或 ~/workspace/ssh-helpers 的
pty helper,密码只走 SSH_PASSWORD 环境变量);GitHub Actions 里走 key。
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
from datetime import datetime, timezone
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HOST_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_deploy.sh")
HOST_GIT_FETCH_SCRIPT = os.path.join(REPO_ROOT, "deploy", "host_git_fetch.sh")
DEFAULT_GIT_REPO = "git@github.com:openqj/codex-app-server.git"
DEFAULT_GIT_KEY = "/root/.ssh/id_codex_github"
HELPER_DIR = os.path.expanduser("~/workspace/ssh-helpers")
REMOTE_RELEASES_DIR = "/tmp/codex-app-server-releases"
def run(cmd, cwd=None, check=True):
"""跑命令并返回 stdout;失败时把 stderr 透传后按 check 决定是否退出。"""
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if check and r.returncode != 0:
print("命令失败:%s" % " ".join(cmd), file=sys.stderr)
print((r.stdout + r.stderr)[-2000:], file=sys.stderr)
sys.exit(1)
return r
def git_commit(ref):
return run(["git", "rev-parse", ref], cwd=REPO_ROOT).stdout.strip()
def is_full_sha(value):
"""git 源只接受 40 位完整 sha:短 sha 有歧义,主机 checkout
前后都必须能用它唯一确定一个提交。"""
return bool(re.fullmatch(r"[0-9a-f]{40}", value or ""))
def is_ssh_git_url(url):
"""git 源只接受 SSH 形式:HTTPS 需要 token,不在本模式内;
仓库是私有的,裸 https 拉不到,token 绝不能拼进 URL。"""
return bool(url) and (
url.startswith("git@github.com:") or
url.startswith("ssh://git@github.com/"))
def commit_on_origin_main(commit):
"""commit 是否已在 origin/main 里:主机只能拉到已推的提交,
本地有但未推的 HEAD 用 git 源部署必失败,先在 VM 侧拦住。"""
r = run(["git", "merge-base", "--is-ancestor", commit, "origin/main"],
cwd=REPO_ROOT, check=False)
return r.returncode == 0
def check_clean_tree(allow_dirty):
"""已提交内容与工作区不一致时,默认拒绝;--allow-dirty 只告警,
且部署的仍是 HEAD 的 archive,脏文件不会被偷偷带上去。"""
diff = run(["git", "diff", "--quiet"], cwd=REPO_ROOT, check=False)
cached = run(["git", "diff", "--cached", "--quiet"], cwd=REPO_ROOT, check=False)
if diff.returncode != 0 or cached.returncode != 0:
if not allow_dirty:
print("工作区有未提交改动,先提交再部署(或显式 --allow-dirty,"
"但部署的仍是 HEAD,不含脏文件)。", file=sys.stderr)
sys.exit(2)
print("警告:工作区有脏文件,部署的仍是 HEAD 的 archive,"
"脏文件不会上线。")
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def release_tarball_name(commit):
return "codex-app-server-%s.tar.gz" % commit
def build_release(ref, out_dir):
"""从 git archive 建发布包,返回 (tarball_path, sha256, commit)。"""
commit = git_commit(ref)
os.makedirs(out_dir, exist_ok=True)
tarball = os.path.join(out_dir, release_tarball_name(commit))
with tempfile.TemporaryDirectory() as tmp:
# git archive 输出是二进制 tar 流,直接写文件,不经过文本解码。
with open(os.path.join(tmp, "archive.tar"), "wb") as f:
r = subprocess.run(["git", "archive", ref], cwd=REPO_ROOT, stdout=f)
if r.returncode != 0:
print("git archive 失败:%s" % ref, file=sys.stderr)
sys.exit(1)
with tarfile.open(os.path.join(tmp, "archive.tar")) as tf:
tf.extractall(tmp, filter="data")
with open(os.path.join(tmp, "DEPLOYED_COMMIT"), "w") as f:
f.write(commit + "\n")
with open(os.path.join(tmp, "RELEASE.json"), "w") as f:
json.dump({
"commit": commit, "ref": ref,
"built_at": datetime.now(timezone.utc).isoformat(),
}, f, indent=2, sort_keys=True)
f.write("\n")
with tarfile.open(tarball, "w:gz", format=tarfile.PAX_FORMAT) as tf:
for root, dirs, files in os.walk(tmp):
dirs.sort()
for name in sorted(files):
full = os.path.join(root, name)
arc = os.path.relpath(full, tmp)
if arc in ("archive.tar",):
continue
tf.add(full, arcname=arc)
sha = sha256_of(tarball)
with open(tarball + ".sha256", "w") as f:
f.write("%s %s\n" % (sha, os.path.basename(tarball)))
return tarball, sha, commit
def tarball_commit(tarball):
with tarfile.open(tarball, "r:gz") as tf:
try:
member = tf.getmember("DEPLOYED_COMMIT")
except KeyError:
print("发布包里没有 DEPLOYED_COMMIT,拒绝部署:%s" % tarball,
file=sys.stderr)
sys.exit(2)
data = tf.extractfile(member).read().decode().strip()
return data
def parse_release_ok(output, expected_commit):
"""主机脚本成功输出里必须有 RELEASE_OK 且 commit 等于期望值。"""
for line in output.splitlines():
if line.startswith("RELEASE_OK"):
fields = dict(
part.split("=", 1) for part in line.split()[1:]
if "=" in part)
return fields.get("commit") == expected_commit
return False
# ---------------- 传输:ssh / scp 抽象 ----------------
def helper_path(name):
path = os.path.join(HELPER_DIR, name)
return path if os.path.isfile(path) else None
def _deploy_key():
"""无密码时的部署密钥路径:显式环境变量优先,其次两处惯常
位置。/root/.ssh 的那份会在 VM 回收时被清空,~/.ssh 的那份
持久;两处都没有时返回 None(此时靠 ssh 配置或 agent,不
硬编造路径)。这里只返回路径,不读密钥内容。"""
env_key = os.environ.get("CODEX_DEPLOY_KEY")
if env_key:
return env_key
for cand in ("/root/.ssh/id_codex_deploy",
os.path.expanduser("~/.ssh/id_codex_deploy")):
if os.path.isfile(cand):
return cand
return None
def _proxy_cmd():
"""无密码时的 ProxyCommand:显式环境变量优先,其次 helper
目录的 proxy_tunnel.py(存在才用);都没有时返回 None(直
连,不把不存在的脚本写进命令)。"""
proxy = os.environ.get("SSH_PROXY_CMD")
if proxy:
return proxy
default_proxy = os.path.join(HELPER_DIR, "proxy_tunnel.py")
if os.path.isfile(default_proxy):
return "python3 %s %%h %%p" % default_proxy
return None
def ssh_cmd(host, user, remote_cmd):
"""返回一条 ssh 命令。有 SSH_PASSWORD 时优先用已验证稳定的
helper(pty 喂密码);无密码时走密钥直连:ProxyCommand 按
_proxy_cmd(),密钥按 _deploy_key() 显式传 -i(不能只依赖
/root/.ssh/config 的 Host 条目,那个目录会在 VM 回收时被
清空)。绝不把密码拼进命令。"""
helper = helper_path("ssh_run.py")
if helper and os.environ.get("SSH_PASSWORD"):
return ["python3", helper, "ssh", host, user, remote_cmd]
base = ["ssh", "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null"]
proxy = _proxy_cmd()
if proxy:
base += ["-o", "ProxyCommand %s" % proxy]
key = _deploy_key()
if key:
base += ["-i", key]
return base + ["%s@%s" % (user, host), remote_cmd]
def scp_cmd(host, user, local, remote):
helper = helper_path("scp_run.py")
if helper and os.environ.get("SSH_PASSWORD"):
# helper 自带 egress proxy + 密码 pty,是此前实测最稳的一路。
return ["python3", helper, "scp", host, user, local, remote]
base = ["scp", "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null"]
proxy = _proxy_cmd()
if proxy:
base += ["-o", "ProxyCommand %s" % proxy]
key = _deploy_key()
if key:
base += ["-i", key]
return base + [local, "%s@%s:%s" % (user, host, remote)]
def remote_sha(host, user, remote_path):
"""远端 sha256sum 的输出里取 64 位 hex。不能取 split()[0]:经 pty
的 ssh helper 会把 stderr 的 Warning(例如 Permanently added to
the list of known hosts)合并进 stdout 放在 sha 行前面,首 token
于是变成 Warning:,导致传输明明成功却被判验真失败(2026-10-27
新链路首推实测)。"""
r = subprocess.run(ssh_cmd(host, user, "sha256sum %s" % remote_path),
capture_output=True, text=True)
if r.returncode != 0:
return None
m = re.search(r"\b[0-9a-f]{64}\b", r.stdout)
return m.group(0) if m else None
def live_commit(text):
"""从 ssh 输出里取 40 位 commit。理由同 remote_sha:helper 的
Warning 行会混进 stdout,直接 strip() 比对必失败。"""
m = re.search(r"\b[0-9a-f]{40}\b", text)
return m.group(0) if m else None
def remote_rm(host, user, remote_path):
subprocess.run(ssh_cmd(host, user, "rm -f %s" % remote_path),
capture_output=True, text=True)
def transfer(host, user, local, remote, expected_sha):
"""传输链:scp -> chunk_key 分片(密钥版)-> chunk 分片(密码版)。
每路传完都验远端 sha,不符删掉重来;全部失败才退出,
绝不把错包交给主机脚本。chunk_key 一路不依赖 SSH_PASSWORD:
它用部署密钥走显式 ProxyCommand,是 VM 侧实测最稳的小文件
传输路(git 源只传两个小脚本时尤其重要:大 tar 的 scp 在
kex 阶段被 egress 代理 reset 的概率远高于小脚本,但小脚本
也不能只押 scp 一路)。"""
attempts = []
# 1. scp(含 helper 路由,由 scp_cmd 内部决定)
attempts.append(("scp", scp_cmd(host, user, local, remote)))
chunk_key = helper_path("chunk_send_key_explicit.py")
if chunk_key:
attempts.append(("chunk_key", None)) # 特殊处理,见下
chunk = helper_path("chunk_send.py")
if chunk and os.environ.get("SSH_PASSWORD"):
attempts.append(("chunk", None)) # 特殊处理,见下
remote_b64 = remote + ".b64"
for name, cmd in attempts:
print("传输尝试:%s -> %s" % (name, remote))
if name in ("chunk", "chunk_key"):
helper = chunk_key if name == "chunk_key" else chunk
r = subprocess.run(
["python3", helper, local, remote_b64, host, user],
capture_output=True, text=True)
if r.returncode != 0:
print("%s 传输失败,换下一路。" % name, file=sys.stderr)
continue
r = subprocess.run(
ssh_cmd(host, user,
"base64 -d %s > %s && sha256sum %s"
% (remote_b64, remote, remote)),
capture_output=True, text=True)
if r.returncode != 0:
print("%s 解码失败,换下一路。" % name, file=sys.stderr)
continue
else:
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print("scp 失败(%s),换下一路。" % (r.stderr or "").strip()[-200:],
file=sys.stderr)
continue
actual = remote_sha(host, user, remote)
if actual == expected_sha:
print("传输验真通过:%s sha=%s" % (name, actual))
return
print("远端 sha 不符(expected=%s actual=%s),删掉该次产物,换下一路。"
% (expected_sha, actual), file=sys.stderr)
remote_rm(host, user, remote)
print("所有传输方式都失败或验真不通过,部署未开始(旧代码不动)。",
file=sys.stderr)
sys.exit(1)
# 部署前默认跑的测试清单:必须用 discover -s <目录>,
# 绝不能用 deploy.tests 这种包路径——包路径会把 deploy 当包
# import、盖掉 deploy.py 本体(2026-10-27 教训),导致
# 13 个假报错。P2-ops:deploy/tests 以前漏在清单外,
# deploy.py 自身的回归(如 ssh 分发)可能带病上线,
# 现补进默认清单。
DEFAULT_TEST_SUITES = ("tests", "cloud/tests", "deploy/tests")
def run_tests():
for suite in DEFAULT_TEST_SUITES:
run(["python3", "-m", "unittest", "discover", "-s", suite],
cwd=REPO_ROOT)
def deploy_via_git(args):
"""git 源部署:VM 只传两个小脚本,主机直拉 GitHub。
门禁顺序与 tar 路径同款强度,只是取包方换了主人:
脏工作区、测试、commit 未推到 origin/main、repo 非 SSH、
commit 非完整 sha,任一不符都拒绝,主机绝不动。
主机侧 host_git_fetch.sh 负责重试、checkout 校验、本地建包,
再调 host_deploy.sh 换目录;换目录后的 RELEASE_OK 解析与
live 二次核对与 tar 路径完全一致。"""
if args.tarball:
print("--tarball 与 --source git 冲突:git 源不传包,请去掉 --tarball。",
file=sys.stderr)
sys.exit(2)
if args.build_only:
print("--source git 不在 VM 建包,--build-only 不适用。",
file=sys.stderr)
sys.exit(2)
if not args.host and not args.dry_run:
print("git 源需要 host,或用 --dry-run。", file=sys.stderr)
sys.exit(2)
if not is_ssh_git_url(args.repo):
print("git 源只接受 SSH 仓库(deploy key),HTTPS 需要 token:%s"
% args.repo, file=sys.stderr)
sys.exit(2)
check_clean_tree(args.allow_dirty)
if not args.skip_tests:
run_tests()
commit = git_commit(args.ref)
if not is_full_sha(commit):
print("git 源需要 40 位完整 commit,解析结果不符:%s" % commit,
file=sys.stderr)
sys.exit(2)
if not commit_on_origin_main(commit):
print("commit 未在 origin/main 里,主机拉不到:先推分支/合 PR 再推。",
file=sys.stderr)
print(" commit=%s" % commit, file=sys.stderr)
sys.exit(2)
print("git 源:repo=%s commit=%s git_key=%s(只传路径)"
% (args.repo, commit, args.git_key))
if args.dry_run:
print("[dry-run] 将传 host_git_fetch.sh 与 host_deploy.sh 到 "
"%s@%s:%s(唯一路径,传完验 sha)" % (args.user, args.host,
REMOTE_RELEASES_DIR))
print("[dry-run] 将执行:sh host_git_fetch.sh <repo> <commit> "
"<git_key> <host_deploy.sh>,主机直拉、校验、本地建包、"
"换目录,成功须打印 RELEASE_OK 且 commit 一致,否则回滚。")
return 0
host, user = args.host, args.user
remote_git_fetch = "%s/host_git_fetch-%s.sh" % (REMOTE_RELEASES_DIR, commit)
remote_host_deploy = "%s/host_deploy-%s.sh" % (REMOTE_RELEASES_DIR, commit)
run(ssh_cmd(host, user, "mkdir -p %s" % REMOTE_RELEASES_DIR), check=False)
transfer(host, user, HOST_GIT_FETCH_SCRIPT, remote_git_fetch,
sha256_of(HOST_GIT_FETCH_SCRIPT))
transfer(host, user, HOST_SCRIPT, remote_host_deploy,
sha256_of(HOST_SCRIPT))
print("执行主机 git 取包 + 部署脚本(staging 取包 + 换目录 + 失败回滚)…")
r = subprocess.run(
ssh_cmd(host, user,
"sh %s %s %s %s %s" % (remote_git_fetch, args.repo, commit,
args.git_key, remote_host_deploy)),
capture_output=True, text=True)
print(r.stdout)
if r.stderr:
print(r.stderr, file=sys.stderr)
if r.returncode != 0 or not parse_release_ok(r.stdout, commit):
print("主机 git 部署未返回有效的 RELEASE_OK,部署失败(主机侧已回滚或未动)。",
file=sys.stderr)
sys.exit(1)
live = subprocess.run(
ssh_cmd(host, user, "cat /opt/codex-app-server/DEPLOYED_COMMIT"),
capture_output=True, text=True)
if live_commit(live.stdout) != commit:
print("二次核对失败:live DEPLOYED_COMMIT=%s,期望 %s。"
"请立即用 deploy/rollback.sh 检查,不要再推新版。"
% (live.stdout.strip(), commit), file=sys.stderr)
sys.exit(1)
print("部署成功且二次核对通过:source=git commit=%s repo=%s"
% (commit, args.repo))
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description="codex-app-server 统一发布入口")
parser.add_argument("host", nargs="?", help="部署目标主机")
parser.add_argument("user", nargs="?", default="root", help="SSH 用户")
parser.add_argument("--ref", default="HEAD")
parser.add_argument("--tarball", help="直接部署已有发布包(CI 产物)")
parser.add_argument("--commit", help="配合 --tarball 指定期望 commit")
parser.add_argument("--source", choices=("tar", "git"), default="tar",
help="取包方式:tar(默认,VM 建包传包)或 git"
"(主机用只读 deploy key 直拉 GitHub,见本文件头注释)")
parser.add_argument("--repo", default=DEFAULT_GIT_REPO,
help="git 源仓库(只接受 SSH 形式,默认 openqj/codex-app-server)")
parser.add_argument("--git-key", default=DEFAULT_GIT_KEY,
help="git 源主机 deploy key 私钥路径(只传路径,不传内容)")
parser.add_argument("--out-dir", default=os.path.join(
REPO_ROOT, "work", "external-install", "releases"))
parser.add_argument("--allow-dirty", action="store_true")
parser.add_argument("--skip-tests", action="store_true")
parser.add_argument("--build-only", action="store_true")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
if args.source == "git":
return deploy_via_git(args)
if args.tarball:
commit = args.commit or tarball_commit(args.tarball)
tarball, sha = args.tarball, sha256_of(args.tarball)
if tarball_commit(tarball) != commit:
print("发布包内 DEPLOYED_COMMIT 与 --commit 不符,拒绝部署。",
file=sys.stderr)
sys.exit(2)
else:
if not args.build_only and not args.dry_run and not args.host:
parser.error("需要 host,或用 --build-only / --dry-run")
check_clean_tree(args.allow_dirty)
if not args.skip_tests:
run_tests()
tarball, sha, commit = build_release(args.ref, args.out_dir)
print("发布包:%s" % tarball)
print("commit=%s sha256=%s" % (commit, sha))
if args.build_only:
print("仅构建,不传输、不部署。")
return 0
if args.dry_run:
print("[dry-run] 将传输 %s 到 %s@%s:%s/%s(唯一路径,传完验 sha)"
% (tarball, args.user, args.host, REMOTE_RELEASES_DIR,
os.path.basename(tarball)))
print("[dry-run] 将传主机脚本 %s 并执行:"
"sh host_deploy.sh <tarball> <sha> <commit>,"
"成功须打印 RELEASE_OK 且 commit 一致,否则回滚。" % HOST_SCRIPT)
return 0
host, user = args.host, args.user
remote_tar = "%s/%s" % (REMOTE_RELEASES_DIR, os.path.basename(tarball))
remote_script = "%s/host_deploy-%s.sh" % (REMOTE_RELEASES_DIR, commit)
run(ssh_cmd(host, user, "mkdir -p %s" % REMOTE_RELEASES_DIR), check=False)
transfer(host, user, tarball, remote_tar, sha)
transfer(host, user, HOST_SCRIPT, remote_script, sha256_of(HOST_SCRIPT))
print("执行主机部署脚本(唯一脚本,staging 换目录 + 失败回滚)…")
r = subprocess.run(
ssh_cmd(host, user,
"sh %s %s %s %s" % (remote_script, remote_tar, sha, commit)),
capture_output=True, text=True)
print(r.stdout)
if r.stderr:
print(r.stderr, file=sys.stderr)
if r.returncode != 0 or not parse_release_ok(r.stdout, commit):
print("主机部署未返回有效的 RELEASE_OK,部署失败(主机侧已回滚或未动)。",
file=sys.stderr)
sys.exit(1)
# 独立二次核对:不只信主机脚本的打印,再 ssh 读一次 live 清单。
live = subprocess.run(
ssh_cmd(host, user, "cat /opt/codex-app-server/DEPLOYED_COMMIT"),
capture_output=True, text=True)
if live_commit(live.stdout) != commit:
print("二次核对失败:live DEPLOYED_COMMIT=%s,期望 %s。"
"请立即用 deploy/rollback.sh 检查,不要再推新版。"
% (live.stdout.strip(), commit), file=sys.stderr)
sys.exit(1)
print("部署成功且二次核对通过:commit=%s sha256=%s" % (commit, sha))
return 0
if __name__ == "__main__":
sys.exit(main())