ci: add Jenkins multi-architecture release pipeline

This commit is contained in:
2026-08-11 13:34:58 +08:00
parent 19d358b312
commit f2e55e46c4
3 changed files with 490 additions and 271 deletions
Vendored
+282 -266
View File
@@ -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)
}
}
}
+19 -5
View File
@@ -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 \
+189
View File
@@ -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