Files
ERP/tests/release-scripts.test.sh
T
Qiufeng 70dd8e1d6b
Signed Release / release (push) Failing after 20s
test: cover installer asset download
2026-08-04 08:37:52 +08:00

529 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
PASS=0
FAIL=0
run_test() {
local name="$1"
shift
if ("$@"); then
printf 'PASS %s\n' "$name"
PASS=$((PASS + 1))
else
printf 'FAIL %s\n' "$name"
FAIL=$((FAIL + 1))
fi
}
checksum_file() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file"
else
shasum -a 256 "$file"
fi
}
test_erp_run_preserves_java_option_arguments() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-run-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state"
: > "$tmp/current/app/kaidi-erp.jar"
: > "$tmp/state/install.lock"
: > "$tmp/-Dpattern=expanded"
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [[ "${1:-}" == "-version" ]]; then' \
' printf '\''openjdk version "17.0.12"\n'\'' >&2' \
' exit 0' \
'fi' \
'printf '\''%s\n'\'' "$@"' > "$tmp/java"
chmod +x "$tmp/java"
{
printf 'ERP_JAVA_BIN=%q\n' "$tmp/java"
printf 'ERP_JAVA_OPTS=%q\n' '-Dpattern=* -Xmx1g'
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
} > "$tmp/erp.env"
local output
output="$(cd "$tmp" && ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-run")" || return 1
grep -Fqx -- '-Dpattern=*' <<< "$output" || return 1
grep -Fqx -- '-Xmx1g' <<< "$output" || return 1
grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output"
)
prepare_signed_archive() {
local tmp="$1" unsafe="${2:-0}"
mkdir -p "$tmp/package/kaidi-erp-1.2.3/app"
printf 'jar\n' > "$tmp/package/kaidi-erp-1.2.3/app/kaidi-erp.jar"
if [[ "$unsafe" == "1" ]]; then
ln -s /etc/passwd "$tmp/package/kaidi-erp-1.2.3/unsafe-link"
fi
tar -czf "$tmp/kaidi-erp-1.2.3.tar.gz" -C "$tmp/package" kaidi-erp-1.2.3
printf 'installer jar\n' > "$tmp/kaidi-erp-installer-1.2.3.jar"
(cd "$tmp" && checksum_file kaidi-erp-1.2.3.tar.gz && checksum_file kaidi-erp-installer-1.2.3.jar) > "$tmp/SHA256SUMS"
openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \
-in "$tmp/SHA256SUMS" -out "$tmp/SHA256SUMS.sig"
}
test_installer_verifies_signed_safe_archive() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-signature-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
prepare_signed_archive "$tmp" || return 1
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
TMP_DIR="$tmp"
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar
INSTALLER_PATH="$tmp/$INSTALLER_NAME"
verify_release
)
test_installer_rejects_archive_symlinks() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-unsafe-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
prepare_signed_archive "$tmp" 1 || return 1
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
TMP_DIR="$tmp"
PUBLIC_KEY="$(<"$tmp/public.pem")"
ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz
ARCHIVE_PATH="$tmp/$ARCHIVE_NAME"
INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar
INSTALLER_PATH="$tmp/$INSTALLER_NAME"
! (verify_release) >/dev/null 2>&1
)
test_stable_channel_rejects_prerelease_tag() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-prerelease-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
printf 'OA_UPDATE_GITEA_BASE_URL=https://example.test\n' > "$tmp/erp.env"
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" source "$PROJECT_ROOT/distribution/bin/erp-update"
trap 'rm -rf "$tmp"' EXIT
printf '%s\n' \
'{"tag_name":"v2.0.0-rc.1","draft":false,"prerelease":false,"assets":[' \
'{"name":"kaidi-erp-2.0.0-rc.1.tar.gz","browser_download_url":"https://example.test/app"},' \
'{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums"},' \
'{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig"}]}' \
> "$tmp/release.json"
CHANNEL=stable
! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1
)
prepare_update_release_archive() {
local tmp="$1" version="$2"
local root="$tmp/package/kaidi-erp-$version"
mkdir -p "$root/app" "$root/bin" "$root/config" "$tmp/assets"
printf 'mock jar %s\n' "$version" > "$root/app/kaidi-erp.jar"
cp "$PROJECT_ROOT/distribution/bin/erp-run" "$root/bin/erp-run"
cp "$PROJECT_ROOT/distribution/bin/erp-update" "$root/bin/erp-update"
chmod +x "$root/bin/erp-run" "$root/bin/erp-update"
printf '%s\n' "$version" > "$root/VERSION"
printf '{"version":"%s","database":"postgresql","rollbackCompatible":true}\n' \
"$version" > "$root/manifest.json"
printf 'installer jar %s\n' "$version" > "$tmp/assets/kaidi-erp-installer-$version.jar"
local archive="kaidi-erp-$version.tar.gz"
tar -czf "$tmp/assets/$archive" -C "$tmp/package" "kaidi-erp-$version"
(cd "$tmp/assets" && checksum_file "$archive" && checksum_file "kaidi-erp-installer-$version.jar") > "$tmp/assets/SHA256SUMS"
: > "$tmp/assets/SHA256SUMS.sig"
}
test_installer_downloads_and_installs_signed_release() (
local version=3.0.0 tmp server_pid=""
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-install-e2e.XXXXXX")" || return 1
cleanup_install_scenario() {
[[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup_install_scenario EXIT
prepare_update_release_archive "$tmp" "$version" || return 1
openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1
openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1
openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \
-in "$tmp/assets/SHA256SUMS" -out "$tmp/assets/SHA256SUMS.sig" || return 1
python3 - "$tmp" "$version" <<'PY' > "$tmp/server.log" 2>&1 &
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root, version = sys.argv[1:]
assets = os.path.join(root, "assets")
archive = f"kaidi-erp-{version}.tar.gz"
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, status, body, content_type="application/octet-stream"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
base = f"http://127.0.0.1:{self.server.server_port}/assets"
payload = {
"tag_name": "v" + version,
"draft": False,
"prerelease": False,
"assets": [
{"name": archive, "browser_download_url": base + "/" + archive},
{"name": f"kaidi-erp-installer-{version}.jar", "browser_download_url": base + f"/kaidi-erp-installer-{version}.jar"},
{"name": "SHA256SUMS", "browser_download_url": base + "/SHA256SUMS"},
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/SHA256SUMS.sig"},
],
}
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
return
if self.path.startswith("/assets/"):
name = self.path.removeprefix("/assets/")
if name not in {archive, f"kaidi-erp-installer-{version}.jar", "SHA256SUMS", "SHA256SUMS.sig"}:
self.send_bytes(404, b"not found", "text/plain")
return
with open(os.path.join(assets, name), "rb") as handle:
self.send_bytes(200, handle.read())
return
self.send_bytes(404, b"not found", "text/plain")
def log_message(self, *_):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle:
handle.write(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
local attempt=0
while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/server.port" ]] || return 1
local port
port="$(<"$tmp/server.port")"
source "$PROJECT_ROOT/install.sh"
trap cleanup_install_scenario EXIT
GITEA_BASE_URL="http://127.0.0.1:$port"
REPOSITORY=awaioi/ERP
ALLOW_INSECURE=1
INSTALL_ROOT="$tmp/install"
ERP_CONFIG_ROOT="$tmp/config"
ERP_STATE_ROOT="$tmp/state"
ERP_LOG_ROOT="$tmp/log"
NO_SERVICE=0
PUBLIC_KEY="$(<"$tmp/public.pem")"
detect_platform() { PLATFORM=darwin; }
check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; }
start_service() { return 0; }
wait_for_installer() { return 0; }
main > "$tmp/install.log" 2>&1 || return 1
[[ "$(readlink "$INSTALL_ROOT/current")" == "releases/$version" ]] || return 1
[[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1
(
source "$ERP_CONFIG_ROOT/erp.env"
[[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]]
[[ "$OA_UPDATE_ENABLED" == "true" ]]
[[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]]
[[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]]
[[ -z "${OA_DB_URL:-}" ]]
)
)
run_update_scenario() (
local mode="$1" version=2.0.0
local tmp server_pid="" supervisor_pid=""
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-update-e2e.XXXXXX")" || return 1
cleanup_scenario() {
[[ -z "$supervisor_pid" ]] || kill "$supervisor_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true
[[ -z "$supervisor_pid" ]] || wait "$supervisor_pid" 2>/dev/null || true
[[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true
rm -rf "$tmp"
}
trap cleanup_scenario EXIT
prepare_update_release_archive "$tmp" "$version" || return 1
mkdir -p "$tmp/releases/1.0.0" "$tmp/run" "$tmp/state"
printf '1.0.0\n' > "$tmp/releases/1.0.0/VERSION"
ln -s releases/1.0.0 "$tmp/current"
python3 - "$tmp" "$mode" <<'PY' > "$tmp/server.log" 2>&1 &
import json
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root, mode = sys.argv[1:]
assets = os.path.join(root, "assets")
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, status, body, content_type="application/octet-stream"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/v1/repos/awaioi/ERP/releases/latest":
base = f"http://127.0.0.1:{self.server.server_port}"
payload = {
"tag_name": "v2.0.0",
"draft": False,
"prerelease": False,
"assets": [
{"name": "kaidi-erp-2.0.0.tar.gz", "browser_download_url": base + "/assets/kaidi-erp-2.0.0.tar.gz"},
{"name": "SHA256SUMS", "browser_download_url": base + "/assets/SHA256SUMS"},
{"name": "SHA256SUMS.sig", "browser_download_url": base + "/assets/SHA256SUMS.sig"},
],
}
self.send_bytes(200, json.dumps(payload).encode(), "application/json")
return
if self.path == "/health":
current = os.path.basename(os.path.realpath(os.path.join(root, "current")))
healthy = current == "2.0.0" if mode == "success" else current == "1.0.0"
self.send_bytes(200 if healthy else 503, b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}', "application/json")
return
if self.path.startswith("/assets/"):
name = self.path.removeprefix("/assets/")
if name not in {"kaidi-erp-2.0.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"}:
self.send_bytes(404, b"not found", "text/plain")
return
with open(os.path.join(assets, name), "rb") as handle:
self.send_bytes(200, handle.read())
return
self.send_bytes(404, b"not found", "text/plain")
def log_message(self, *_):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle:
handle.write(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
local attempt=0
while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/server.port" ]] || return 1
local port
port="$(<"$tmp/server.port")"
(
child=""
trap '[[ -z "$child" ]] || kill "$child" 2>/dev/null || true' EXIT
trap 'exit 0' TERM INT
while :; do
sleep 300 &
child=$!
printf '%s\n' "$child" > "$tmp/run/app.pid"
wait "$child" 2>/dev/null || true
child=""
sleep 0.05
done
) &
supervisor_pid=$!
attempt=0
while [[ ! -s "$tmp/run/app.pid" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/run/app.pid" ]] || return 1
{
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json"
printf 'OA_UPDATE_GITEA_BASE_URL=http://127.0.0.1:%s\n' "$port"
printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n'
printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n'
printf 'ERP_HEALTH_URL=http://127.0.0.1:%s/health\n' "$port"
printf 'ERP_UPDATE_REQUIRE_SIGNATURE=false\n'
printf 'ERP_UPDATE_BACKUP_MODE=none\n'
printf 'ERP_UPDATE_HEALTH_TIMEOUT_SECONDS=3\n'
printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n'
} > "$tmp/erp.env"
local status=0 phase current
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install "$version" \
> "$tmp/update.log" 2>&1 || status=$?
phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["phase"])' "$tmp/state/update.json")" \
|| return 1
current="$(readlink "$tmp/current")"
if [[ "$mode" == "success" ]]; then
[[ "$status" == "0" && "$phase" == "SUCCEEDED" && "$current" == "releases/2.0.0" ]]
else
[[ "$status" != "0" && "$phase" == "ROLLED_BACK" && "$current" == "releases/1.0.0" ]]
fi
)
test_update_helper_installs_healthy_release() (
run_update_scenario success
)
test_update_helper_rolls_back_unhealthy_release() (
run_update_scenario rollback
)
test_update_lock_rejects_concurrent_process() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-lock-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin" "$tmp/run" "$tmp/state"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$$" > "$MOCK_CURL_PID_FILE"' \
'trap '\''exit 1'\'' TERM INT' \
'sleep 30' \
'exit 1' > "$tmp/bin/curl"
chmod +x "$tmp/bin/curl"
{
printf 'ERP_RUN_DIR=%q\n' "$tmp/run"
printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json"
printf 'OA_UPDATE_GITEA_BASE_URL=%q\n' 'http://127.0.0.1:1'
printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n'
printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n'
} > "$tmp/erp.env"
PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl.pid" \
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install > "$tmp/first.log" 2>&1 &
local first=$! attempt=0
while [[ ! -s "$tmp/run/update.lock" && "$attempt" -lt 100 ]]; do
sleep 0.02
attempt=$((attempt + 1))
done
[[ -s "$tmp/run/update.lock" ]] || { kill "$first" 2>/dev/null || true; return 1; }
local output status=0 rejected=0
output="$(PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl-2.pid" \
ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \
"$PROJECT_ROOT/distribution/bin/erp-update" install 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'已有更新任务正在执行'* ]] && rejected=1
[[ ! -r "$tmp/curl.pid" ]] || kill "$(<"$tmp/curl.pid")" 2>/dev/null || true
kill "$first" 2>/dev/null || true
wait "$first" 2>/dev/null || true
[[ "$rejected" == "1" ]]
)
test_installer_moves_database_setup_to_web_wizard() (
local output status=0
output="$(bash "$PROJECT_ROOT/install.sh" --db-mode existing 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'unknown option: --db-mode'* ]]
)
test_installer_requires_explicit_gitea_url() (
local output status=0
output="$(ERP_GITEA_BASE_URL= bash -c '
root="$1"
set --
source "$root/install.sh"
GITEA_BASE_URL=
main
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
)
test_linux_service_preflight_requires_systemd() (
local tmp output status=0
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-systemd-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin"
source "$PROJECT_ROOT/install.sh"
PLATFORM=linux
NO_SERVICE=0
output="$(PATH="$tmp/bin" validate_service_manager 2>&1)" || status=$?
[[ "$status" -ne 0 && "$output" == *'systemd'* ]] || return 1
NO_SERVICE=1 PATH="$tmp/bin" validate_service_manager
)
test_no_service_install_disables_online_update() (
local tmp
tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1
trap 'rm -rf "$tmp"' EXIT
source "$PROJECT_ROOT/install.sh"
trap 'rm -rf "$tmp"' EXIT
CONFIG_ROOT="$tmp/config"
STATE_ROOT="$tmp/state"
INSTALL_ROOT="$tmp/install"
PLATFORM=darwin
NO_SERVICE=1
VERSION=1.0.0
JAVA_BIN="$(command -v java)"
GITEA_BASE_URL=https://example.test
REPOSITORY=awaioi/ERP
mkdir -p "$CONFIG_ROOT" "$STATE_ROOT" "$INSTALL_ROOT"
write_bootstrap_configuration || return 1
grep -Fqx 'OA_UPDATE_ENABLED=false' "$CONFIG_ROOT/erp.env"
)
test_release_workflow_uses_scoped_job_token() (
local workflow="$PROJECT_ROOT/.gitea/workflows/release.yml"
grep -Fqx ' contents: read' "$workflow"
grep -Fqx ' releases: write' "$workflow"
grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow"
grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow"
! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow"
! grep -Fq 'vars.GITEA_BASE_URL' "$workflow"
)
test_installer_runs_when_piped_to_bash() (
local output status=0
output="$(ERP_GITEA_BASE_URL= bash -s -- --no-service < "$PROJECT_ROOT/install.sh" 2>&1)" || status=$?
[[ "$status" -ne 0 ]]
[[ "$output" == *'[ERP Install] ERROR:'* ]]
[[ "$output" != *'BASH_SOURCE'* ]]
)
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive
run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks
run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag
run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release
run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release
run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release
run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process
run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard
run_test 'installer requires an explicit Gitea URL' test_installer_requires_explicit_gitea_url
run_test 'Linux production install requires systemd' test_linux_service_preflight_requires_systemd
run_test 'no-service install disables online update' test_no_service_install_disables_online_update
run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token
run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]