Files
ykxmz/_build/release_all.py
T
xmz7891 bf15a2ee61 静态资源交给 Cloudflare 边缘缓存(下载提速 41 倍)
实测基线:
  ykxmz-setup-1.8.1.exe   只下到 237568/15067199 字节就停(17 KB/s)
  ykxmz-watch-1.8.1.apk   201523 字节 / 66.8 秒 = 3 KB/s
  ykxmz-server-1.8.1.zip  214900 字节 / 3.1 秒  = 67 KB/s

原因:relay 给所有响应都发 Cache-Control: no-store,
Cloudflare 完全不缓存 —— 每次下载/每张图都要穿隧道回 4G 源站。

改动:
  /download/*   no-store -> public, max-age=604800, immutable
                文件名带版本号,同一名字内容永不改变,可以长缓存
  /             -> public, max-age=60(挡住刷新重复回源)
  /favicon.ico  已是 max-age=86400,保持不变
  /shot/*       已是 max-age=86400,保持不变
  /v1/*         保持 no-store(接口必须实时)

顺带修两个自己踩的坑:

1. _send() 无条件先发 no-store,再追加 extra 里的 Cache-Control,
   结果是**两个 Cache-Control 头**,客户端只认第一个 ——
   调用方传的缓存策略被吃掉。改成 extra 里有就不再发默认值。

2. do_HEAD 和 do_GET 的缓存头不一致:HEAD 对非 /shot/ 一律报 no-store。
   下载器/代理用 HEAD 探测时会误判文件不可缓存。
   现在 _head_info 给每个路由带上和 GET 完全相同的缓存头,
   do_HEAD 也不再盲目覆盖。

结果(同一个测试口径):
  setup.exe  17 KB/s(截断) -> 695 KB/s 完整 15 MB   41 倍
  watch.apk   3 KB/s         -> 101 KB/s              33 倍
  server.zip 67 KB/s         ->  87 KB/s
  三个文件全部 cf-cache-status=HIT(不再穿隧道回源)

排查过程记一笔:我一开始用 curl -I 验证,
看到的全是 no-store —— 因为 **-I 发的是 HEAD**,
而 HEAD 走的是另一个分支。查了半天才发现是验证方法错了。
2026-10-07 15:44:48 +08:00

455 lines
18 KiB
Python
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.
# -*- coding: utf-8 -*-
r"""一次把代码和发行版发到两个平台。
平台:
origin gitee.com:xmz7891/ykxmz (Gitee,API v5)
ezhub git.261826.xyz/xmz/ykxmz (EzHub = Gitea 1.22,API v1)
用法:
python _build/release_all.py <gitee令牌> <ezhub令牌> [版本号] [--dry]
--dry 只看会做什么,不真的推/建
为什么合成一个脚本:
两个平台的产物、更新内容完全一样,分开写会漂移
(比如改了一个忘了改另一个)。这里把"要发什么"算一次,
然后分别喂给各自的接口。
令牌只从命令行读,不落盘、不进仓库。
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
GIT = r"C:\Program Files\Git\bin\git.exe"
SSH = r"C:\Program Files\Git\usr\bin\ssh.exe"
PLATFORMS = {
"gitee": {
"remote": "origin",
"web": "https://gitee.com/xmz7891/ykxmz",
"kind": "gitee",
},
"ezhub": {
"remote": "ezhub",
"web": "https://git.261826.xyz/xmz/ykxmz",
"kind": "gitea",
},
}
def run_git(args: list, check: bool = False) -> subprocess.CompletedProcess:
import os
env = dict(os.environ)
env["GIT_SSH_COMMAND"] = '"%s"' % SSH
return subprocess.run([GIT] + args, cwd=str(ROOT), env=env,
capture_output=True, text=True,
encoding="utf-8", errors="replace")
def read_changelog(version: str) -> str:
"""取 CHANGELOG 里这个版本那一节 —— 发行版说明只放更新内容。
⚠ 注意:按 "\\n## " 切分时,**第一块会带着自己的 "## 版本号"**
(因为它前面没有换行),后面几块没有。
所以匹配版本号前必须先把开头的 # 去掉 ——
原来只写 startswith("1.8.0"),对第一块永远匹配不上,
全靠"取不到就退回第一块"的兜底碰巧对了。
"""
note = ROOT / "_build" / "CHANGELOG.md"
if not note.is_file():
return ""
text = note.read_text(encoding="utf-8")
blocks = [b.strip() for b in text.split("\n## ") if b.strip()]
def strip_heading(block: str) -> str:
lines = block.splitlines()
if lines and lines[0].lstrip().startswith("#"):
lines = lines[1:]
return "\n".join(lines).strip()
for block in blocks:
first = block.splitlines()[0].lstrip("# ").strip()
if first == version or first == "v" + version:
return strip_heading(block)
return strip_heading(blocks[0]) if blocks else ""
def artifacts(version: str) -> list:
return [
ROOT / "dist" / f"ykxmz-setup-{version}.exe",
ROOT / "dist" / f"ykxmz-server-{version}.zip",
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
]
# ---------------------------------------------------------------- Gitee
def gitee_release(token: str, version: str, body: str,
files: list) -> bool:
import mimetypes
import uuid
api = "https://gitee.com/api/v5/repos/xmz7891/ykxmz"
tag = "v" + version
# 先看标签有没有发行版。
# 不先查的话,重发会直接 400「该标签已经存在发行版」——
# 而且没有可复用的 id,后面的附件也就传不上。
rid = None
try:
with urllib.request.urlopen(
"%s/releases/tags/%s?access_token=%s" % (api, tag, token),
timeout=60) as resp:
rid = json.loads(resp.read().decode("utf-8", "replace")).get("id")
print(" • 该标签已有发行版 id=%s,复用" % rid)
except urllib.error.HTTPError:
pass
if not rid:
payload = json.dumps({
"access_token": token, "tag_name": tag,
"name": "ykxmz " + version, "body": body,
"target_commitish": "main", "prerelease": False,
}).encode("utf-8")
req = urllib.request.Request(
api + "/releases", data=payload, method="POST",
headers={"Content-Type": "application/json;charset=UTF-8",
"User-Agent": "ykxmz-release"})
try:
with urllib.request.urlopen(req, timeout=90) as resp:
reply = json.loads(resp.read().decode("utf-8", "replace"))
rid = reply.get("id")
print(" ✓ 发行版已建 id=%s" % rid)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
print(" ✗ 建发行版失败 HTTP %s: %s" % (exc.code, detail))
return False
# 复用已有发行版时先把同名旧附件删掉,否则每跑一次多一份。
#
# ⚠ Gitee 的坑:`GET /releases/{id}` 返回的 `assets` **只有 name 和
# download_url,没有 id**,照着它删是删不掉的。
# 必须换成单独这个接口才带 id:
# GET /releases/{id}/attach_files -> [{id, name, size, ...}]
# 删也是:DELETE /releases/{id}/attach_files/{attach_id}
try:
with urllib.request.urlopen(
"%s/releases/%s/attach_files?access_token=%s"
% (api, rid, token), timeout=90) as resp:
old_assets = json.loads(resp.read().decode("utf-8", "replace"))
if not isinstance(old_assets, list):
old_assets = []
wanted = {p.name for p in files}
for asset in old_assets:
name = asset.get("name")
aid = asset.get("id")
if name in wanted and aid:
del_url = ("%s/releases/%s/attach_files/%s"
"?access_token=%s" % (api, rid, aid, token))
dreq = urllib.request.Request(del_url, method="DELETE")
for attempt in range(1, 4):
try:
urllib.request.urlopen(dreq, timeout=90)
print(" • 删掉同名旧附件 %s" % name)
break
except urllib.error.HTTPError as exc:
print(" • 删 %s 失败 HTTP %s(继续)"
% (name, exc.code))
break
except Exception: # noqa: BLE001
if attempt == 3:
print(" • 删 %s 网络失败,跳过" % name)
else:
time.sleep(4)
except Exception as exc: # noqa: BLE001
print(" • 清理旧附件时出错(继续): %s" % exc)
ok = 0
for path in files:
boundary = "----yk" + uuid.uuid4().hex
ctype = (mimetypes.guess_type(path.name)[0]
or "application/octet-stream")
head = (
"--%s\r\n"
'Content-Disposition: form-data; name="access_token"\r\n\r\n'
"%s\r\n"
"--%s\r\n"
'Content-Disposition: form-data; name="file"; '
'filename="%s"\r\n'
"Content-Type: %s\r\n\r\n"
% (boundary, token, boundary, path.name, ctype)
).encode("utf-8")
data = head + path.read_bytes() + \
("\r\n--%s--\r\n" % boundary).encode("utf-8")
req = urllib.request.Request(
"%s/releases/%s/attach_files" % (api, rid), data=data,
method="POST",
headers={"Content-Type":
"multipart/form-data; boundary=" + boundary,
"Content-Length": str(len(data)),
"User-Agent": "ykxmz-release"})
try:
with urllib.request.urlopen(req, timeout=900) as resp:
if resp.status in (200, 201):
print(" ✓ %s" % path.name)
ok += 1
except urllib.error.HTTPError as exc:
print(" ✗ %s HTTP %s" % (path.name, exc.code))
return ok == len(files)
# ---------------------------------------------------------------- Gitea
def gitea_release(token: str, version: str, body: str,
files: list) -> bool:
import mimetypes
import urllib.parse
import uuid
api = "https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
tag = "v" + version
auth = {"Authorization": "token " + token,
"User-Agent": "ykxmz-release"}
# 先看标签有没有发行版(重发时复用,别报错)
rid = None
try:
req = urllib.request.Request(
"%s/releases/tags/%s" % (api, tag), headers=auth)
with urllib.request.urlopen(req, timeout=60) as resp:
rid = json.loads(resp.read().decode("utf-8", "replace")).get("id")
print(" • 该标签已有发行版 id=%s,复用" % rid)
except urllib.error.HTTPError:
pass
if not rid:
payload = json.dumps({
"tag_name": tag, "name": "ykxmz " + version, "body": body,
"target_commitish": "main", "draft": False,
"prerelease": False,
}).encode("utf-8")
headers = dict(auth)
headers["Content-Type"] = "application/json"
req = urllib.request.Request(api + "/releases", data=payload,
method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
reply = json.loads(resp.read().decode("utf-8", "replace"))
rid = reply.get("id")
print(" ✓ 发行版已建 id=%s" % rid)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
print(" ✗ 建发行版失败 HTTP %s: %s" % (exc.code, detail))
return False
# 先把同名旧附件删掉。
# 不删的话,复用已有发行版时每跑一次就多传一份 ——
# 实测调试期间同一个文件堆了 3 份。
try:
req = urllib.request.Request(
"%s/releases/%s" % (api, rid), headers=auth)
with urllib.request.urlopen(req, timeout=60) as resp:
existing = json.loads(resp.read().decode("utf-8", "replace"))
wanted = {p.name for p in files}
for asset in (existing.get("assets") or []):
if asset.get("name") in wanted:
del_url = "%s/releases/%s/assets/%s" % (api, rid,
asset.get("id"))
dreq = urllib.request.Request(del_url, method="DELETE",
headers=auth)
try:
urllib.request.urlopen(dreq, timeout=60)
print(" • 删掉同名旧附件 %s" % asset.get("name"))
except urllib.error.HTTPError as exc:
print(" • 删 %s 失败 HTTP %s(继续)"
% (asset.get("name"), exc.code))
except Exception as exc: # noqa: BLE001
print(" • 清理旧附件时出错(继续): %s" % exc)
ok = 0
for path in files:
boundary = "----yk" + uuid.uuid4().hex
ctype = (mimetypes.guess_type(path.name)[0]
or "application/octet-stream")
head = (
"--%s\r\n"
'Content-Disposition: form-data; name="attachment"; '
'filename="%s"\r\n'
"Content-Type: %s\r\n\r\n" % (boundary, path.name, ctype)
).encode("utf-8")
data = head + path.read_bytes() + \
("\r\n--%s--\r\n" % boundary).encode("utf-8")
url = ("%s/releases/%s/assets?name=%s"
% (api, rid, urllib.parse.quote(path.name)))
headers = dict(auth)
headers["Content-Type"] = "multipart/form-data; boundary=" + boundary
headers["Content-Length"] = str(len(data))
req = urllib.request.Request(url, data=data, method="POST",
headers=headers)
try:
with urllib.request.urlopen(req, timeout=900) as resp:
if resp.status in (200, 201):
print(" ✓ %s" % path.name)
ok += 1
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:200]
print(" ✗ %s HTTP %s %s" % (path.name, exc.code, detail))
return ok == len(files)
def check_ezhub_scope(token: str) -> bool:
"""提前确认 EzHub 令牌有 write:repository。
为什么要单独查:
Gitea 的令牌可以只给读权限。只读令牌能读仓库、能列发行版,
看起来"正常",但一推代码就 403、一建发行版就 403,
而且报错信息藏在一层层输出里。所以开头就探一下。
探法(无副作用):删一个**不存在的**发行版。
403 -> 缺 write:repository
404 -> 有写权限(只是资源不存在)
"""
url = ("https://git.261826.xyz/api/v1/repos/xmz/ykxmz"
"/releases/99999999")
req = urllib.request.Request(
url, method="DELETE",
headers={"Authorization": "token " + token,
"User-Agent": "ykxmz-release"})
try:
urllib.request.urlopen(req, timeout=30)
return True
except urllib.error.HTTPError as exc:
if exc.code == 404:
return True
detail = exc.read().decode("utf-8", "replace")[:200]
if "write:repository" in detail:
print(" ✗ EzHub 令牌缺 write:repository 权限")
print(" -> 去 EzHub:设置 → 应用 → 生成令牌,"
"权限勾上 write:repository")
return False
print(" ✗ EzHub 令牌检查失败 HTTP %s: %s" % (exc.code, detail))
return False
except Exception as exc: # noqa: BLE001
print(" ✗ 连不上 EzHub: %s" % exc)
return False
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
dry = "--dry" in sys.argv
if len(args) < 2:
print(" 用法: python _build/release_all.py <gitee令牌> <ezhub令牌>"
" [版本号] [--dry]")
return 1
gitee_token, ezhub_token = args[0].strip(), args[1].strip()
version = args[2] if len(args) > 2 else "1.8.0"
tag = "v" + version
print(" 版本: %s" % version)
print(" 模式: %s" % ("演练(不真的发)" if dry else "正式发布"))
print()
# ---- 0. 前置检查 ----
print(" 一、前置检查")
files = artifacts(version)
missing = [f for f in files if not f.is_file()]
for path in files:
mark = "✓" if path.is_file() else "✗ 缺失"
size = (path.stat().st_size / 1024 / 1024) if path.is_file() else 0
print(" %s %-32s %6.2f MB" % (mark, path.name, size))
if missing:
print(" [失败] 产物不全,先构建")
return 1
body = read_changelog(version)
if not body:
print(" [失败] CHANGELOG 里找不到 %s 那一节" % version)
return 1
print(" 更新说明 %d 字符(%d 行)" % (len(body), body.count("\n") + 1))
# 工作区必须干净,否则推上去的和测过的不是一份
st = run_git(["status", "--porcelain"])
if (st.stdout or "").strip():
print(" [失败] 工作区不干净,先提交")
for line in (st.stdout or "").strip().splitlines()[:5]:
print(" " + line)
return 1
print(" ✓ 工作区干净")
# 两个平台的令牌都得有写权限,否则走到一半才失败
if not check_ezhub_scope(ezhub_token):
return 1
print(" ✓ EzHub 令牌有写权限")
# 对外材料清洁度:绝不能让管理页字眼进发行版说明
for word in ("zhongzhuanzhan", "/admin", "管理后台", "管理页"):
if word in body:
print(" [失败] 更新说明里出现了「%s」—— 违反对外口径" % word)
return 1
print(" ✓ 更新说明里没有管理页字眼")
if dry:
print()
print(" 演练结束。要真发就去掉 --dry。")
return 0
# ---- 1. 推代码 ----
# EzHub 的 SSH 密钥在用户那边还没配对成功(服务端拒绝本机公钥),
# 但令牌有 push 权限,所以走 HTTPS + 令牌推。
# 令牌只在这次进程里拼进 URL,不写进 .git/config。
print()
print(" 二、推代码")
for name, info in PLATFORMS.items():
if name == "ezhub":
url = ("https://%s:%s@%s/%s/%s.git"
% ("xmz", ezhub_token, "git.261826.xyz", "xmz", "ykxmz"))
r = subprocess.run([GIT, "push", url, "main"], cwd=str(ROOT),
capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=900)
raw = (r.stderr or r.stdout or "")
else:
r = run_git(["push", info["remote"], "main"])
raw = (r.stderr or r.stdout or "")
ok = r.returncode == 0
# 输出里可能带令牌,打码
safe = raw.replace(ezhub_token, "***") if ezhub_token else raw
tail = (safe.strip().splitlines() or [""])[-1]
print(" %-6s %s %s" % (name, "✓" if ok else "✗", tail[:90]))
if not ok:
print(" (代码没推上去,先修这个再建发行版)")
return 1
# ---- 2. 建发行版 ----
print()
print(" 三、建发行版")
results = {}
print(" [Gitee]")
results["gitee"] = gitee_release(gitee_token, version, body, files)
print(" [EzHub]")
results["ezhub"] = gitea_release(ezhub_token, version, body, files)
print()
print(" ================ 结果 ================")
for name, info in PLATFORMS.items():
mark = "✓" if results.get(name) else "✗"
print(" %s %-6s %s/releases/tag/%s"
% (mark, name, info["web"], tag))
return 0 if all(results.values()) else 1
if __name__ == "__main__":
sys.exit(main())