#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) ================================================================ 用法(在固件仓任意目录执行, 自动从 origin 解析仓库): python publish_gitea.py release/FilmForge-GUI-v0.2.0-win64.zip python publish_gitea.py --notes-file release/notes-v0.2.0.md python publish_gitea.py --tag FilmForge-GUI-v0.2.0 --replace 行为: - tag 与版本默认取 zip 文件名(FilmForge-GUI-vX.Y.Z-win64.zip → FilmForge-GUI-vX.Y.Z) - release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag); 已存在则复用, 仅上传/更新资产 - release 上已有同名资产时: 默认跳过, --replace 删旧再传 - API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限) - 本实例 python urllib 间歇性 SSL UNEXPECTED_EOF, 已内置连接级自动重试; 证书校验失败时加 --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 = """## FilmForge GUI v{version} FilmForge 热压模具温控板(RS485)上位机: 实时温度曲线(自绘, 无第三方图表依赖)、 目标温度/加热/周期上报控制、收发日志、CSV 数据记录。协议见固件仓 `protocols.md`。 ### 安装 下载 `FilmForge-GUI-v{version}-win64.zip`, 解压后双击 `FilmForge.exe`(Windows 10/11 x64, 免安装)。 ### 包内容 - `FilmForge.exe` — 主程序 - `README.md` — 完整使用说明 ### 注意 - 加热(M3S1)为真实 PID 闭环控温: 开启后模具将真实加热(NTC 故障/过温 150℃ 固件自动断电保护)。 - 软件复位(M888)/进 OTA(M9999) 会复位设备, 界面内已做二次确认。 """ 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@|https?://)(?:[^/@]+@)?([\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('FilmForge-GUI-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'FilmForge GUI 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()