From f2e55e46c4cd91aeb189197367bc274000fea08f Mon Sep 17 00:00:00 2001 From: nanxun Date: Tue, 11 Aug 2026 13:34:18 +0800 Subject: [PATCH] ci: add Jenkins multi-architecture release pipeline --- Jenkinsfile | 548 ++++++++++++++-------------- scripts/build-fnos-package.sh | 24 +- scripts/upload-openlist-artifact.sh | 189 ++++++++++ 3 files changed, 490 insertions(+), 271 deletions(-) create mode 100755 scripts/upload-openlist-artifact.sh diff --git a/Jenkinsfile b/Jenkinsfile index dfa1960..7587092 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,325 +1,341 @@ pipeline { - agent { label '\u6784\u5efa\u673a1' } + agent { label '构建机1' } options { timestamps() disableConcurrentBuilds() skipDefaultCheckout(true) + timeout(time: 180, unit: 'MINUTES') + buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20')) } environment { - REGISTRY_URL = 'reg.nxsir.cn' - API_IMAGE_NAME = 'live_recorder/app-api' - WEB_IMAGE_NAME = 'live_recorder/app-web' - IMAGE_TAG = "${env.BUILD_ID}" - DOCKER_CREDS = 'harbor_key' - TARGET_PLATFORMS = 'linux/amd64,linux/arm64' - BUILDER_NAME = 'liverecorder-buildx' - WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine' - WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine' - HTTP_PROXY_URL = 'http://192.168.5.200:7890' - // Tsinghua/daocloud/mcr hosts are reachable directly from the builder — routing them - // through the 7890 proxy caused intermittent '502 Bad Gateway' during apt-get install. - NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,mcr.microsoft.com,docker.m.daocloud.io' + REGISTRY_URL = 'reg.nxsir.cn' + API_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-api' + WEB_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-web' + HARBOR_CREDENTIALS = 'harbor_key' + OPENLIST_CREDENTIALS = 'openlist_key' + OPENLIST_BASE_URL = 'https://openlist.nxsir.cn' + OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/liverecorder' - GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git' - GIT_BRANCH = 'main' - GIT_CREDS = '' + FNPACK_BIN = '/home/nanxunai/.local/bin/fnpack' + FNPACK_SHA256 = '54b97fa7b70968c4d05c79840f5daeff508957d0bb2062fdb0376d00d9615c93' + DOTNET = '/home/nanxunai/.local/bin/dotnet' - API_IMAGE_TAGGED = "${REGISTRY_URL}/${API_IMAGE_NAME}:${IMAGE_TAG}" - API_IMAGE_LATEST = "${REGISTRY_URL}/${API_IMAGE_NAME}:latest" - WEB_IMAGE_TAGGED = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:${IMAGE_TAG}" - WEB_IMAGE_LATEST = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:latest" - API_CACHE_IMAGE = "${REGISTRY_URL}/${API_IMAGE_NAME}:buildcache" - WEB_CACHE_IMAGE = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:buildcache" + BUILDER_NAME = "liverecorder-${BUILD_NUMBER}" + BUILDKIT_IMAGE = 'docker.m.daocloud.io/moby/buildkit:buildx-stable-1' + BINFMT_IMAGE = 'docker.m.daocloud.io/tonistiigi/binfmt:latest' + ARM_TEST_IMAGE = 'docker.m.daocloud.io/library/alpine:latest' + NODE_BUILD_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine' + NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine' + + HTTP_PROXY_URL = 'http://192.168.5.200:7890' + NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,openlist.nxsir.cn,mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,mcr.microsoft.com,docker.m.daocloud.io' + + CI_ROOT = "${WORKSPACE}/.ci" + DOCKER_CONFIG = "${WORKSPACE}/.ci/docker" + NUGET_PACKAGES = "${WORKSPACE}/.ci/nuget-packages" + NUGET_HTTP_CACHE_PATH = "${WORKSPACE}/.ci/nuget-http" + NUGET_PLUGINS_CACHE_PATH = "${WORKSPACE}/.ci/nuget-plugins" + DOTNET_CLI_HOME = "${WORKSPACE}/.ci/dotnet-home" + NPM_CONFIG_CACHE = "${WORKSPACE}/.ci/npm-cache" + LIVERECORDER_NUGET_FEED = "${WORKSPACE}/.ci/nuget-feed" + LIVERECORDER_BUILD_TMPDIR = "${WORKSPACE}/.ci/fnos-tmp" + LIVERECORDER_VERIFY_TMPDIR = "${WORKSPACE}/.ci/fnos-verify" + LIVERECORDER_SKIP_NPM_CI = '1' } stages { stage('Checkout') { steps { - script { - def userRemoteConfig = [url: env.GIT_REPO_URL] - if (env.GIT_CREDS?.trim()) { - userRemoteConfig.credentialsId = env.GIT_CREDS.trim() - } - - checkout([ - $class: 'GitSCM', - branches: [[name: "*/${env.GIT_BRANCH}"]], - userRemoteConfigs: [userRemoteConfig] - ]) - } + deleteDir() + checkout scm } } - stage('Prepare Buildx') { + stage('Metadata And Preflight') { steps { - sh """ - set -e + script { + env.APP_VERSION = sh( + script: "sed -n 's/^version=//p' fnos/manifest | head -n 1", + returnStdout: true + ).trim() + env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim() + if (!env.APP_VERSION || !env.SHORT_SHA) { + error('Unable to resolve fnOS version or Git commit.') + } + env.IMMUTABLE_TAG = "${env.APP_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}" + env.FPK_BASENAME = "liverecorder-${env.IMMUTABLE_TAG}-x86_64.fpk" + env.FPK_PATH = "${env.WORKSPACE}/artifacts/ci/${env.FPK_BASENAME}" + currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.APP_VERSION} ${env.SHORT_SHA}" + currentBuild.description = "${env.IMMUTABLE_TAG}" + } + + sh ''' + set -euo pipefail + test "$(uname -m)" = x86_64 + for command_name in git sudo docker curl python3 npm node sha256sum stat tar; do + command -v "$command_name" >/dev/null + done + test -x "$DOTNET" + test -x "$FNPACK_BIN" + printf '%s %s\n' "$FNPACK_SHA256" "$FNPACK_BIN" | sha256sum --check --status + sudo -n true + sudo docker version >/dev/null sudo docker buildx version - # Register ARM64 binfmt. Docker Hub is unreachable from this builder, - # so try daocloud mirror first; fall back to apt-installed qemu-user-static. - if [ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ] && [ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64-static ]; then - echo 'Registering ARM64 emulation (binfmt not present)...' - if ! sudo docker run --privileged --rm docker.m.daocloud.io/tonistiigi/binfmt --install arm64 2>/dev/null; then - echo 'daocloud mirror failed, falling back to qemu-user-static via apt...' - sudo apt-get update -qq - sudo apt-get install -y -qq qemu-user-static - fi - else - echo 'ARM64 binfmt already registered, skipping.' + available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }') + if [ "$available_kb" -lt 2097152 ]; then + echo "At least 2 GiB free workspace disk is required; available KiB: $available_kb" >&2 + exit 1 fi - # Verify emulation works before proceeding - sudo docker run --rm --platform linux/arm64 docker.m.daocloud.io/library/alpine:latest uname -m - - # Use the default docker builder (docker driver) — not a separate - # docker-container instance — so we don't need to pull moby/buildkit - # from unreachable Docker Hub. - sudo docker buildx use default 2>/dev/null || true - sudo docker buildx inspect --bootstrap >/dev/null - """ + mkdir -p \ + "$DOCKER_CONFIG" \ + "$NUGET_PACKAGES" \ + "$NUGET_HTTP_CACHE_PATH" \ + "$NUGET_PLUGINS_CACHE_PATH" \ + "$DOTNET_CLI_HOME" \ + "$NPM_CONFIG_CACHE" \ + "$LIVERECORDER_NUGET_FEED" \ + "$LIVERECORDER_BUILD_TMPDIR" \ + "$LIVERECORDER_VERIFY_TMPDIR" \ + "$(dirname -- "$FPK_PATH")" + chmod 700 "$DOCKER_CONFIG" + ''' } } - stage('Build And Push API Image') { + stage('Restore And Test') { steps { - withCredentials([ - usernamePassword( - credentialsId: "${DOCKER_CREDS}", - usernameVariable: 'DOCKER_USERNAME', - passwordVariable: 'DOCKER_PASSWORD' - ) - ]) { - sh """ - set -e + sh ''' + set -euo pipefail + npm ci --prefix frontend + npm run build --prefix frontend + "$DOTNET" restore LiveRecorder.sln --disable-parallel + "$DOTNET" test LiveRecorder.sln -c Release --no-restore --logger 'console;verbosity=normal' /maxcpucount:1 + ''' + } + } - run_with_heartbeat() { - log_file="\$1" - build_label="\$2" - shift 2 - rm -f "\$log_file" - : > "\$log_file" - "\$@" >"\$log_file" 2>&1 & - cmd_pid=\$! - cmd_status=0 - last_size=0 - while kill -0 "\$cmd_pid" >/dev/null 2>&1; do - echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)" - if [ -f "\$log_file" ]; then - current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0) - if [ "\$current_size" -gt "\$last_size" ]; then - start_byte=\$((last_size + 1)) - tail -c +"\$start_byte" "\$log_file" || true - last_size=\$current_size - fi - fi - sleep 20 - done - wait "\$cmd_pid" || cmd_status=\$? - if [ -f "\$log_file" ]; then - current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0) - if [ "\$current_size" -gt "\$last_size" ]; then - start_byte=\$((last_size + 1)) - tail -c +"\$start_byte" "\$log_file" || true - fi - fi - if [ "\$cmd_status" -ne 0 ]; then - echo "[heartbeat] \${build_label} failed with exit code \$cmd_status" - fi - return "\$cmd_status" - } + stage('Build fnOS x64') { + steps { + sh ''' + set -euo pipefail + FNPACK="$FNPACK_BIN" ./scripts/build-fnos-package.sh "$FPK_PATH" + ./scripts/verify-fnos-package.sh "$FPK_PATH" + test -s "$FPK_PATH.sha256" + ''' + } + } - # ── Build each platform separately, push with docker CLI, - # then assemble a multi-arch manifest. - # buildx --push does not forward auth reliably on this - # builder; per-platform --load + docker push + manifest - # bypasses that entirely. - for PLATFORM in linux/amd64 linux/arm64; do - ARCH_TAG="\${PLATFORM##*/}" # amd64 / arm64 - PLAT_REF="${API_IMAGE_TAGGED}-\${ARCH_TAG}" - PLAT_LATEST="${API_IMAGE_LATEST}-\${ARCH_TAG}" + stage('Prepare Docker Builder') { + steps { + sh ''' + set -euo pipefail + if ! sudo docker run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then + sudo docker run --privileged --rm "$BINFMT_IMAGE" --install arm64 + fi + sudo docker run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m | grep -q aarch64 - echo '' - echo "=== Building API for \$PLATFORM -> \$PLAT_REF ===" - run_with_heartbeat /tmp/live-recorder-api-\${ARCH_TAG}.log "API \${ARCH_TAG} build" \ - sudo docker buildx build \ - --platform "\$PLATFORM" \ - --network host \ - --progress=plain \ - --provenance=false \ - --no-cache \ - --build-arg HTTP_PROXY=${HTTP_PROXY_URL} \ - --build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \ - --build-arg NO_PROXY=${NO_PROXY_HOSTS} \ - --build-arg http_proxy=${HTTP_PROXY_URL} \ - --build-arg https_proxy=${HTTP_PROXY_URL} \ - --build-arg no_proxy=${NO_PROXY_HOSTS} \ - -f src/LiveRecorder.WebApi/Dockerfile \ - -t "\$PLAT_REF" \ - -t "\$PLAT_LATEST" \ - --load \ - . + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx create \ + --name "$BUILDER_NAME" \ + --driver docker-container \ + --driver-opt "image=$BUILDKIT_IMAGE" \ + --driver-opt network=host \ + --use + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx inspect "$BUILDER_NAME" --bootstrap + ''' + } + } - echo "=== Pushing \$PLAT_REF ===" - # Write Harbor auth directly to config.json (bypass docker - # login entirely) — then push with fresh baked-in credentials. - AUTH_B64=\$(printf '%s:%s' "\$DOCKER_USERNAME" "\$DOCKER_PASSWORD" | base64 -w0) - printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY_URL}" "\$AUTH_B64" | sudo tee /root/.docker/config.json >/dev/null - sudo docker push "\$PLAT_REF" 2>&1 | tail -5 - sudo docker push "\$PLAT_LATEST" 2>&1 | tail -5 - - # Free disk space before the next platform build - sudo docker rmi "\$PLAT_REF" "\$PLAT_LATEST" 2>/dev/null || true - done - - echo '' - echo "=== Creating multi-arch manifest: ${API_IMAGE_TAGGED} ===" - AUTH_B64=\$(printf '%s:%s' "\$DOCKER_USERNAME" "\$DOCKER_PASSWORD" | base64 -w0) && printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY_URL}" "\$AUTH_B64" | sudo tee /root/.docker/config.json >/dev/null - sudo docker manifest create "${API_IMAGE_TAGGED}" \ - "${API_IMAGE_TAGGED}-amd64" \ - "${API_IMAGE_TAGGED}-arm64" - sudo docker manifest create --amend "${API_IMAGE_LATEST}" \ - "${API_IMAGE_LATEST}-amd64" \ - "${API_IMAGE_LATEST}-arm64" - - echo "=== Pushing manifests ===" - sudo docker manifest push "${API_IMAGE_TAGGED}" 2>&1 | tail -3 - sudo docker manifest push "${API_IMAGE_LATEST}" 2>&1 | tail -3 - - # (Optionally clean up per-platform tags from registry, but harmless to leave them) - echo '' - echo "API multi-arch images pushed successfully:" - echo " ${API_IMAGE_TAGGED}" - echo " ${API_IMAGE_LATEST}" - """ + stage('Prepare Harbor Authentication') { + steps { + withCredentials([usernamePassword( + credentialsId: "${HARBOR_CREDENTIALS}", + usernameVariable: 'HARBOR_USERNAME', + passwordVariable: 'HARBOR_PASSWORD' + )]) { + sh ''' + set -euo pipefail + auth=$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0) + printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY_URL" "$auth" >"$DOCKER_CONFIG/config.json" + chmod 600 "$DOCKER_CONFIG/config.json" + ''' } } } - stage('Build And Push Web Image') { + stage('Build And Push amd64') { steps { - withCredentials([ - usernamePassword( - credentialsId: "${DOCKER_CREDS}", - usernameVariable: 'DOCKER_USERNAME', - passwordVariable: 'DOCKER_PASSWORD' - ) - ]) { - sh """ - set -e + sh ''' + set -euo pipefail + arch=amd64 + platform=linux/amd64 + api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch" + web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch" - run_with_heartbeat() { - log_file="\$1" - build_label="\$2" - shift 2 - rm -f "\$log_file" - : > "\$log_file" - "\$@" >"\$log_file" 2>&1 & - cmd_pid=\$! - cmd_status=0 - last_size=0 - while kill -0 "\$cmd_pid" >/dev/null 2>&1; do - echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)" - if [ -f "\$log_file" ]; then - current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0) - if [ "\$current_size" -gt "\$last_size" ]; then - start_byte=\$((last_size + 1)) - tail -c +"\$start_byte" "\$log_file" || true - last_size=\$current_size - fi - fi - sleep 20 - done - wait "\$cmd_pid" || cmd_status=\$? - if [ -f "\$log_file" ]; then - current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0) - if [ "\$current_size" -gt "\$last_size" ]; then - start_byte=\$((last_size + 1)) - tail -c +"\$start_byte" "\$log_file" || true - fi - fi - if [ "\$cmd_status" -ne 0 ]; then - echo "[heartbeat] \${build_label} failed with exit code \$cmd_status" - fi - return "\$cmd_status" - } + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx build \ + --builder "$BUILDER_NAME" --platform "$platform" --network host \ + --progress=plain --provenance=false --no-cache \ + --build-arg HTTP_PROXY="$HTTP_PROXY_URL" \ + --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \ + --build-arg NO_PROXY="$NO_PROXY_HOSTS" \ + --build-arg http_proxy="$HTTP_PROXY_URL" \ + --build-arg https_proxy="$HTTP_PROXY_URL" \ + --build-arg no_proxy="$NO_PROXY_HOSTS" \ + -f src/LiveRecorder.WebApi/Dockerfile -t "$api_ref" --load . + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker push "$api_ref" + sudo docker rmi "$api_ref" >/dev/null - for PLATFORM in linux/amd64 linux/arm64; do - ARCH_TAG="\${PLATFORM##*/}" - PLAT_REF="${WEB_IMAGE_TAGGED}-\${ARCH_TAG}" - PLAT_LATEST="${WEB_IMAGE_LATEST}-\${ARCH_TAG}" + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx build \ + --builder "$BUILDER_NAME" --platform "$platform" --network host \ + --progress=plain --provenance=false --no-cache \ + --build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_IMAGE" \ + --build-arg HTTP_PROXY="$HTTP_PROXY_URL" \ + --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \ + --build-arg NO_PROXY="$NO_PROXY_HOSTS" \ + --build-arg http_proxy="$HTTP_PROXY_URL" \ + --build-arg https_proxy="$HTTP_PROXY_URL" \ + --build-arg no_proxy="$NO_PROXY_HOSTS" \ + --build-arg VITE_API_BASE_URL=/api \ + -f frontend/Dockerfile -t "$web_ref" --load frontend + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker push "$web_ref" + sudo docker rmi "$web_ref" >/dev/null + ''' + } + } - echo '' - echo "=== Building Web for \$PLATFORM -> \$PLAT_REF ===" - run_with_heartbeat /tmp/live-recorder-web-\${ARCH_TAG}.log "Web \${ARCH_TAG} build" \ - sudo docker buildx build \ - --platform "\$PLATFORM" \ - --network host \ - --progress=plain \ - --provenance=false \ - --no-cache \ - --build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \ - --build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \ - --build-arg HTTP_PROXY=${HTTP_PROXY_URL} \ - --build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \ - --build-arg NO_PROXY=${NO_PROXY_HOSTS} \ - --build-arg http_proxy=${HTTP_PROXY_URL} \ - --build-arg https_proxy=${HTTP_PROXY_URL} \ - --build-arg no_proxy=${NO_PROXY_HOSTS} \ - -f frontend/Dockerfile \ - --build-arg VITE_API_BASE_URL=/api \ - -t "\$PLAT_REF" \ - -t "\$PLAT_LATEST" \ - --load \ - frontend + stage('Build And Push arm64') { + steps { + sh ''' + set -euo pipefail + arch=arm64 + platform=linux/arm64 + api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch" + web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch" - echo "=== Pushing \$PLAT_REF ===" - AUTH_B64=\$(printf '%s:%s' "\$DOCKER_USERNAME" "\$DOCKER_PASSWORD" | base64 -w0) && printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY_URL}" "\$AUTH_B64" | sudo tee /root/.docker/config.json >/dev/null - sudo docker push "\$PLAT_REF" 2>&1 | tail -3 - sudo docker push "\$PLAT_LATEST" 2>&1 | tail -3 - sudo docker rmi "\$PLAT_REF" "\$PLAT_LATEST" 2>/dev/null || true + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx build \ + --builder "$BUILDER_NAME" --platform "$platform" --network host \ + --progress=plain --provenance=false --no-cache \ + --build-arg HTTP_PROXY="$HTTP_PROXY_URL" \ + --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \ + --build-arg NO_PROXY="$NO_PROXY_HOSTS" \ + --build-arg http_proxy="$HTTP_PROXY_URL" \ + --build-arg https_proxy="$HTTP_PROXY_URL" \ + --build-arg no_proxy="$NO_PROXY_HOSTS" \ + -f src/LiveRecorder.WebApi/Dockerfile -t "$api_ref" --load . + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker push "$api_ref" + sudo docker rmi "$api_ref" >/dev/null + + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx build \ + --builder "$BUILDER_NAME" --platform "$platform" --network host \ + --progress=plain --provenance=false --no-cache \ + --build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_IMAGE" \ + --build-arg HTTP_PROXY="$HTTP_PROXY_URL" \ + --build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \ + --build-arg NO_PROXY="$NO_PROXY_HOSTS" \ + --build-arg http_proxy="$HTTP_PROXY_URL" \ + --build-arg https_proxy="$HTTP_PROXY_URL" \ + --build-arg no_proxy="$NO_PROXY_HOSTS" \ + --build-arg VITE_API_BASE_URL=/api \ + -f frontend/Dockerfile -t "$web_ref" --load frontend + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker push "$web_ref" + sudo docker rmi "$web_ref" >/dev/null + ''' + } + } + + stage('Publish Multi-arch Manifests') { + steps { + sh ''' + set -euo pipefail + for repository in "$API_IMAGE_REPO" "$WEB_IMAGE_REPO"; do + immutable_ref="$repository:$IMMUTABLE_TAG" + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx imagetools create \ + --tag "$immutable_ref" \ + "$immutable_ref-amd64" "$immutable_ref-arm64" + + manifest=$(sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx imagetools inspect --raw "$immutable_ref") + printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"amd64"' + printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"arm64"' done - echo '' - echo "=== Creating multi-arch manifest: ${WEB_IMAGE_TAGGED} ===" - AUTH_B64=\$(printf '%s:%s' "\$DOCKER_USERNAME" "\$DOCKER_PASSWORD" | base64 -w0) && printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY_URL}" "\$AUTH_B64" | sudo tee /root/.docker/config.json >/dev/null - sudo docker manifest create "${WEB_IMAGE_TAGGED}" \ - "${WEB_IMAGE_TAGGED}-amd64" \ - "${WEB_IMAGE_TAGGED}-arm64" - sudo docker manifest create --amend "${WEB_IMAGE_LATEST}" \ - "${WEB_IMAGE_LATEST}-amd64" \ - "${WEB_IMAGE_LATEST}-arm64" + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx imagetools create \ + --tag "$API_IMAGE_REPO:$APP_VERSION" --tag "$API_IMAGE_REPO:latest" \ + "$API_IMAGE_REPO:$IMMUTABLE_TAG" + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx imagetools create \ + --tag "$WEB_IMAGE_REPO:$APP_VERSION" --tag "$WEB_IMAGE_REPO:latest" \ + "$WEB_IMAGE_REPO:$IMMUTABLE_TAG" + ''' + } + } - echo "=== Pushing manifests ===" - sudo docker manifest push "${WEB_IMAGE_TAGGED}" 2>&1 | tail -3 - sudo docker manifest push "${WEB_IMAGE_LATEST}" 2>&1 | tail -3 - - echo '' - echo "Web multi-arch images pushed successfully:" - echo " ${WEB_IMAGE_TAGGED}" - echo " ${WEB_IMAGE_LATEST}" - """ + stage('Upload fnOS Artifact') { + steps { + withCredentials([usernamePassword( + credentialsId: "${OPENLIST_CREDENTIALS}", + usernameVariable: 'OPENLIST_USERNAME', + passwordVariable: 'OPENLIST_PASSWORD' + )]) { + sh ''' + set -euo pipefail + ./scripts/upload-openlist-artifact.sh "$FPK_PATH" "$OPENLIST_REMOTE_DIR" + ./scripts/upload-openlist-artifact.sh "$FPK_PATH.sha256" "$OPENLIST_REMOTE_DIR" + ''' } } } + + stage('Build Summary') { + steps { + sh ''' + set -euo pipefail + printf '%s\n' \ + "Commit: $(git rev-parse HEAD)" \ + "fnOS: $FPK_BASENAME" \ + "Checksum: $(awk '{print $1}' "$FPK_PATH.sha256")" \ + "API amd64: $API_IMAGE_REPO:$IMMUTABLE_TAG-amd64" \ + "API arm64: $API_IMAGE_REPO:$IMMUTABLE_TAG-arm64" \ + "API multi-arch: $API_IMAGE_REPO:$IMMUTABLE_TAG" \ + "Web amd64: $WEB_IMAGE_REPO:$IMMUTABLE_TAG-amd64" \ + "Web arm64: $WEB_IMAGE_REPO:$IMMUTABLE_TAG-arm64" \ + "Web multi-arch: $WEB_IMAGE_REPO:$IMMUTABLE_TAG" \ + "OpenList: $OPENLIST_BASE_URL$OPENLIST_REMOTE_DIR/$FPK_BASENAME" + ''' + } + } } post { success { - echo "Pipeline completed successfully." + echo "LiveRecorder ${env.IMMUTABLE_TAG} published successfully." } failure { - echo "Pipeline failed. Please check the build log." + echo 'LiveRecorder pipeline failed; stable Docker tags were only updated if all architecture builds succeeded.' } always { - sh """ + sh ''' set +e - sudo docker logout ${REGISTRY_URL} >/dev/null 2>&1 || true - true - """ - deleteDir() + if [ -n "${BUILDER_NAME:-}" ]; then + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker buildx rm -f "$BUILDER_NAME" >/dev/null 2>&1 || true + fi + if [ -n "${IMMUTABLE_TAG:-}" ]; then + for repository in "$API_IMAGE_REPO" "$WEB_IMAGE_REPO"; do + for suffix in amd64 arm64; do + sudo docker rmi "$repository:$IMMUTABLE_TAG-$suffix" >/dev/null 2>&1 || true + done + sudo env DOCKER_CONFIG="$DOCKER_CONFIG" docker manifest rm "$repository:$IMMUTABLE_TAG" >/dev/null 2>&1 || true + done + fi + if [ -f "$DOCKER_CONFIG/config.json" ]; then + : >"$DOCKER_CONFIG/config.json" + fi + ''' + cleanWs(deleteDirs: true, notFailBuild: true) } } } diff --git a/scripts/build-fnos-package.sh b/scripts/build-fnos-package.sh index 3628c3e..9aad10a 100755 --- a/scripts/build-fnos-package.sh +++ b/scripts/build-fnos-package.sh @@ -5,7 +5,13 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) VERSION=1.2.19 OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${VERSION}-x86_64.fpk}" WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd) -DOTNET_BIN="${DOTNET:-$WORKSPACE_CACHE/.dotnet8/dotnet}" +if [ -n "${DOTNET:-}" ]; then + DOTNET_BIN="$DOTNET" +elif [ -x "$WORKSPACE_CACHE/.dotnet8/dotnet" ]; then + DOTNET_BIN="$WORKSPACE_CACHE/.dotnet8/dotnet" +else + DOTNET_BIN=$(command -v dotnet 2>/dev/null || true) +fi NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}" NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}" DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}" @@ -30,8 +36,10 @@ for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha2 exit 1 } done -test -x "$DOTNET_BIN" || { printf 'missing .NET SDK: %s\n' "$DOTNET_BIN" >&2; exit 1; } -test -d "$NUGET_FEED" || { printf 'missing offline NuGet feed: %s\n' "$NUGET_FEED" >&2; exit 1; } +test -n "$DOTNET_BIN" && test -x "$DOTNET_BIN" || { + printf 'missing .NET SDK; set DOTNET or install dotnet in PATH\n' >&2 + exit 1 +} mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")" WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX") @@ -48,14 +56,20 @@ mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \ cp -a "$ROOT_DIR/fnos/." "$STAGE/" printf 'Building frontend...\n' +if [ "${LIVERECORDER_SKIP_NPM_CI:-0}" != "1" ]; then + npm ci --prefix "$ROOT_DIR/frontend" +fi npm run build --prefix "$ROOT_DIR/frontend" printf 'Publishing self-contained .NET application...\n' export NUGET_PACKAGES DOTNET_CLI_HOME +RESTORE_SOURCES=(--source "https://api.nuget.org/v3/index.json") +if [ -d "$NUGET_FEED" ]; then + RESTORE_SOURCES=(--source "$NUGET_FEED" "${RESTORE_SOURCES[@]}") +fi "$DOTNET_BIN" restore "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \ -r linux-x64 \ - --source "$NUGET_FEED" \ - --source "https://api.nuget.org/v3/index.json" \ + "${RESTORE_SOURCES[@]}" \ --disable-parallel "$DOTNET_BIN" publish "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \ -c Release \ diff --git a/scripts/upload-openlist-artifact.sh b/scripts/upload-openlist-artifact.sh new file mode 100755 index 0000000..8dfe4e9 --- /dev/null +++ b/scripts/upload-openlist-artifact.sh @@ -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