Files
TallyNote/scripts/publish-gitea-release.sh
T
Qiufeng 4434acf697
TallyNote release / linux-x64 (push) Successful in 6m24s
release: simplify unsigned installation
2026-09-01 00:10:35 +08:00

261 lines
11 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 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=''
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' "$*"; }
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'
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
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
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
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"