feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user