157 lines
6.6 KiB
Python
157 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
|
|
================================================================
|
|
用法:
|
|
python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip
|
|
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
|
|
python publish_gitea.py <zip> --tag DAPLinkFlash-v0.1.0 --replace
|
|
|
|
行为:
|
|
- tag 与版本默认取 zip 文件名(DAPLinkFlash-vX.Y.Z-win64.zip → DAPLinkFlash-vX.Y.Z)
|
|
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
|
|
已存在则复用, 仅上传/更新资产
|
|
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
|
|
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
APP_NAME = 'DAPLinkFlash'
|
|
|
|
DEFAULT_NOTES = """## DAPLink Flash {version}
|
|
|
|
GD32E230 固件烧录与打包工具: OpenOCD 烧录(检测/烧录/擦除/读取/复位)、
|
|
固件补齐(Pad, Y-Modem 对齐)、镜像拼接(Merge, Bootloader+APP+有效标志)。
|
|
|
|
### 安装
|
|
下载 `{app}-v{version}-win64.zip`, 解压后双击 `{app}.exe`(Windows 10/11 x64, 免安装)。
|
|
|
|
### 包内容
|
|
- `{app}.exe` — 主程序(单文件自包含)
|
|
- `toolchain/openocd/` — 内置 OpenOCD 工具链(openocd.exe + scripts, GUI 自动定位)
|
|
- `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=f'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(APP_NAME + '-v')
|
|
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
|
|
else DEFAULT_NOTES.format(version=version, app=APP_NAME)
|
|
|
|
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'{APP_NAME} 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()
|