a1a3118c88
Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回 JSON 元数据(约245字节)
而非二进制,导致下载到坏 web.tar.gz、部署时 tar 报 Unrecognized archive format。
改用 asset.browser_download_url(http→https 跟随重定向 + token)拉真正的文件,
并在下载后校验字节数与声明 size 一致,不符即当场失败。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
160 lines
7.2 KiB
Bash
160 lines
7.2 KiB
Bash
#!/usr/bin/env bash
|
||
# lib-forgejo.sh — shared CI helpers for the three release pipelines
|
||
# (client / site / server). `source` this from release-*.sh and deploy-*.sh.
|
||
#
|
||
# Provides:
|
||
# ver_from_tag <tag> -> strips client-v|site-v|server-v|v prefix
|
||
# extract_release_notes <file> -> prints the latest "## [x]" section body
|
||
# create_release <tag> <body> -> POSTs a Forgejo Release, sets RELEASE_ID
|
||
# upload_asset <file> -> uploads one asset (needs RELEASE_ID)
|
||
# download_release_assets <tag> -> downloads all assets of a release into dist/
|
||
# setup_ssh / teardown_ssh -> prepare/clean the EC2 deploy key (sets SSH/SCP)
|
||
#
|
||
# Requires env (where relevant): FORGEJO_URL, FORGEJO_TOKEN, GITEA_REPOSITORY,
|
||
# EC2_SSH_KEY, EC2_HOST, EC2_USER.
|
||
|
||
# Strip the part-specific tag prefix, leaving a bare semver (1.2.3).
|
||
ver_from_tag() {
|
||
local tag="$1"
|
||
tag="${tag#client-v}"
|
||
tag="${tag#site-v}"
|
||
tag="${tag#server-v}"
|
||
tag="${tag#v}"
|
||
printf '%s' "$tag"
|
||
}
|
||
|
||
# Print the body (lines) of the most recent "## [ver] - date" section.
|
||
extract_release_notes() {
|
||
python3 - "$1" <<'PYEOF'
|
||
import re, sys
|
||
fn = sys.argv[1]
|
||
try:
|
||
with open(fn, encoding='utf-8') as f:
|
||
content = f.read()
|
||
parts = re.split(r'\n## ', '\n' + content)
|
||
if len(parts) > 1:
|
||
lines = parts[1].strip().split('\n')
|
||
notes = []
|
||
for line in lines[1:]:
|
||
s = line.strip()
|
||
if s.startswith('## '):
|
||
break
|
||
if s:
|
||
notes.append(s)
|
||
print('\n'.join(notes) if notes else lines[0])
|
||
else:
|
||
print('')
|
||
except Exception:
|
||
print('')
|
||
PYEOF
|
||
}
|
||
|
||
# create_release <tag> <body_json_string> — sets global RELEASE_ID on success.
|
||
# 幂等:若该 tag 的 Release 已存在(重跑发版时 Forgejo 回 409),不再硬失败,
|
||
# 改为取回既有 Release 复用其 id 继续(配合 upload_asset 的覆盖上传,重跑安全)。
|
||
create_release() {
|
||
local tag="$1" body="$2" http_code resp
|
||
echo "==> release: creating Forgejo Release ${tag}"
|
||
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
|
||
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}")
|
||
resp=$(cat /tmp/release_resp.json)
|
||
echo "==> release: API response (HTTP ${http_code}): ${resp}"
|
||
if [ "$http_code" = "409" ]; then
|
||
# Release 已存在(重跑)→ 幂等复用:按 tag 取回既有 Release。
|
||
echo "==> release: 该 tag 的 Release 已存在,改为复用(幂等重跑)"
|
||
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
|
||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}")
|
||
resp=$(cat /tmp/release_resp.json)
|
||
echo "==> release: 既有 Release 查询 (HTTP ${http_code}): ${resp}"
|
||
fi
|
||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||
echo "==> release: FAILED — HTTP ${http_code}" >&2
|
||
return 1
|
||
fi
|
||
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${resp}")
|
||
echo "==> release: release_id=${RELEASE_ID}"
|
||
}
|
||
|
||
# upload_asset <file> — requires RELEASE_ID.
|
||
# 幂等:若同名 asset 已存在(重跑),先删旧再传新,避免 Forgejo 409 重名冲突。
|
||
upload_asset() {
|
||
local file="$1" code name existing_id
|
||
name=$(basename "$file")
|
||
existing_id=$(curl -ks -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||
| python3 -c "import sys,json; a=json.load(sys.stdin); print(next((str(x['id']) for x in a if x.get('name')==sys.argv[1]), ''))" "$name" 2>/dev/null || true)
|
||
if [ -n "$existing_id" ]; then
|
||
echo "==> release: asset ${name} 已存在(id=${existing_id}),先删旧再重传"
|
||
curl -ks -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets/${existing_id}" >/dev/null || true
|
||
fi
|
||
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
|
||
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
-F "attachment=@${file}")
|
||
echo "==> release: uploaded ${file} (HTTP ${code}): $(cat /tmp/upload_resp.json)"
|
||
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then return 1; fi
|
||
}
|
||
|
||
# download_release_assets <tag> — pull every asset of the release into dist/.
|
||
download_release_assets() {
|
||
local tag="$1"
|
||
echo "==> deploy: downloading assets for ${tag} from Forgejo"
|
||
mkdir -p dist
|
||
curl -kf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}" \
|
||
-o /tmp/release-info.json
|
||
python3 - <<'PYEOF'
|
||
import json, urllib.request, ssl, os
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
rel = json.load(open('/tmp/release-info.json'))
|
||
token = os.environ['FORGEJO_TOKEN']
|
||
# 用每个 asset 的 browser_download_url 拉真正的二进制。
|
||
# 注意:Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回的是 JSON
|
||
# 元数据(几百字节)而非文件本身——曾导致下载到 245B 的坏 web.tar.gz、
|
||
# 部署时 tar 报 "Unrecognized archive format"。browser_download_url 是
|
||
# http,会 301 到 https,urllib 默认跟随重定向;带上 token 以防私有仓 401。
|
||
for a in rel.get('assets', []):
|
||
dl = a.get('browser_download_url')
|
||
if not dl:
|
||
raise SystemExit(f"asset {a.get('name')} 缺 browser_download_url")
|
||
req = urllib.request.Request(dl, headers={'Authorization': f'token {token}'})
|
||
with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o:
|
||
o.write(r.read())
|
||
got = os.path.getsize(f"dist/{a['name']}")
|
||
exp = a.get('size', 0)
|
||
print(f"Downloaded: {a['name']} ({got} bytes)")
|
||
if exp and got != exp:
|
||
raise SystemExit(f"下载大小不符: {a['name']} 期望 {exp} 实得 {got}")
|
||
PYEOF
|
||
}
|
||
|
||
# setup_ssh — write the deploy key and export SSH / SCP commands.
|
||
# Target is the EC2 host by default; set DEPLOY_HOST / DEPLOY_USER / DEPLOY_SSH_KEY
|
||
# to retarget another host (e.g. the Alibaba Cloud box during the EC2→Ali migration).
|
||
# Backward-compatible: with no DEPLOY_* set, behaves exactly as before (EC2).
|
||
setup_ssh() {
|
||
local host="${DEPLOY_HOST:-$EC2_HOST}"
|
||
local key="${DEPLOY_SSH_KEY:-$EC2_SSH_KEY}"
|
||
mkdir -p ~/.ssh
|
||
# `printf '%s\n'` 末尾补一个换行:Forgejo 存 secret 会去掉结尾换行,而
|
||
# OpenSSH 格式私钥(-----BEGIN OPENSSH PRIVATE KEY-----,如阿里 ed25519 部署 key)
|
||
# 缺结尾换行会被判为 "invalid format" 而拒绝加载,退化成无密钥 → Permission denied。
|
||
# 多补的换行对已含结尾换行的 PEM(如 EC2 key)无害。
|
||
printf '%s\n' "${key}" > ~/.ssh/ec2_deploy.pem
|
||
chmod 600 ~/.ssh/ec2_deploy.pem
|
||
ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts 2>/dev/null
|
||
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
|
||
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
|
||
}
|
||
|
||
teardown_ssh() {
|
||
rm -f ~/.ssh/ec2_deploy.pem
|
||
}
|