Files
TallyNote/scripts/publish-gitea-release.sh
2026-09-11 18:28:03 +08:00

359 lines
15 KiB
Bash
Executable File
Raw Permalink 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.
#!/usr/bin/env bash
set -Eeuo pipefail
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
# always generated; an Ed25519 detached signature is added when a signing key
# is supplied. The script remains separate from the workflow so operators can
# dry-run the exact same asset selection locally without exposing a key.
PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export PATH
umask 077
TAG=''
ASSET_DIR='release'
GITHUB_SERVER=${GITHUB_SERVER_URL:-https://git.awaioi.com}
GITHUB_SERVER=${GITHUB_SERVER%/}
API_ROOT=${GITEA_API_URL:-$GITHUB_SERVER/api/v1}
REPOSITORY=${GITHUB_REPOSITORY:-awaioi/TallyNote}
TOKEN=${GITEA_TOKEN:-${GITHUB_TOKEN:-}}
SIGNING_KEY_FILE=${TALLYNOTE_RELEASE_SIGNING_KEY_FILE:-}
SIGNING_KEY_VALUE=${TALLYNOTE_RELEASE_SIGNING_KEY:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
CURL_BIN=${TALLYNOTE_CURL_BIN:-curl}
DRY_RUN=0
AUTH_CONFIG=''
SUMS_TMP=''
SIG_TMP=''
RELEASE_NOTES_TMP=''
SIGNATURE_GENERATED=0
usage() {
cat <<'EOF'
Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
Required in publish mode:
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
Optional:
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
Without a signing key, the release is published with SHA256SUMS only.
EOF
}
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
generate_release_notes() {
local current=${TAG#v} previous='' subject kind line count=0
local -a commits
commits=()
# A workflow checks out the tag with history. Prefer an explicitly supplied
# notes file for mirrors, then derive notes from the immutable tag range.
if [[ -n "${TALLYNOTE_RELEASE_NOTES_FILE:-}" && -f "$TALLYNOTE_RELEASE_NOTES_FILE" ]]; then
# Read at most the API's bounded notes size without a pipe that can turn a
# deliberately truncated input into a SIGPIPE failure under pipefail.
LC_ALL=C awk 'BEGIN { remaining = 65536 } { if (remaining <= 0) exit; line=$0; gsub(/[[:cntrl:]]/, "", line); bytes=length(line)+1; if (bytes > remaining) { print substr(line, 1, remaining); exit } print line; remaining-=bytes }' "$TALLYNOTE_RELEASE_NOTES_FILE"
return
fi
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -n "$line" ]] || continue
[[ "$line" == "v${current}" ]] && continue
previous="$line"
break
done < <(git tag --sort=-version:refname --list 'v*')
if [[ -n "$previous" && "$previous" != "v${current}" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log --format='%s' "${previous}..${TAG}")
else
while IFS= read -r line; do
[[ -n "$line" ]] && commits+=("$line")
done < <(git log -n 30 --format='%s' "$TAG")
fi
fi
printf '# TallyNote %s\n\n' "$current"
if [[ -n "$previous" ]]; then
printf '> 从 `%s` 到 `%s` 的变更\n\n' "$previous" "v${current}"
else
printf '> 本版本变更\n\n'
fi
local -a features fixes improvements docs other
features=(); fixes=(); improvements=(); docs=(); other=()
for subject in "${commits[@]-}"; do
# Do not expose merge noise or the synthetic release commit in user notes.
[[ "$subject" != Merge\ * && "$subject" != release:* ]] || continue
kind=${subject%%:*}
if [[ "$subject" == *:* ]]; then subject=${subject#*: }; fi
subject=${subject# }
[[ -n "$subject" ]] || continue
case "$kind" in
feat|feature) features+=("$subject") ;;
fix|bugfix) fixes+=("$subject") ;;
refactor|perf|style|improvement) improvements+=("$subject") ;;
docs|doc|test|tests) docs+=("$subject") ;;
*) other+=("$subject") ;;
esac
done
print_group() {
local title=$1; shift
local item
(($# > 0)) || return 0
printf '## %s\n\n' "$title"
for item in "$@"; do printf -- '- %s\n' "$item"; done
printf '\n'
}
((${#features[@]})) && print_group '新增功能' "${features[@]}"
((${#fixes[@]})) && print_group '问题修复' "${fixes[@]}"
((${#improvements[@]})) && print_group '优化与重构' "${improvements[@]}"
((${#docs[@]})) && print_group '文档与测试' "${docs[@]}"
((${#other[@]})) && print_group '其他变更' "${other[@]}"
if (( ${#features[@]} + ${#fixes[@]} + ${#improvements[@]} + ${#docs[@]} + ${#other[@]} == 0 )); then
printf '本版本包含内部维护更新。\n'
fi
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
prerelease=${value#*-}
[[ "$value" == *-* ]] || return 0
prerelease=${prerelease%%+*}
IFS='.' read -r -a _prerelease_parts <<< "$prerelease"
for part in "${_prerelease_parts[@]}"; do
[[ ! "$part" =~ ^0[0-9]+$ ]] || return 1
done
}
validate_api_root() {
local value=$1 authority host port path_part
[[ "$value" == https://* && "$value" != *[[:cntrl:]]* && "$value" != *[[:space:]]* ]] || die 'GITEA_API_URL must be a clean HTTPS URL'
[[ "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die 'GITEA_API_URL must not contain credentials, query, or fragment'
authority=${value#https://}
authority=${authority%%/*}
[[ -n "$authority" ]] || die 'GITEA_API_URL host is invalid'
if [[ "$authority" == \[*\]* ]]; then
host=${authority#\[}; host=${host%%\]*}
else
host=${authority%%:*}
fi
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die 'GITEA_API_URL host is invalid'
if [[ "$authority" != \[*\]* && "$authority" == *:* ]]; then
port=${authority##*:}
[[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$port" -le 65535 ]] || die 'GITEA_API_URL port is invalid'
fi
path_part=${value#https://"$authority"}
[[ -z "$path_part" || "$path_part" == /* ]] || die 'GITEA_API_URL path is invalid'
[[ "$path_part" != *'//'* ]] || die 'GITEA_API_URL path is invalid'
}
assert_sidecar_target() {
local target=$1
[[ ! -L "$target" ]] || die "sidecar target must not be a symbolic link: $target"
[[ ! -e "$target" || -f "$target" ]] || die "sidecar target must be a regular file: $target"
}
validate_signing_key_file() {
local file=$1 uid mode
[[ -f "$file" && ! -L "$file" ]] || die 'signing key file is invalid'
uid=$(stat -c '%u' "$file" 2>/dev/null || stat -f '%u' "$file")
mode=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file")
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]] || die 'signing key file must be owned by the publishing user'
[[ "$mode" =~ ^[0-7]+$ && $((8#$mode & 18)) -eq 0 ]] || die 'signing key file is readable or writable by group/other users'
}
write_auth_config() {
local escaped
[[ "$TOKEN" != *[[:cntrl:]]* && ${#TOKEN} -le 4096 ]] || die 'Gitea token contains invalid characters'
escaped=${TOKEN//\\/\\\\}
escaped=${escaped//\"/\\\"}
AUTH_CONFIG=$(mktemp)
chmod 600 "$AUTH_CONFIG"
printf 'header = "Authorization: token %s"\nheader = "Accept: application/json"\n' "$escaped" > "$AUTH_CONFIG"
}
while (($#)); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*)
if [[ -z "$TAG" ]]; then TAG=$1
elif [[ "$ASSET_DIR" == release ]]; then ASSET_DIR=$1
else die "unknown option: $1"; fi
;;
esac
shift
done
validate_semver "$TAG" || die 'TAG must be a semantic version such as v1.0.0'
TAG="v${TAG#v}"
[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die 'GITHUB_REPOSITORY must be owner/repository'
API_ROOT=${API_ROOT%/}
validate_api_root "$API_ROOT"
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
fi
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
full_assets=()
for file in "$ASSET_DIR"/*.tar.gz; do
[[ -f "$file" && ! -L "$file" ]] || continue
name=$(basename -- "$file")
[[ "$name" =~ ^tallynote-[A-Za-z0-9][A-Za-z0-9.+-]*-linux-(x64|arm64|armv7)-[A-Za-z0-9._-]+\.tar\.gz$ ]] || die "invalid release asset name: $name"
asset_version=${name#tallynote-}
asset_version=${asset_version%%-linux-*}
[[ "$asset_version" == "${TAG#v}" ]] || die "release asset version does not match tag: $name"
if [[ "$name" =~ \.update-[a-f0-9]{64}\.tar\.gz$ ]]; then
die "不再发布轻量更新资产:$name;请只保留完整生产包"
fi
full_assets+=("$file")
done
assets=("${full_assets[@]}")
(( ${#assets[@]} > 0 )) || die 'no .tar.gz release asset found'
(( ${#full_assets[@]} > 0 )) || die 'no full release asset found'
SUMS_FILE="$ASSET_DIR/SHA256SUMS"
SIG_FILE="$ASSET_DIR/SHA256SUMS.sig"
assert_sidecar_target "$SUMS_FILE"
assert_sidecar_target "$SIG_FILE"
SUMS_TMP=$(mktemp "$ASSET_DIR/.SHA256SUMS.XXXXXX")
{
(cd "$ASSET_DIR" && for file in ./*.tar.gz; do sha256sum "$file"; done)
} | sed 's#^\./##' | LC_ALL=C sort > "$SUMS_TMP"
chmod 600 "$SUMS_TMP"
mv -f -- "$SUMS_TMP" "$SUMS_FILE"
SUMS_TMP=''
temporary_key=''
temporary_key_owned=0
release_json=''
cleanup() {
if [[ "$temporary_key_owned" -eq 1 && -n "$temporary_key" ]]; then rm -f -- "$temporary_key"; fi
if [[ -n "$release_json" ]]; then rm -f -- "$release_json"; fi
if [[ -n "$AUTH_CONFIG" ]]; then rm -f -- "$AUTH_CONFIG"; fi
if [[ -n "$SUMS_TMP" ]]; then rm -f -- "$SUMS_TMP"; fi
if [[ -n "$SIG_TMP" ]]; then rm -f -- "$SIG_TMP"; fi
if [[ -n "$RELEASE_NOTES_TMP" ]]; then rm -f -- "$RELEASE_NOTES_TMP"; fi
}
trap cleanup EXIT
if [[ -n "$SIGNING_KEY_FILE" ]]; then
validate_signing_key_file "$SIGNING_KEY_FILE"
temporary_key=$SIGNING_KEY_FILE
elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
temporary_key=$(mktemp)
temporary_key_owned=1
chmod 600 "$temporary_key"
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
unset SIGNING_KEY_VALUE
fi
if [[ -n "$temporary_key" ]]; then
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
SIG_TMP=$(mktemp "$ASSET_DIR/.SHA256SUMS.sig.XXXXXX")
"$OPENSSL_BIN" pkeyutl -sign -rawin -inkey "$temporary_key" -in "$SUMS_FILE" -out "$SIG_TMP" >/dev/null 2>&1 || die 'could not create Ed25519 signature'
chmod 600 "$SIG_TMP"
mv -f -- "$SIG_TMP" "$SIG_FILE"
SIG_TMP=''
SIGNATURE_GENERATED=1
fi
log "tag: $TAG"
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
log "$asset_summary"
if (( DRY_RUN )); then
log 'dry-run: no API request was sent'
exit 0
fi
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
# Keep the release body deterministic and human-readable. Gitea renders this
# Markdown in the Release page; the update API later exposes the same body as
# text for the safe client-side Markdown renderer.
RELEASE_NOTES_TMP=$(mktemp)
generate_release_notes > "$RELEASE_NOTES_TMP"
release_notes=$(<"$RELEASE_NOTES_TMP")
api_curl() {
"$CURL_BIN" --proto '=https' --tlsv1.2 --fail --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
}
api_curl_status() {
# Status probes must keep 404/409 bodies so the caller can distinguish a
# missing release from a transport failure without putting the token in argv.
"$CURL_BIN" --proto '=https' --tlsv1.2 --silent --show-error --connect-timeout 15 --max-time 120 \
--config "$AUTH_CONFIG" "$@"
}
repo_path="${REPOSITORY}"
release_json=$(mktemp)
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取 Gitea Release'
if [[ "$status" == 200 ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
existing_body=$(jq -r '.body // ""' "$release_json")
# Older releases used a one-line placeholder. Upgrade that placeholder when
# a tag is republished, while leaving deliberately authored release notes
# untouched.
if [[ "$existing_body" == "TallyNote $TAG" || -z "$existing_body" ]]; then
patch_body=$(jq -cn --arg body "$release_notes" '{body:$body}')
patch_status=$(api_curl_status -X PATCH -H 'Content-Type: application/json' -d "$patch_body" -o /dev/null -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/$release_id") || die '无法更新 Gitea Release 日志'
[[ "$patch_status" == 2* ]] || die "无法更新 Gitea Release 日志(HTTP $patch_status)"
fi
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "$release_notes" '{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false}')
create_status=$(api_curl_status -H 'Content-Type: application/json' -d "$body" -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases") || die '无法创建 Gitea Release'
if [[ "$create_status" == 2* ]]; then
release_id=$(jq -r '.id // empty' "$release_json")
elif [[ "$create_status" == 409 || "$create_status" == 422 ]]; then
# Another runner may have created the tag between our GET and POST. Reuse
# that release instead of producing a duplicate or failing the workflow.
status=$(api_curl_status --max-time 30 -o "$release_json" -w '%{http_code}' "$API_ROOT/repos/$repo_path/releases/tags/$TAG") || die '无法读取并发创建的 Gitea Release'
[[ "$status" == 200 ]] || die "Gitea Release 创建冲突(HTTP $create_status)"
release_id=$(jq -r '.id // empty' "$release_json")
else
die "无法创建 Gitea Release(HTTP $create_status)"
fi
else
die "Gitea Release 查询失败(HTTP $status)"
fi
[[ "$release_id" =~ ^[0-9]+$ ]] || die 'Gitea 未返回有效 Release ID'
assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
# Remove same-name assets so rerunning a tag build is deterministic. The
# release itself and all unrelated assets remain untouched.
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
while IFS=$'\t' read -r existing_id existing_name; do
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
for candidate in "${candidates[@]}"; do
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
done
done < <(jq -r '.[]? | [(.id|tostring), .name] | @tsv' <<< "$existing")
upload_asset() {
local file=$1 name
name=$(basename -- "$file")
# Asset names are restricted to URL-safe characters above.
api_curl -F "attachment=@$file;filename=$name" "$assets_endpoint?name=$name" >/dev/null \
|| die "无法上传资产:$name"
}
for file in "${assets[@]}"; do upload_asset "$file"; done
upload_asset "$SUMS_FILE"
if (( SIGNATURE_GENERATED )); then
upload_asset "$SIG_FILE"
fi
log "published $TAG to $REPOSITORY"