Files
TallyNote/scripts/publish-gitea-release.sh
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

250 lines
10 KiB
Bash
Executable File
Raw 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, signed release to a Gitea-compatible API. The script
# is intentionally separate from the workflow so operators can dry-run the
# exact same asset selection locally without ever exposing a signing 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=''
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
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
EOF
}
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
log() { printf 'release publisher: %s\n' "$*"; }
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'
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
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"
assets+=("$file")
done
(( ${#assets[@]} > 0 )) || die 'no .tar.gz 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
}
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
else
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
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=''
fi
log "tag: $TAG"
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
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'
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
write_auth_config
unset TOKEN
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")
elif [[ "$status" == 404 ]]; then
body=$(jq -cn --arg tag "$TAG" --arg name "$TAG" --arg body "TallyNote $TAG" '{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
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; 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"
upload_asset "$SIG_FILE"
log "published $TAG to $REPOSITORY"