Files
codex-app-server/deploy/deploy.py
host f39ed3654e ci3: lint/mypy 第二阶段清理 + 覆盖率 76% -> 78%(门槛不动)
基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上),
基线 origin/main aebc6238;本轨不改 #251 已定语义、不动
workflow 门槛、不删检查、不加 skip。

lint(ruff E,F@120):109 -> 10
- F401 43 -> 0:删未用导入;try/except 双绑定的名字出现
  “打地鼠”(删 except 分支后 try 分支又被标出),两分支
  均已核对正文无引用后清掉;MCP 三模块的 Error 名不删,
  改走 importlib 属性绑定(见下),两路径绑定都保留。
- E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行,
  tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号);
  一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。
- E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server
  3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值
  完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host
  5 项断开 git commit 参数表。
- E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app
  _base64 移到顶部,之前无引用、无重名);剩余 10 项全是
  tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件
  沿用同一既定模式),移上去会破坏单文件直跑,有意保留。
- F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、
  tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖;
  host dispatch_from_branch 新增真实 git 仓库回归测试。
- F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get
  逐行相同,删本地定义,全部调用点解析到同一 import;
  新增 test_server_row_get(同一对象 + 三路行为)。

mypy(生产范围):24 -> 0,零新增 type: ignore
- no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import
  改 importlib.import_module + 属性绑定(cloud 优先、flat
  copy 回落、内层 ImportError 同样回落,语义与原来一致;
  flat-copy 子进程测试证实回落后 __module__ 为顶层名);
  server _row_get 随 F811 消除。
- arg-type 10 -> 0:harness2_tasks shared 改 TypedDict
  逐键精确类型;CodexProbeAdapter 补 no-op validate_tree
  (_adapter_validate 本就跳过缺失方法,行为一致)。
- var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、
  verify-multiuser RESULTS 加精确注解。
- assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明
  Any 再 try import(草稿先验 mypy 通过)。

bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改
(属第 3 项白名单轨范围),只做 triage:
- 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell
  语义(新增 test_host_run_verify 断言管道与 && 真实生效)。
- 1497 notify:模板来自算子配置,三处插值全部 shlex.quote,
  证据为既有 test_notify_failure_quotes_shell_metacharacters
  (; touch 与 $(touch ) 不执行)。
- 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。
- 无新增 High;pip-audit 0 漏洞。

覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87%
(22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%)
- client.py 0% -> 90%(pipe 假子进程 + main 分支)
- run_parallel.py 0% -> 97%、migrate 包装 0% -> 95%
- playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、
  websearch_mcp 51% -> 86%、deploy.py 56% -> 77%
- verify-multiuser 0% -> 27%(仅 check/req helper;main()
  是主机侧真实多用户实弹验证,按门禁不建生产任务、
  不以 mock 冒充,仍为缺口)
- host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行
  的计量口径变化,非代码膨胀)
- 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径;
  缺口主力仍是 server.py / worker.py / host.py 大文件,
  下一阶段须针对性深测,非本轨快赢可及。

验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests
1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0;
bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删,
提交后工作树干净。
2026-11-03 02:48:54 +00:00

518 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 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())