#!/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/sbin:/usr/bin:/sbin:/bin:/usr/local/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=() update_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 update_assets+=("$file") else full_assets+=("$file") fi done assets=("${full_assets[@]}") if ((${#update_assets[@]})); then assets+=("${update_assets[@]}"); fi (( ${#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"