#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) ================================================================ 用法: python publish_gitea.py SerialMate-v0.1.0-win64.zip python publish_gitea.py --notes-file release/notes-v0.1.0.md python publish_gitea.py --tag SerialMate-v0.1.0 --replace 行为: - tag 与版本默认取 zip 文件名(SerialMate-vX.Y.Z-win64.zip → SerialMate-vX.Y.Z) - release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag); 已存在则复用, 仅上传/更新资产 - release 上已有同名资产时: 默认跳过, --replace 删旧再传 - API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限) - 本实例 TLS 间歇抽风: 已内置连接级重试, 实际发布请加 --insecure """ import argparse import json import os import re import ssl import subprocess import sys import time import urllib.error import urllib.request import uuid DEFAULT_NOTES = """## SerialMate {version} RS485/串口调试助手(LT8619C 调试工具集配套): 串口收发 + 协议包解析, 帧格式(包头/板ID/LEN/CRC 算法与覆盖范围)全可定制, 默认与 DLPilot 光机 RS485 协议一致; payload 支持字符串/HEX 显示、4 通道曲线绘制, 发送框输入 payload 自动包装组帧(多条用 / 分隔), 也可 HEX 直发与周期发送。 ### 安装 下载 `SerialMate-v{version}-win64.zip`, 解压后双击 `SerialMate.exe` (Windows 10/11 x64, 免安装; 串口功能需系统可访问串口, 程序自带 pyserial)。 ### 包内容 - `SerialMate.exe` — 主程序 - `README.md` — 完整使用说明 """ def git_remote_info(): """从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)""" url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip() m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url) if not m: sys.exit(f'无法从 origin 解析 Gitea 地址: {url}') host, owner, repo = m.groups() return f'https://{host}', owner, repo class Api: def __init__(self, base, owner, repo, token, insecure): self.api = f'{base}/api/v1/repos/{owner}/{repo}' self.headers = {'Authorization': f'token {token}'} self.ctx = ssl._create_unverified_context() if insecure else None def request(self, method, path, data=None, headers=None, retries=4): """发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试""" last = None for attempt in range(retries): req = urllib.request.Request(self.api + path, data=data, method=method, headers={**self.headers, **(headers or {})}) try: with urllib.request.urlopen(req, context=self.ctx) as resp: body = resp.read() return resp.status, json.loads(body) if body else None except urllib.error.HTTPError as e: return e.code, json.loads(e.read() or b'{}') except (urllib.error.URLError, ssl.SSLError, OSError) as e: last = e time.sleep(1.0 + attempt) raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}') def get_json(self, path): status, data = self.request('GET', path) return data if status == 200 else None def upload_asset(self, release_id, filepath, replace): name = os.path.basename(filepath) status, rel = self.request('GET', f'/releases/{release_id}') if status != 200: sys.exit(f'读取 release {release_id} 失败: {status} {rel}') # Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组 assets = rel.get('assets') or [] if isinstance(assets, dict): assets = assets.get('items', []) for asset in assets: if asset['name'] == name: if not replace: print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)') return self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}") print(f'已删除旧资产 {name}') boundary = uuid.uuid4().hex payload = open(filepath, 'rb').read() body = (f'--{boundary}\r\n' f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n' f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \ f'\r\n--{boundary}--\r\n'.encode() status, data = self.request( 'POST', f'/releases/{release_id}/assets?name={name}', body, {'Content-Type': f'multipart/form-data; boundary={boundary}'}) if status != 201: sys.exit(f'上传资产失败: {status} {data}') print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)') def main(): ap = argparse.ArgumentParser() ap.add_argument('zipfile_path', help='发布 zip(release/ 下)') ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip') ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板') ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token') ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产') ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)') args = ap.parse_args() if not os.path.exists(args.zipfile_path): sys.exit(f'找不到 {args.zipfile_path}') if not args.token: sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN') tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path)) version = tag.removeprefix('SerialMate-v') notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \ else DEFAULT_NOTES.format(version=version) base, owner, repo = git_remote_info() api = Api(base, owner, repo, args.token, args.insecure) print(f'目标: {base}/{owner}/{repo} tag={tag}') rel = api.get_json(f'/releases/tags/{tag}') if rel: print(f'release 已存在(id={rel["id"]}), 复用') else: status, rel = api.request('POST', '/releases', json.dumps({ 'tag_name': tag, 'name': f'SerialMate v{version}', 'body': notes, }).encode(), {'Content-Type': 'application/json'}) if status != 201: sys.exit(f'创建 release 失败: {status} {rel}') print(f'release 已创建(id={rel["id"]})') api.upload_asset(rel['id'], args.zipfile_path, args.replace) print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}') if __name__ == '__main__': main()