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
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Build a self-contained release on the target Linux architecture. Native
|
||||
# addons (SQLite, Argon2 and image processing) must be installed on the same
|
||||
# architecture/libc as the artifact.
|
||||
ROOT=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
|
||||
VERSION=${1:-}
|
||||
OUT_DIR=${2:-$ROOT/release}
|
||||
[[ "$(uname -s)" == "Linux" ]] || { printf 'release builds must run on Linux; detected %s\n' "$(uname -s)" >&2; exit 2; }
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
VERSION=$(node -p 'require("./package.json").version')
|
||||
fi
|
||||
VERSION=${VERSION#v}
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || { printf 'invalid version: %s\n' "$VERSION" >&2; exit 2; }
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH=x64 ;;
|
||||
aarch64|arm64) ARCH=arm64 ;;
|
||||
armv7l|armv7|armhf) ARCH=armv7 ;;
|
||||
*) printf 'unsupported architecture: %s\n' "$(uname -m)" >&2; exit 2 ;;
|
||||
esac
|
||||
LIBC=glibc
|
||||
if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then LIBC=musl; fi
|
||||
|
||||
cd "$ROOT"
|
||||
pnpm build
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
mkdir -p "$stage/dist" "$stage/migrations" "$stage/bin" "$stage/scripts" "$stage/systemd" "$stage/runtime/bin"
|
||||
cp -a dist/. "$stage/dist/"
|
||||
cp -a migrations/. "$stage/migrations/"
|
||||
cp package.json pnpm-lock.yaml "$stage/"
|
||||
cp -a bin/. "$stage/bin/"
|
||||
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
|
||||
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
|
||||
node_path=$(command -v node)
|
||||
cp -L "$node_path" "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
|
||||
|
||||
# pnpm's default linker creates symlinks. A release archive is deliberately
|
||||
# symlink-free so the installer can reject traversal links deterministically.
|
||||
(cd "$stage" && pnpm install --prod --node-linker=hoisted --frozen-lockfile)
|
||||
find "$stage" -type l -delete
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
|
||||
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
|
||||
# Keep the sidecar useful when a caller builds more than one architecture into
|
||||
# the same directory. The publishing script recomputes this list immediately
|
||||
# before signing, so stale or hand-edited entries can never reach a Release.
|
||||
(cd "$OUT_DIR" && sha256sum ./*.tar.gz | sed 's#^\./##' | LC_ALL=C sort > SHA256SUMS)
|
||||
printf 'built %s\n' "$archive"
|
||||
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"
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
REQUEST_FILE="$DATA_DIR/update-request.json"
|
||||
CURRENT_LINK="$PREFIX/current"
|
||||
STATE_FILE="$PREFIX/.update-state"
|
||||
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
|
||||
HOST=${TALLYNOTE_HOST:-127.0.0.1}
|
||||
PORT=${TALLYNOTE_PORT:-3000}
|
||||
|
||||
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
|
||||
[[ -f "$REQUEST_FILE" || -f "$STATE_FILE" ]] || exit 0
|
||||
[[ -L "$CURRENT_LINK" ]] || die 'current release link is missing'
|
||||
|
||||
old_target=$(readlink -f -- "$CURRENT_LINK")
|
||||
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
|
||||
|
||||
was_active=0
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
|
||||
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
|
||||
restore_initial_service() {
|
||||
local result=$?
|
||||
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
|
||||
return "$result"
|
||||
}
|
||||
trap restore_initial_service EXIT
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
|
||||
job_id=''
|
||||
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
|
||||
job_id=$(sed -n 's/.*"jobId"[[:space:]]*:[[:space:]]*"\([0-9a-f-]*\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
|
||||
fi
|
||||
old_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$old_node" ]] || old_node=$(command -v node || true)
|
||||
switched=0
|
||||
handled=0
|
||||
|
||||
write_update_state() {
|
||||
local phase=$1 temporary
|
||||
temporary="$PREFIX/.update-state-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$temporary" && ! -L "$temporary" ]] || return 1
|
||||
printf 'job_id=%s\nold_target=%s\nphase=%s\n' "$job_id" "$old_target" "$phase" > "$temporary"
|
||||
chmod 600 "$temporary"
|
||||
mv -Tf -- "$temporary" "$STATE_FILE"
|
||||
}
|
||||
|
||||
clear_update_state() {
|
||||
[[ ! -L "$STATE_FILE" ]] || return 1
|
||||
rm -f -- "$STATE_FILE"
|
||||
}
|
||||
|
||||
finalize_state_job() {
|
||||
local node=$1 status=$2 state_job=$3
|
||||
[[ "$state_job" =~ ^[0-9a-f-]{36}$ && -n "$node" ]] || return 1
|
||||
[[ -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 1
|
||||
"$node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$state_job" --finalize-status "$status" --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
recover_stale_state() {
|
||||
local state_job state_old state_phase current_target recovery_node rollback_link state_mode state_uid
|
||||
[[ -f "$STATE_FILE" && ! -L "$STATE_FILE" ]] || die 'update state file is invalid'
|
||||
state_uid=$(stat -c '%u' "$STATE_FILE" 2>/dev/null || stat -f '%u' "$STATE_FILE")
|
||||
state_mode=$(stat -c '%a' "$STATE_FILE" 2>/dev/null || stat -f '%Lp' "$STATE_FILE")
|
||||
[[ "$state_uid" == 0 && "$state_mode" =~ ^[0-7]+$ && $((8#$state_mode & 077)) -eq 0 ]] || die 'update state file permissions are invalid'
|
||||
state_job=$(sed -n 's/^job_id=//p' "$STATE_FILE" | head -n 1)
|
||||
state_old=$(sed -n 's/^old_target=//p' "$STATE_FILE" | head -n 1)
|
||||
state_phase=$(sed -n 's/^phase=//p' "$STATE_FILE" | head -n 1)
|
||||
[[ "$state_job" =~ ^[0-9a-f-]{36}$ ]] || die 'update state job id is invalid'
|
||||
[[ "$state_old" == "$PREFIX/releases/"* && -d "$state_old" && ! -L "$state_old" ]] || die 'update state target is invalid'
|
||||
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
|
||||
if [[ "$state_phase" == finalizing && "$current_target" != "$state_old" ]]; then
|
||||
recovery_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
|
||||
for _ in 1 2 3; do
|
||||
if finalize_state_job "$recovery_node" completed "$state_job"; then
|
||||
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
|
||||
clear_update_state || true
|
||||
return 10
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
fi
|
||||
if [[ "$current_target" != "$state_old" ]]; then
|
||||
rollback_link="$PREFIX/.current-recovery-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
|
||||
ln -s -- "$state_old" "$rollback_link" || return 1
|
||||
if ! mv -Tf -- "$rollback_link" "$CURRENT_LINK"; then
|
||||
rm -f -- "$rollback_link" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
recovery_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$recovery_node" ]] || recovery_node=$(command -v node || true)
|
||||
if ! finalize_state_job "$recovery_node" failed "$state_job"; then
|
||||
# If the original queue is still present, retry it from the restored old
|
||||
# release; a crash before the CLI wrote its job row is recoverable this
|
||||
# way. Without a queue there is no safe operation to replay.
|
||||
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
|
||||
clear_update_state || true
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
rm -f -- "$REQUEST_FILE" 2>/dev/null || true
|
||||
clear_update_state || true
|
||||
return 11
|
||||
fi
|
||||
clear_update_state || true
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ -e "$STATE_FILE" ]]; then
|
||||
recovery_result=0
|
||||
set +e
|
||||
recover_stale_state
|
||||
recovery_result=$?
|
||||
set -e
|
||||
case "$recovery_result" in
|
||||
10) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 0 ;;
|
||||
11) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 1 ;;
|
||||
0) : ;;
|
||||
*) if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
[[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]] || exit 0
|
||||
|
||||
rollback_current() {
|
||||
local current_target rollback_link
|
||||
current_target=$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)
|
||||
[[ "$current_target" == "$old_target" ]] && return 0
|
||||
rollback_link="$PREFIX/.current-rollback-$$-${RANDOM}.tmp"
|
||||
[[ ! -e "$rollback_link" && ! -L "$rollback_link" ]] || return 1
|
||||
ln -s -- "$old_target" "$rollback_link" || return 1
|
||||
if ! mv -Tf -- "$rollback_link" "$CURRENT_LINK"; then
|
||||
rm -f -- "$rollback_link" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
switched=0
|
||||
}
|
||||
|
||||
finalize_failed_job() {
|
||||
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
|
||||
[[ -n "$old_node" && -f "$CURRENT_LINK/dist/server/cli/update.js" ]] || return 0
|
||||
"$old_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status failed --message '新版本健康检查失败,已恢复上一版本' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
finalize_completed_job() {
|
||||
[[ "$job_id" =~ ^[0-9a-f-]{36}$ ]] || return 0
|
||||
[[ -n "$final_node" ]] || return 1
|
||||
"$final_node" "$CURRENT_LINK/dist/server/cli/update.js" --finalize-job "$job_id" --finalize-status completed >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
|
||||
cleanup_after_update() {
|
||||
local result=$? rollback_ok=1
|
||||
if (( result != 0 && handled == 0 )); then
|
||||
if ! rollback_current; then rollback_ok=0; fi
|
||||
if (( rollback_ok == 1 && switched == 0 )); then
|
||||
if finalize_failed_job; then
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
clear_update_state || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if (( was_active )); then
|
||||
systemctl start "$SERVICE_NAME" || true
|
||||
else
|
||||
systemctl stop "$SERVICE_NAME" || true
|
||||
fi
|
||||
return "$result"
|
||||
}
|
||||
trap cleanup_after_update EXIT
|
||||
|
||||
write_update_state running || exit 1
|
||||
node_bin="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
|
||||
[[ -n "$node_bin" ]] || die 'node runtime not found'
|
||||
cli="$CURRENT_LINK/dist/server/cli/update.js"
|
||||
[[ -f "$cli" ]] || die 'update CLI not found in current release'
|
||||
|
||||
set +e
|
||||
"$node_bin" "$cli" --request-file "$REQUEST_FILE" --defer-completion
|
||||
update_result=$?
|
||||
set -e
|
||||
if (( update_result != 0 )); then
|
||||
exit "$update_result"
|
||||
fi
|
||||
|
||||
if [[ "$(readlink -f -- "$CURRENT_LINK" 2>/dev/null || true)" != "$old_target" ]]; then
|
||||
switched=1
|
||||
fi
|
||||
write_update_state health-check || exit 1
|
||||
|
||||
systemctl start "$SERVICE_NAME"
|
||||
healthy=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if (( healthy == 0 )); then
|
||||
systemctl stop "$SERVICE_NAME" || true
|
||||
rollback_current || die '无法恢复上一版本链接'
|
||||
if (( was_active )); then systemctl start "$SERVICE_NAME" || true; fi
|
||||
if ! finalize_failed_job; then
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
clear_update_state || true
|
||||
handled=1
|
||||
trap - EXIT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Preserve an operator's intentionally stopped service after validating the
|
||||
# new release in a temporary start.
|
||||
if (( was_active == 0 )); then
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
fi
|
||||
|
||||
write_update_state finalizing || exit 1
|
||||
final_node="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$final_node" ]] || final_node=$(command -v node || true)
|
||||
if [[ "$job_id" =~ ^[0-9a-f-]{36}$ ]]; then
|
||||
finalized=0
|
||||
for _ in 1 2 3; do
|
||||
if finalize_completed_job; then finalized=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
(( finalized == 1 )) || exit 1
|
||||
fi
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
clear_update_state || true
|
||||
handled=1
|
||||
trap - EXIT
|
||||
exit 0
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
# Manual updater for operators without using the web control. The same
|
||||
# verified TypeScript updater used by the systemd queue performs download,
|
||||
# extraction and atomic release switching.
|
||||
PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json}
|
||||
NODE=${TALLYNOTE_NODE:-}
|
||||
|
||||
die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; }
|
||||
version_sort_desc() {
|
||||
if sort -V </dev/null >/dev/null 2>&1; then
|
||||
sort -V -r
|
||||
return
|
||||
fi
|
||||
awk -F'[.-]' '{ printf "%020d.%020d.%020d.%s\t%s\n", $1, $2, $3, ($4 == "" ? "~" : $4), $0 }' \
|
||||
| sort -r | cut -f2-
|
||||
}
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
|
||||
|
||||
if [[ "${1:-}" == "--rollback" ]]; then
|
||||
current="$PREFIX/current"
|
||||
[[ -L "$current" ]] || die 'current release is not a symlink'
|
||||
current_target=$(readlink -f -- "$current")
|
||||
current_name=$(basename -- "$current_target")
|
||||
[[ "$current_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || die 'current release version is invalid'
|
||||
mapfile -t releases < <(
|
||||
find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%p\n' \
|
||||
| awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/' \
|
||||
| version_sort_desc
|
||||
)
|
||||
previous=''
|
||||
found_current=0
|
||||
for release in "${releases[@]}"; do
|
||||
release_target=$(readlink -f -- "$release")
|
||||
if [[ "$release_target" == "$current_target" ]]; then
|
||||
found_current=1
|
||||
continue
|
||||
fi
|
||||
if (( found_current )); then
|
||||
previous=$release
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ -n "$previous" && -d "$previous" ]] || die 'no previous release available'
|
||||
was_active=0
|
||||
if systemctl is-active --quiet tallynote.service; then was_active=1; fi
|
||||
if (( was_active )); then systemctl stop tallynote.service; fi
|
||||
tmp="$PREFIX/.current-rollback-$$-${RANDOM}"
|
||||
[[ ! -e "$tmp" && ! -L "$tmp" ]] || die 'rollback temporary path already exists'
|
||||
ln -s -- "$previous" "$tmp"
|
||||
mv -Tf -- "$tmp" "$current"
|
||||
if (( was_active )); then systemctl start tallynote.service; fi
|
||||
printf 'rolled back to %s\n' "$(basename -- "$previous")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -f "$REQUEST_FILE" ]] || die "no queued update request at $REQUEST_FILE"
|
||||
[[ -L "$PREFIX/current" ]] || die 'current release is not a symlink'
|
||||
|
||||
# Prefer the systemd runner, which performs the post-switch health check and
|
||||
# rollback. The fallback remains useful in development installations where the
|
||||
# privileged helper has not been installed yet.
|
||||
if [[ -x /usr/local/libexec/tallynote-update-runner ]]; then
|
||||
exec /usr/local/libexec/tallynote-update-runner
|
||||
fi
|
||||
if [[ -z "$NODE" ]]; then
|
||||
NODE="$PREFIX/current/runtime/bin/node"
|
||||
[[ -x "$NODE" ]] || NODE=$(command -v node || true)
|
||||
fi
|
||||
[[ -n "$NODE" ]] || die 'node runtime not found'
|
||||
CLI="$PREFIX/current/dist/server/cli/update.js"
|
||||
[[ -f "$CLI" ]] || die 'update CLI not found'
|
||||
|
||||
was_active=0
|
||||
if systemctl is-active --quiet tallynote.service; then was_active=1; fi
|
||||
if (( was_active )); then systemctl stop tallynote.service; fi
|
||||
set +e
|
||||
"$NODE" "$CLI" --request-file "$REQUEST_FILE"
|
||||
result=$?
|
||||
set -e
|
||||
if (( result != 0 )); then
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
if (( was_active )); then systemctl start tallynote.service || true; fi
|
||||
die 'update failed; the previous release remains active'
|
||||
fi
|
||||
if (( was_active )); then systemctl start tallynote.service || { rm -f -- "$REQUEST_FILE"; die 'updated service failed to start'; }; fi
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
printf 'update completed; inspect the update page for details\n'
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
|
||||
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'dry-run' <<<"$output"
|
||||
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-HTTPS URL to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d)
|
||||
cleanup_tmp() {
|
||||
if [[ -d "$tmp" ]]; then
|
||||
rm -r "$tmp" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_tmp EXIT
|
||||
cat >"$tmp/uname" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'i686\n'
|
||||
EOF
|
||||
chmod +x "$tmp/uname"
|
||||
if TALLYNOTE_UNAME_BIN="$tmp/uname" bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||
echo 'expected ia32 to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(uname -s)" != Linux ]]; then
|
||||
if bash "$root/scripts/build-release.sh" 1.0.0 /tmp/tallynote-installer-release-test >/dev/null 2>&1; then
|
||||
echo 'expected non-Linux release build to fail on this host' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Exercise installer helpers without mutating the host. Removing the final
|
||||
# main invocation lets this subprocess source the exact production code.
|
||||
installer_lib="$tmp/install-lib.sh"
|
||||
sed '$d' "$root/install.sh" > "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
mode_dir=$2
|
||||
owner_parent=$3
|
||||
set --
|
||||
source "$script"
|
||||
mkdir -p "$mode_dir"
|
||||
chmod 700 "$mode_dir"
|
||||
[[ "$(stat_mode_bits "$mode_dir")" == 448 ]]
|
||||
mkdir -p "$owner_parent"
|
||||
if (assert_path_chain "$owner_parent/child") >/dev/null 2>&1; then
|
||||
echo "expected non-root path parent to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
|
||||
|
||||
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
|
||||
# when the first value looks valid (systemd uses the later value).
|
||||
duplicate_env="$tmp/duplicate.env"
|
||||
printf '%s\n' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true' 'TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false' > "$duplicate_env"
|
||||
bash -c '
|
||||
script=$1
|
||||
env_file=$2
|
||||
set --
|
||||
source "$script"
|
||||
stat_uid() { printf "0"; }
|
||||
stat_mode_bits() { printf "384"; }
|
||||
if (validate_existing_env "$env_file") >/dev/null 2>&1; then
|
||||
echo "expected duplicate environment assignment to fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
' _ "$installer_lib" "$duplicate_env"
|
||||
|
||||
# A release archive is extracted under umask 077, then explicitly normalized
|
||||
# so the tallynote system user can traverse and execute the shipped tree.
|
||||
source_tmp="$tmp/source"
|
||||
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
|
||||
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
|
||||
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
|
||||
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
|
||||
archive_tmp="$tmp/release.tar.gz"
|
||||
tar -C "$source_tmp" -czf "$archive_tmp" .
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
destination=$3
|
||||
set --
|
||||
source "$script"
|
||||
safe_extract "$archive" "$destination"
|
||||
normalize_release_tree "$destination"
|
||||
[[ "$(stat_mode "$destination/dist")" == 755 ]]
|
||||
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
|
||||
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
||||
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
||||
|
||||
# Newline/control characters in release configuration must never become extra
|
||||
# systemd EnvironmentFile assignments.
|
||||
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||
echo 'expected control characters in release URL to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The publisher is safe to exercise on every host in dry-run mode. When an
|
||||
# OpenSSL build supports Ed25519, also verify the exact detached signature.
|
||||
publisher_tmp=$(mktemp -d)
|
||||
printf 'test-release' > "$publisher_tmp/tallynote-1.0.0-linux-x64-glibc.tar.gz"
|
||||
if "$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1; then
|
||||
test -s "$publisher_tmp/SHA256SUMS"
|
||||
else
|
||||
echo 'publisher dry-run failed' >&2
|
||||
exit 1
|
||||
fi
|
||||
openssl_test_bin=${TALLYNOTE_OPENSSL_BIN:-$(command -v openssl || true)}
|
||||
if [[ -n "$openssl_test_bin" ]] && "$openssl_test_bin" genpkey -algorithm ED25519 -out "$publisher_tmp/key" >/dev/null 2>&1; then
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
|
||||
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" --dry-run >/dev/null 2>&1
|
||||
"$openssl_test_bin" pkey -in "$publisher_tmp/key" -pubout -out "$publisher_tmp/pub" >/dev/null 2>&1
|
||||
"$openssl_test_bin" pkeyutl -verify -pubin -inkey "$publisher_tmp/pub" -rawin \
|
||||
-in "$publisher_tmp/SHA256SUMS" -sigfile "$publisher_tmp/SHA256SUMS.sig" >/dev/null 2>&1
|
||||
|
||||
# Exercise the 404 -> create -> assets -> upload flow with a local curl
|
||||
# shim. The shim records argv and verifies the secret only arrives through
|
||||
# the temporary curl config file, never as a process argument.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
fake_curl="$publisher_tmp/fake-curl"
|
||||
fake_trace="$publisher_tmp/curl-args"
|
||||
fake_config_seen="$publisher_tmp/curl-config-seen"
|
||||
cat > "$fake_curl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
out=''; format=''; method='GET'; url=''; previous=''; config=''
|
||||
for arg in "$@"; do
|
||||
case "$previous" in
|
||||
out) out=$arg; previous=''; continue ;;
|
||||
format) format=$arg; previous=''; continue ;;
|
||||
method) method=$arg; previous=''; continue ;;
|
||||
config) config=$arg; previous=''; continue ;;
|
||||
esac
|
||||
case "$arg" in
|
||||
-o) previous=out ;;
|
||||
-w) previous=format ;;
|
||||
-X) previous=method ;;
|
||||
--config) previous=config ;;
|
||||
-d*|-F*) method=POST ;;
|
||||
http://*|https://*) url=$arg ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$*" >> "$TALLYNOTE_FAKE_CURL_TRACE"
|
||||
[[ "$*" != *"$TALLYNOTE_FAKE_TOKEN"* ]] || { echo 'token leaked in curl argv' >&2; exit 91; }
|
||||
[[ -n "$config" && -s "$config" ]] || { echo 'curl auth config missing' >&2; exit 92; }
|
||||
grep -q "Authorization: token $TALLYNOTE_FAKE_TOKEN" "$config"
|
||||
printf '%s\n' seen > "$TALLYNOTE_FAKE_CURL_CONFIG_SEEN"
|
||||
code=200; body='{}'
|
||||
if [[ "$url" == */releases/tags/* ]]; then
|
||||
if [[ ! -f "$TALLYNOTE_FAKE_RELEASE_CREATED" ]]; then code=404; body='{}'; else code=200; body='{"id":42}'; fi
|
||||
elif [[ "$url" == */releases && "$method" == POST ]]; then
|
||||
printf '%s' created > "$TALLYNOTE_FAKE_RELEASE_CREATED"
|
||||
code=201; body='{"id":42}'
|
||||
elif [[ "$url" == */assets && "$method" == GET ]]; then
|
||||
code=200; body='[]'
|
||||
elif [[ "$url" == */assets\?name=* ]]; then
|
||||
code=201; body='{"id":1}'
|
||||
elif [[ "$method" == DELETE ]]; then
|
||||
code=204; body=''
|
||||
fi
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s' "$body" > "$out"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
if [[ "$format" == '%{http_code}' ]]; then
|
||||
printf '%s' "$code"
|
||||
fi
|
||||
EOF
|
||||
chmod 700 "$fake_curl"
|
||||
TALLYNOTE_FAKE_CURL_TRACE="$fake_trace" TALLYNOTE_FAKE_CURL_CONFIG_SEEN="$fake_config_seen" \
|
||||
TALLYNOTE_FAKE_RELEASE_CREATED="$publisher_tmp/release-created" TALLYNOTE_FAKE_TOKEN='secret-token' \
|
||||
TALLYNOTE_CURL_BIN="$fake_curl" GITEA_API_URL='https://gitea.example/api/v1' \
|
||||
GITHUB_REPOSITORY='awaioi/TallyNote' GITEA_TOKEN='secret-token' \
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE="$publisher_tmp/key" \
|
||||
TALLYNOTE_OPENSSL_BIN="$openssl_test_bin" \
|
||||
"$root/scripts/publish-gitea-release.sh" v1.0.0 "$publisher_tmp" >/dev/null
|
||||
if grep -q 'secret-token' "$fake_trace"; then
|
||||
echo 'token leaked in curl argv' >&2
|
||||
exit 1
|
||||
fi
|
||||
test -s "$fake_config_seen"
|
||||
fi
|
||||
fi
|
||||
if [[ -d "$publisher_tmp" ]]; then
|
||||
rm -r "$publisher_tmp" 2>/dev/null || true
|
||||
fi
|
||||
printf '%s\n' 'installer shell tests passed'
|
||||
Reference in New Issue
Block a user