feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
BASE_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*//p' "$ROOT_DIR/fnos/manifest" | head -n 1 | tr -d '\r')
|
||||
PACKAGE_VERSION="${PACKAGE_VERSION:-$BASE_VERSION}"
|
||||
case "$PACKAGE_VERSION" in
|
||||
''|*[!0-9.]*|.*|*..*|*.) printf 'invalid fnOS package version: %s\n' "$PACKAGE_VERSION" >&2; exit 1 ;;
|
||||
esac
|
||||
[ "$(printf '%s' "$PACKAGE_VERSION" | awk -F. '{ print NF }')" -eq 3 ] || {
|
||||
printf 'fnOS package version must contain exactly three numeric components: %s\n' "$PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
OUTPUT_DIR="${1:-${ROOT_DIR}/artifacts/fnos}"
|
||||
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-1785168000}"
|
||||
export VITE_BUILD_TIMESTAMP="${VITE_BUILD_TIMESTAMP:-$((SOURCE_DATE_EPOCH * 1000))}"
|
||||
BUILD_TMP_ROOT="${DYSYNC_BUILD_TMPDIR:-${DYSYNC_TMPDIR:-${TMPDIR:-/tmp}}}"
|
||||
mkdir -p "${BUILD_TMP_ROOT}"
|
||||
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/dysync-fnos-build.XXXXXX")
|
||||
trap 'rm -rf -- "${WORK_DIR}"' EXIT
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
(cd "${ROOT_DIR}/app" && npm run build)
|
||||
dotnet restore "${ROOT_DIR}/dy.net.csproj" -r linux-x64
|
||||
dotnet publish "${ROOT_DIR}/dy.net.csproj" -c Release -r linux-x64 --self-contained true --no-restore \
|
||||
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=false -o "${WORK_DIR}/publish"
|
||||
|
||||
mkdir -p "${WORK_DIR}/payload/server" "${WORK_DIR}/payload/ui/images" "${WORK_DIR}/package"
|
||||
cp -a "${WORK_DIR}/publish/." "${WORK_DIR}/payload/server/"
|
||||
cp "${ROOT_DIR}/appsettings.json" "${WORK_DIR}/payload/server/appsettings.json"
|
||||
sed -i -E "s/(\"tagName\"[[:space:]]*:[[:space:]]*)\"[^\"]*\"/\1\"${PACKAGE_VERSION}\"/; s/(\"fnVersion\"[[:space:]]*:[[:space:]]*)\"[^\"]*\"/\1\"${PACKAGE_VERSION}\"/" \
|
||||
"${WORK_DIR}/payload/server/appsettings.json"
|
||||
rm -rf -- "${WORK_DIR}/payload/server/db" "${WORK_DIR}/payload/server/logs"
|
||||
cp "${ROOT_DIR}/fnos/ui/config" "${WORK_DIR}/payload/ui/config"
|
||||
cp "${ROOT_DIR}/app/dist/logo.png" "${WORK_DIR}/payload/ui/images/icon_64.png"
|
||||
cp "${ROOT_DIR}/app/dist/logo.png" "${WORK_DIR}/payload/ui/images/icon_256.png"
|
||||
|
||||
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \
|
||||
-czf "${WORK_DIR}/package/app.tgz" -C "${WORK_DIR}/payload" server ui
|
||||
cp -a "${ROOT_DIR}/fnos/cmd" "${ROOT_DIR}/fnos/config" "${WORK_DIR}/package/"
|
||||
cp "${ROOT_DIR}/app/dist/logo.png" "${WORK_DIR}/package/ICON.PNG"
|
||||
cp "${ROOT_DIR}/app/dist/logo.png" "${WORK_DIR}/package/ICON_256.PNG"
|
||||
checksum=$(md5sum "${WORK_DIR}/package/app.tgz" | cut -d' ' -f1)
|
||||
sed -e "s/@CHECKSUM@/${checksum}/" \
|
||||
-e "s/^version[[:space:]]*=.*/version = ${PACKAGE_VERSION}/" \
|
||||
"${ROOT_DIR}/fnos/manifest" > "${WORK_DIR}/package/manifest"
|
||||
chmod 0755 "${WORK_DIR}/package/cmd/"*
|
||||
|
||||
PACKAGE="${OUTPUT_DIR}/dy.net-${PACKAGE_VERSION}-x86_64.fpk"
|
||||
tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \
|
||||
-czf "${PACKAGE}" -C "${WORK_DIR}/package" app.tgz cmd config ICON.PNG ICON_256.PNG manifest
|
||||
(cd "${OUTPUT_DIR}" && sha256sum "$(basename "${PACKAGE}")" > "$(basename "${PACKAGE}").sha256")
|
||||
DYSYNC_VERIFY_TMPDIR="${DYSYNC_VERIFY_TMPDIR:-${BUILD_TMP_ROOT}}" \
|
||||
"${ROOT_DIR}/scripts/verify-fnos-package.sh" "${PACKAGE}" "$PACKAGE_VERSION"
|
||||
printf 'Built %s\n' "${PACKAGE}"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${JENKINS_NODE_PASSWORD:?JENKINS_NODE_PASSWORD is required}"
|
||||
|
||||
printf '%s\n' "$JENKINS_NODE_PASSWORD" | sudo -S -p '' -v
|
||||
|
||||
sudo -n -- \
|
||||
/usr/bin/env "DOCKER_CONFIG=${DOCKER_CONFIG:-}" \
|
||||
/usr/bin/docker "$@"
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: smoke-fnos-package.sh package.fpk [temporary-directory]}
|
||||
SMOKE_TMP_ROOT="${2:-${DYSYNC_SMOKE_TMPDIR:-${DYSYNC_TMPDIR:-${TMPDIR:-/tmp}}}}"
|
||||
mkdir -p "${SMOKE_TMP_ROOT}"
|
||||
SMOKE_TMP_ROOT=$(cd "${SMOKE_TMP_ROOT}" && pwd)
|
||||
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/dysync-fnos-smoke.XXXXXX")
|
||||
trap 'if [ -x "${WORK_DIR}/cmd/main" ]; then "${WORK_DIR}/cmd/main" stop 2>/dev/null || true; fi; rm -rf -- "${WORK_DIR}"' EXIT
|
||||
tar -xzf "${PACKAGE}" -C "${WORK_DIR}"
|
||||
tar -xzf "${WORK_DIR}/app.tgz" -C "${WORK_DIR}"
|
||||
export TRIM_APPDEST="${WORK_DIR}"
|
||||
export TRIM_APPDEST_VOL="${WORK_DIR}/volume"
|
||||
export TRIM_PKGVAR="${WORK_DIR}/var"
|
||||
export DYSYNC_LISTEN_URL=http://127.0.0.1:19101
|
||||
# Package users on fnOS commonly have no login home. The smoke test intentionally
|
||||
# reproduces that environment so a bundle-extraction regression cannot pass locally.
|
||||
unset HOME XDG_CACHE_HOME
|
||||
if ! "${WORK_DIR}/cmd/main" start; then
|
||||
test ! -f "${TRIM_PKGVAR}/info.log" || cat "${TRIM_PKGVAR}/info.log"
|
||||
exit 1
|
||||
fi
|
||||
APP_PID=$(cat "${TRIM_PKGVAR}/app.pid")
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:19101/" >/dev/null; then
|
||||
printf 'fnOS packaged application started successfully\n'
|
||||
exit 0
|
||||
fi
|
||||
if ! kill -0 "${APP_PID}" 2>/dev/null; then cat "${TRIM_PKGVAR}/info.log"; exit 1; fi
|
||||
sleep 0.5
|
||||
done
|
||||
cat "${TRIM_PKGVAR}/info.log"
|
||||
exit 1
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
FILE=${1:?usage: upload-openlist-artifact.sh file [remote-directory]}
|
||||
REMOTE_DIR=${2:-/yidongpan/构建产物/liverecorder}
|
||||
OPENLIST_BASE_URL=${OPENLIST_BASE_URL:-https://openlist.nxsir.cn}
|
||||
OPENLIST_USERNAME=${OPENLIST_USERNAME:?OPENLIST_USERNAME is required}
|
||||
OPENLIST_PASSWORD=${OPENLIST_PASSWORD:?OPENLIST_PASSWORD is required}
|
||||
OPENLIST_UPLOAD_ATTEMPTS=${OPENLIST_UPLOAD_ATTEMPTS:-6}
|
||||
|
||||
test -f "$FILE" || { printf 'artifact does not exist: %s\n' "$FILE" >&2; exit 1; }
|
||||
for required_command in curl python3 stat basename sleep; do
|
||||
command -v "$required_command" >/dev/null 2>&1 || {
|
||||
printf 'required upload command is missing: %s\n' "$required_command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
OPENLIST_BASE_URL=${OPENLIST_BASE_URL%/}
|
||||
REMOTE_DIR="/${REMOTE_DIR#/}"
|
||||
REMOTE_DIR=${REMOTE_DIR%/}
|
||||
FILE_NAME=$(basename -- "$FILE")
|
||||
BUILD_MARKER=${BUILD_TAG:-${BUILD_ID:-$$}}
|
||||
BUILD_MARKER=$(printf '%s' "$BUILD_MARKER" | tr -c 'A-Za-z0-9._-' '-')
|
||||
TEMP_NAME=".${FILE_NAME}.uploading-${BUILD_MARKER}"
|
||||
TEMP_PATH="$REMOTE_DIR/$TEMP_NAME"
|
||||
FINAL_PATH="$REMOTE_DIR/$FILE_NAME"
|
||||
LOCAL_SIZE=$(stat -c '%s' "$FILE")
|
||||
TOKEN=
|
||||
|
||||
json_string() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$1"
|
||||
}
|
||||
|
||||
envelope_code() {
|
||||
python3 -c 'import json,sys
|
||||
try:
|
||||
value=json.load(sys.stdin)
|
||||
print(value.get("code", -1))
|
||||
except Exception:
|
||||
print(-1)'
|
||||
}
|
||||
|
||||
envelope_message() {
|
||||
python3 -c 'import json,sys
|
||||
try:
|
||||
value=json.load(sys.stdin)
|
||||
print(value.get("message", "unknown OpenList response"))
|
||||
except Exception:
|
||||
print("invalid OpenList response")'
|
||||
}
|
||||
|
||||
login() {
|
||||
local payload response code
|
||||
payload=$(printf '{"username":%s,"password":%s}' \
|
||||
"$(json_string "$OPENLIST_USERNAME")" \
|
||||
"$(json_string "$OPENLIST_PASSWORD")")
|
||||
response=$(curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 120 \
|
||||
--request POST "$OPENLIST_BASE_URL/api/auth/login" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "$payload") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList login failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
TOKEN=$(printf '%s' "$response" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("token", ""))')
|
||||
[ -n "$TOKEN" ]
|
||||
}
|
||||
|
||||
post_json() {
|
||||
local endpoint=$1 payload=$2
|
||||
curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 180 \
|
||||
--request POST "$OPENLIST_BASE_URL$endpoint" \
|
||||
--header "Authorization: $TOKEN" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "$payload"
|
||||
}
|
||||
|
||||
remote_size() {
|
||||
local path=$1 response code
|
||||
response=$(post_json /api/fs/get "{\"path\":$(json_string "$path"),\"password\":\"\"}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || return 1
|
||||
printf '%s' "$response" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("size", -1))'
|
||||
}
|
||||
|
||||
ensure_remote_directory() {
|
||||
local current= segment response code
|
||||
while IFS= read -r segment; do
|
||||
[ -n "$segment" ] || continue
|
||||
current="$current/$segment"
|
||||
response=$(post_json /api/fs/list "{\"path\":$(json_string "$current"),\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}" || true)
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
if [ "$code" = "200" ]; then
|
||||
continue
|
||||
fi
|
||||
response=$(post_json /api/fs/mkdir "{\"path\":$(json_string "$current")}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList mkdir failed for %s: %s\n' "$current" "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
done < <(python3 -c 'import sys; print("\n".join(part for part in sys.argv[1].split("/") if part))' "$REMOTE_DIR")
|
||||
}
|
||||
|
||||
remove_temporary_file() {
|
||||
local response
|
||||
[ -n "$TOKEN" ] || login >/dev/null 2>&1 || return 0
|
||||
response=$(post_json /api/fs/remove "{\"dir\":$(json_string "$REMOTE_DIR"),\"names\":[$(json_string "$TEMP_NAME")]}" 2>/dev/null || true)
|
||||
[ "$(printf '%s' "$response" | envelope_code)" = "200" ] || true
|
||||
}
|
||||
|
||||
publish_temporary_file() {
|
||||
local response code
|
||||
response=$(post_json /api/fs/rename "{\"path\":$(json_string "$TEMP_PATH"),\"name\":$(json_string "$FILE_NAME")}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList rename failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
upload_once() {
|
||||
local encoded_path response code uploaded_size
|
||||
encoded_path=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TEMP_PATH")
|
||||
response=$(curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 3600 --speed-limit 1024 --speed-time 120 \
|
||||
--request PUT "$OPENLIST_BASE_URL/api/fs/put" \
|
||||
--header "Authorization: $TOKEN" \
|
||||
--header "File-Path: $encoded_path" \
|
||||
--header 'As-Task: false' \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header 'Expect:' \
|
||||
--data-binary "@$FILE") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList upload failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
uploaded_size=$(remote_size "$TEMP_PATH") || return 1
|
||||
[ "$uploaded_size" = "$LOCAL_SIZE" ] || {
|
||||
printf 'OpenList size mismatch for temporary upload: local=%s remote=%s\n' "$LOCAL_SIZE" "$uploaded_size" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
printf 'Uploading %s (%s bytes) to OpenList %s\n' "$FILE_NAME" "$LOCAL_SIZE" "$REMOTE_DIR"
|
||||
|
||||
for attempt in $(seq 1 "$OPENLIST_UPLOAD_ATTEMPTS"); do
|
||||
TOKEN=
|
||||
upload_ready=1
|
||||
if login && ensure_remote_directory; then
|
||||
existing_size=$(remote_size "$FINAL_PATH" 2>/dev/null || true)
|
||||
if [ "$existing_size" = "$LOCAL_SIZE" ]; then
|
||||
printf 'OpenList artifact already exists with matching size: %s\n' "$FINAL_PATH"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$existing_size" ]; then
|
||||
remove_response=$(post_json /api/fs/remove "{\"dir\":$(json_string "$REMOTE_DIR"),\"names\":[$(json_string "$FILE_NAME")]}" || true)
|
||||
if [ "$(printf '%s' "$remove_response" | envelope_code)" != "200" ]; then
|
||||
printf 'Unable to remove mismatched OpenList artifact before retry: %s\n' "$FINAL_PATH" >&2
|
||||
upload_ready=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$upload_ready" = "1" ] && upload_once && publish_temporary_file; then
|
||||
final_size=$(remote_size "$FINAL_PATH") || final_size=-1
|
||||
if [ "$final_size" = "$LOCAL_SIZE" ]; then
|
||||
printf 'OpenList upload verified: %s (%s bytes)\n' "$FINAL_PATH" "$final_size"
|
||||
exit 0
|
||||
fi
|
||||
printf 'OpenList final size mismatch: local=%s remote=%s\n' "$LOCAL_SIZE" "$final_size" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$OPENLIST_UPLOAD_ATTEMPTS" ]; then
|
||||
delay=$((5 * (1 << (attempt - 1))))
|
||||
[ "$delay" -le 120 ] || delay=120
|
||||
printf 'OpenList upload attempt %s/%s failed; retrying in %ss\n' "$attempt" "$OPENLIST_UPLOAD_ATTEMPTS" "$delay" >&2
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
remove_temporary_file
|
||||
printf 'OpenList upload failed after %s attempts: %s\n' "$OPENLIST_UPLOAD_ATTEMPTS" "$FILE_NAME" >&2
|
||||
exit 1
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk [expected-version]}
|
||||
EXPECTED_VERSION=${2:-0.2.25}
|
||||
VERIFY_TMP_ROOT="${DYSYNC_VERIFY_TMPDIR:-${DYSYNC_TMPDIR:-${TMPDIR:-/tmp}}}"
|
||||
mkdir -p "${VERIFY_TMP_ROOT}"
|
||||
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/dysync-fnos-verify.XXXXXX")
|
||||
trap 'rm -rf -- "${WORK_DIR}"' EXIT
|
||||
tar -xzf "${PACKAGE}" -C "${WORK_DIR}"
|
||||
grep -q "^version[[:space:]]*= ${EXPECTED_VERSION}$" "${WORK_DIR}/manifest"
|
||||
test -x "${WORK_DIR}/cmd/main"
|
||||
test -x "${WORK_DIR}/cmd/upgrade_init"
|
||||
tar -tzf "${WORK_DIR}/app.tgz" > "${WORK_DIR}/app-files.txt"
|
||||
grep -q '^server/dy.net$' "${WORK_DIR}/app-files.txt"
|
||||
grep -q '^server/appsettings.json$' "${WORK_DIR}/app-files.txt"
|
||||
grep -q '^server/libe_sqlite3.so$' "${WORK_DIR}/app-files.txt"
|
||||
escaped_version=$(printf '%s' "$EXPECTED_VERSION" | sed 's/\./\\./g')
|
||||
tar -xOzf "${WORK_DIR}/app.tgz" server/appsettings.json | \
|
||||
grep -Eq "\"fnVersion\"[[:space:]]*:[[:space:]]*\"${escaped_version}\""
|
||||
if grep -Eq '^server/(db|keys|upgrade-backups)/' "${WORK_DIR}/app-files.txt"; then
|
||||
echo 'Persistent data must not be included in app.tgz' >&2
|
||||
exit 1
|
||||
fi
|
||||
expected=$(grep '^checksum' "${WORK_DIR}/manifest" | sed 's/.*=[[:space:]]*//')
|
||||
actual=$(md5sum "${WORK_DIR}/app.tgz" | cut -d' ' -f1)
|
||||
test "${expected}" = "${actual}"
|
||||
printf 'fnOS package verified: %s\n' "${PACKAGE}"
|
||||
Reference in New Issue
Block a user