diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 3b0fb1ba..11c0f2f3 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -247,7 +247,7 @@ jobs: version: "8.3.1" - uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -336,7 +336,7 @@ jobs: version: "8.3.1" - uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -394,7 +394,7 @@ jobs: SOURCE_COMMIT=${{ github.sha }} network: host # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x context: . file: ./docker/Dockerfile.debian push: true @@ -423,7 +423,7 @@ jobs: version: "8.3.1" - uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -495,7 +495,7 @@ jobs: version: "8.3.1" - uses: actions/setup-node@v6 with: - node-version: "20" + node-version: "24" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml @@ -538,7 +538,7 @@ jobs: SOURCE_COMMIT=${{ github.sha }} network: host # Keep s390x on Debian: npm can hang under QEMU with Alpine (nodejs/docker-node#1973). - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x context: . file: ./docker/Dockerfile.debian310 push: true @@ -662,7 +662,8 @@ jobs: # Both channels publish packages from this run's immutable image digests. if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }} needs: [build-alpine, build-debian] - runs-on: ubuntu-22.04 + # Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU. + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }} timeout-minutes: 90 permissions: contents: read @@ -679,13 +680,13 @@ jobs: - { platform: linux/s390x, libc: musl, name: s390x-musl } - { platform: linux/386, libc: musl, name: ia32-musl } - { platform: linux/amd64, libc: glibc, name: x64-glibc } - - { platform: linux/arm/v7, libc: glibc, name: armv7-glibc } - { platform: linux/arm64, libc: glibc, name: arm64-glibc } - { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc } - { platform: linux/s390x, libc: glibc, name: s390x-glibc } steps: - uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v4 + if: ${{ matrix.platform != 'linux/arm64' }} with: cache-image: false - uses: docker/setup-buildx-action@v4 @@ -721,7 +722,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' - uses: pnpm/action-setup@v6 with: version: '8.3.1' diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index facabafd..cf7a2535 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -69,7 +69,7 @@ jobs: - image: debian file: docker/Dockerfile.debian cache: debian - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x - image: alpine-python3.10 file: docker/Dockerfile.310 cache: alpine-python3.10 @@ -77,7 +77,7 @@ jobs: - image: debian-python3.10 file: docker/Dockerfile.debian310 cache: debian-python3.10 - platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x steps: - uses: actions/checkout@v6 with: diff --git a/back/shared/runtimeBuild.ts b/back/shared/runtimeBuild.ts index 7814b13f..9e20be9c 100644 --- a/back/shared/runtimeBuild.ts +++ b/back/shared/runtimeBuild.ts @@ -28,7 +28,7 @@ const backend = read(path.resolve(__dirname, '../app.js')); const root = process.env.QL_DIR || path.resolve(__dirname, '../../..'); const version = read(path.join(root, 'version.yaml')) ?.toString() - .match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + .match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1]; export const runtimeBuild = Object.freeze({ instanceId: randomUUID(), diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 3b119093..68ab3aae 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -61,7 +61,9 @@ async function withPanelLifecycle(root, tmp, env, operation, signal) { async function targetVersion(source) { const text = await fs.readFile(path.join(source, 'version.yaml'), 'utf8'); - const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + const version = text.match( + /^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m, + )?.[1]; if (!version) throw new Error('Invalid update version'); return version; } diff --git a/cli/src/internal/maintenance/upgradePrestart.cjs b/cli/src/internal/maintenance/upgradePrestart.cjs index e73f5880..a312805f 100644 --- a/cli/src/internal/maintenance/upgradePrestart.cjs +++ b/cli/src/internal/maintenance/upgradePrestart.cjs @@ -92,7 +92,9 @@ async function prestartBackend( .digest('hex'); const version = ( await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8') - ).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + ).match( + /^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m, + )?.[1]; // An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and // package-manager settings from escaping into the disposable backend. const env = { diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d2a1c37..3fb2e840 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ # Run Node package installation natively on the builder. Node/npm can spin at # 100% CPU when Alpine s390x is emulated through QEMU. -FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder +FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder ARG TARGETARCH ARG TARGETVARIANT @@ -32,7 +32,7 @@ RUN set -x \ # Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node. FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64 -FROM python:3.11-alpine +FROM python:3.11-alpine3.24 ARG TARGETARCH ARG TARGETVARIANT @@ -80,6 +80,7 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target= cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ ln -sf /usr/local/bin/node /usr/bin/node; \ fi \ + && node --version | grep -E '^v24\.' \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index 22000149..2e366f77 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -1,6 +1,6 @@ # Run Node package installation natively on the builder. Node/npm can spin at # 100% CPU when Alpine s390x is emulated through QEMU. -FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder +FROM --platform=$BUILDPLATFORM node:24.18.1-alpine3.24 AS builder ARG TARGETARCH ARG TARGETVARIANT @@ -32,7 +32,7 @@ RUN set -x \ # Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node. FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64 -FROM python:3.10-alpine +FROM python:3.10-alpine3.24 ARG TARGETARCH ARG TARGETVARIANT @@ -80,6 +80,7 @@ RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target= cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ ln -sf /usr/local/bin/node /usr/bin/node; \ fi \ + && node --version | grep -E '^v24\.' \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 871feb18..34fc65b8 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -1,6 +1,10 @@ -# Node 20 Bookworm is the latest official Node image variant that covers the -# full Debian build matrix, including arm/v7, ppc64le, and s390x. -FROM node:20-bookworm-slim AS nodebuilder +# Download and verify the target Node archive without running under QEMU. +FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder +ARG TARGETARCH +COPY docker/install-node-debian.sh /tmp/install-node-debian.sh +RUN apt-get update && \ + apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \ + sh /tmp/install-node-debian.sh "${TARGETARCH}" FROM python:3.11.14-slim-bookworm AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ @@ -11,6 +15,7 @@ RUN set -x && \ apt-get update && \ apt-get install --no-install-recommends -y libatomic1 && \ npm i -g pnpm@8.3.1 && \ + test "$(node --version)" = "v24.18.1" && \ test "$(pnpm --version)" = "8.3.1" && \ cd /tmp/build && \ pnpm install --prod --frozen-lockfile @@ -71,6 +76,7 @@ RUN set -x && \ git config --global user.name "qinglong" && \ git config --global http.postBuffer 524288000 && \ npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \ + test "$(node --version)" = "v24.18.1" && \ test "$(pnpm --version)" = "8.3.1" && \ npm cache clean --force && \ rm -rf /root/.cache && \ diff --git a/docker/Dockerfile.debian310 b/docker/Dockerfile.debian310 index 688d7dde..1b7c9af4 100644 --- a/docker/Dockerfile.debian310 +++ b/docker/Dockerfile.debian310 @@ -1,8 +1,12 @@ -# Node 20 Bookworm is the latest official Node image variant that covers the -# full Debian build matrix, including arm/v7, ppc64le, and s390x. -FROM node:20-bookworm-slim AS nodebuilder +# Download and verify the target Node archive without running under QEMU. +FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS nodebuilder +ARG TARGETARCH +COPY docker/install-node-debian.sh /tmp/install-node-debian.sh +RUN apt-get update && \ + apt-get install --no-install-recommends -y ca-certificates curl xz-utils && \ + sh /tmp/install-node-debian.sh "${TARGETARCH}" -# Keep the full release matrix, including s390x, on the same immutable base. +# Keep s390x on the same immutable Python base as the other Debian targets. # The floating 3.10-slim-bookworm tag no longer includes s390x. FROM python:3.10.19-slim-bookworm@sha256:23f63358922e79a794f71be8f3723c84e5ccca9638af3f74456dc73d6184499e AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ @@ -13,6 +17,7 @@ RUN set -x && \ apt-get update && \ apt-get install --no-install-recommends -y libatomic1 && \ npm i -g pnpm@8.3.1 && \ + test "$(node --version)" = "v24.18.1" && \ cd /tmp/build && \ pnpm install --prod --frozen-lockfile @@ -71,6 +76,7 @@ RUN set -x && \ git config --global user.name "qinglong" && \ git config --global http.postBuffer 524288000 && \ npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \ + test "$(node --version)" = "v24.18.1" && \ npm cache clean --force && \ rm -rf /root/.cache && \ rm -rf /root/.npm && \ diff --git a/docker/install-node-debian.sh b/docker/install-node-debian.sh new file mode 100644 index 00000000..ca17eaa4 --- /dev/null +++ b/docker/install-node-debian.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# The official Node 24 Bookworm image does not cover s390x. Use the same +# verified upstream release archive on every supported Debian architecture. +version=24.18.1 +case "${1:?Missing Docker TARGETARCH}" in + amd64) arch=x64; checksum=d6c664df3f3f61458e8c277585571328522d705166723a7c7823a9253a4d15a0 ;; + arm64) arch=arm64; checksum=7201e3a09dc825bac57867c81913e2b8f0ef87d04cb9082af4cda82f6ff3d88c ;; + ppc64le) arch=ppc64le; checksum=c47812c13862be7f6e71194c434c4d78a99096e72efb9c31a2627437e056d669 ;; + s390x) arch=s390x; checksum=acf57bd726f35afc2ba8475194e5a8bd532c00e1a3b2436295b2fbdde199d0e9 ;; + *) echo "Node ${version} has no supported Debian binary for TARGETARCH=$1" >&2; exit 1 ;; +esac + +archive="node-v${version}-linux-${arch}.tar.xz" +curl --fail --location --silent --show-error --retry 3 \ + "https://nodejs.org/dist/v${version}/${archive}" -o "/tmp/${archive}" +printf '%s %s\n' "${checksum}" "/tmp/${archive}" | sha256sum --check --status +tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1 --no-same-owner +rm "/tmp/${archive}" +# The target binary is executed and version-checked in the target builder/runtime stages. +test -x /usr/local/bin/node diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index d49f9a38..cf06828e 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -14,35 +14,35 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中 gh workflow run build-docker-image.yml --ref develop ``` -开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v--`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。 +开发包只使用 `upgrade-develop` 这一个 prerelease。`develop` 和 `debian-dev` 镜像读取其中的 `latest.json`,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。新批次先以带构建 ID 的文件名上传并校验,再切换 `latest.json`;索引仍保留开发版本供客户端判断更新。开发渠道不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。 -开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。 +开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 prerelease;不删除正式包或无法分类的 release。构建或上传失败不会删除上一套可用包。旧资产被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。 -开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。 +开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。 -版本入口为 GitHub prerelease `upgrade-v` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v--` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。 +每个正式版本只使用一个 GitHub prerelease `upgrade-v`,其中存放完整平台包、校验文件、安装器和 `latest.json`;在线正式渠道 `upgrade-master` 只保留指向最新版本的 `latest.json`,兼容现有客户端。不再创建 `upgrade-build-v--` 构建批次 release。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及该版本 release 中带构建 ID 的固定资产 URL。相同版本的镜像 tag 重新发布时,先上传并校验新资产,再更新版本索引和正式渠道索引;同一提交重跑也允许产生新内容。旧批次资产保留在同一版本 release,已选中旧索引的下载可继续完成,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。 ## 手动离线安装和升级 -从 `upgrade-v` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v--` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。 +从 `upgrade-v` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和带构建 ID 的独立安装器;两者都来自该版本 release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同一 release 的 `latest.json` 核对归档 SHA256。 已有安装(默认应用并重启): ```sh -node install-bundle.cjs --root /ql --file /absolute/path/qinglong--linux-arm64-musl.tar.gz +node install-bundle-.cjs --root /ql --file /absolute/path/qinglong--linux-arm64-musl-.tar.gz ``` 只准备,稍后手动重启: ```sh -node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only +node install-bundle-.cjs --root /ql --file /absolute/path/package.tar.gz --download-only ql reload system ``` 仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如: ```sh -node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps +node install-bundle-.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps ``` 修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。 @@ -58,13 +58,13 @@ node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --downlo 安装到空目录: ```sh -node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install +node install-bundle-.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh ``` `--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。 -ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。 +ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。 ## npm 面板包 @@ -89,7 +89,7 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`, - 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。 - 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。 - glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。 -- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。 +- ARMv6 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。 - ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。 - tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 diff --git a/docs/releases/2.24.0-dev.0.md b/docs/releases/2.24.0-dev.0.md new file mode 100644 index 00000000..475b65e9 --- /dev/null +++ b/docs/releases/2.24.0-dev.0.md @@ -0,0 +1,11 @@ +# 青龙 2.24.0-dev.0 开发候选 + +这是 `develop` 渠道的开发构建,上一正式版本为 2.23.0。此版本尚未正式发布。 + +## 本轮变化 + +- Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。 +- Alpine 镜像保留 arm/v6 和 arm/v7,Debian 镜像暂不提供 arm/v7。 +- ARM64 升级包改用原生 runner 构建,避免在 x64 QEMU 下归档时发生异常。 + +升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。 diff --git a/package.json b/package.json index 39ad141d..53b5a2d0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@whyour/qinglong", "packageManager": "pnpm@8.3.1", - "version": "2.23.0", + "version": "2.24.0-dev.0", "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript", "repository": { "type": "git", diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs index 3dc64fd7..55fd4024 100644 --- a/scripts/publish-upgrade-bundles.cjs +++ b/scripts/publish-upgrade-bundles.cjs @@ -13,17 +13,17 @@ const platforms = [ 'linux-s390x-musl', 'linux-ia32-musl', 'linux-x64-glibc', - 'linux-armv7-glibc', 'linux-arm64-glibc', 'linux-ppc64-glibc', 'linux-s390x-glibc', ]; function releaseIndex(directory, commit, repository, options = {}) { - const { version, releaseTag = `upgrade-${commit}` } = options; + const { version, releaseTag = `upgrade-${commit}`, assetBuildId } = options; if ( !/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository) || - !/^[\w.+-]+$/.test(releaseTag) + !/^[\w.+-]+$/.test(releaseTag) || + (assetBuildId && !/^\d+-\d+$/.test(assetBuildId)) ) throw new Error('Invalid release source'); const packages = {}; @@ -48,11 +48,48 @@ function releaseIndex(directory, commit, repository, options = {}) { sha256: record.sha256, url: `https://github.com/${repository}/releases/download/${encodeURIComponent( releaseTag, - )}/${filename}`, + )}/${ + assetBuildId + ? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`) + : filename + }`, }; } return { format: 2, sourceCommit: commit, packages }; } +function stageReleaseAssets(directory, commit, buildId, channel) { + const staged = fs.mkdtempSync(path.join(directory, `.upgrade-${channel}-`)); + const assets = []; + try { + for (const target of platforms) { + const filename = `qinglong-${commit}-${target}.tar.gz`; + const archiveName = filename.replace(/\.tar\.gz$/, `-${buildId}.tar.gz`); + const archive = path.join(staged, archiveName); + fs.linkSync(path.join(directory, filename), archive); + const record = JSON.parse( + fs.readFileSync(path.join(directory, filename + '.json')), + ); + fs.writeFileSync( + archive + '.json', + JSON.stringify({ + ...record, + filename: archiveName, + }) + '\n', + ); + assets.push(archive, archive + '.json'); + } + const installer = path.join(staged, `install-bundle-${buildId}.cjs`); + fs.linkSync(path.join(directory, 'install-bundle.cjs'), installer); + assets.push(installer); + return { staged, assets }; + } catch (error) { + fs.rmSync(staged, { recursive: true, force: true }); + throw error; + } +} +function stageDevelopmentAssets(directory, commit, buildId) { + return stageReleaseAssets(directory, commit, buildId, 'develop'); +} function publishBundles({ directory, commit, @@ -76,13 +113,12 @@ function publishBundles({ // unchanged. Only the version/channel pointer is overwritten, never bytes // that an in-flight download has already selected by hash. const development = branch === 'develop'; - const tag = `upgrade-build-${ - development ? 'develop-' : '' - }v${version}-${buildId}`; + const tag = development ? 'upgrade-develop' : `upgrade-v${version}`; const index = { ...releaseIndex(directory, commit, repository, { version, releaseTag: tag, + assetBuildId: buildId, }), version, buildId, @@ -94,7 +130,7 @@ function publishBundles({ sha256: sha256(installer), url: `https://github.com/${repository}/releases/download/${encodeURIComponent( tag, - )}/install-bundle.cjs`, + )}/install-bundle-${buildId}.cjs`, }; fs.writeFileSync( path.join(directory, 'latest.json'), @@ -114,7 +150,9 @@ function publishBundles({ } }; const notes = - `Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` + + `${ + development ? 'Development' : 'Verified' + } platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` + Object.entries(index.packages) .map(([target, entry]) => `- [${target}](${entry.url})`) .join('\n') + @@ -148,118 +186,136 @@ function publishBundles({ console.log('Newer source exists; leaving version and channel unchanged'); return false; } - ensureRelease(tag, `Platform upgrade v${version} (${buildId})`); - const assets = platforms - .flatMap((target) => { - const archive = path.join( - directory, - `qinglong-${commit}-${target}.tar.gz`, - ); - return [archive, archive + '.json']; - }) - .concat( - path.join(directory, 'install-bundle.cjs'), - path.join(directory, 'latest.json'), - ); - const published = lookup(tag); - const missing = []; - const matches = (asset, file) => { - const bytes = fs.readFileSync(file); - return ( - asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}` - ); - }; - for (const file of assets) { - const previous = published.assets.find( - (a) => a.name === path.basename(file), - ); - if (!previous) missing.push(file); - else if (!matches(previous, file)) - throw new Error( - `Immutable release asset differs: ${path.basename(file)}`, - ); - } - if (missing.length) - gh('release', 'upload', tag, ...missing, '--repo', repository); - const uploaded = lookup(tag); - if ( - !assets.every((file) => - matches( - uploaded.assets.find((a) => a.name === path.basename(file)), - file, - ), - ) - ) - throw new Error('Published platform bundle verification failed'); - - // Each channel serializes its image builds and pointer writes as one release. - // A rerun of an earlier workflow is allowed to replace the same - // version/commit; an obsolete source commit must never advance either alias. - const aliases = development - ? ['upgrade-develop'] - : [`upgrade-v${version}`, 'upgrade-master']; - for (const ref of aliases) { - if (!currentSource()) { - console.log('Newer source exists; leaving version and channel unchanged'); - return false; - } + const staged = development + ? stageDevelopmentAssets(directory, commit, buildId) + : stageReleaseAssets(directory, commit, buildId, `v${version}`); + try { ensureRelease( - ref, - ref === `upgrade-${branch}` - ? `Upgrade channel ${branch}` - : `Platform upgrade v${version}`, + tag, + development ? 'Upgrade channel develop' : `Platform upgrade v${version}`, ); - gh( - 'release', - 'upload', - ref, - path.join(directory, 'latest.json'), - '--repo', - repository, - '--clobber', - ); - gh('release', 'edit', ref, '--repo', repository, '--notes', notes); + const assets = staged.assets; + const published = lookup(tag); + const missing = []; + const matches = (asset, file) => { + const bytes = fs.readFileSync(file); + return ( + asset?.size === bytes.length && + asset.digest === `sha256:${sha256(bytes)}` + ); + }; + for (const file of assets) { + const previous = published.assets.find( + (a) => a.name === path.basename(file), + ); + if (!previous) missing.push(file); + else if (!matches(previous, file)) + throw new Error( + `Immutable release asset differs: ${path.basename(file)}`, + ); + } + if (missing.length) + gh('release', 'upload', tag, ...missing, '--repo', repository); + const uploaded = lookup(tag); if ( - !matches( - lookup(ref)?.assets.find((a) => a.name === 'latest.json'), - path.join(directory, 'latest.json'), + !assets.every((file) => + matches( + uploaded.assets.find((a) => a.name === path.basename(file)), + file, + ), ) ) - throw new Error('Published channel index verification failed'); - } - // Only prune development releases after the complete new batch and its - // channel pointer are verified. Production versions and aliases are retained. - if (development && currentSource()) { - const pages = JSON.parse( - gh( - 'api', - `repos/${repository}/releases?per_page=100`, - '--paginate', - '--slurp', - ), - ); - for (const release of pages.flat()) { - if ( - release.tag_name !== tag && - /^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test( - release.tag_name, - ) && - release.prerelease === true && - release.draft === false - ) { - if (!currentSource()) break; - gh( - 'release', - 'delete', - release.tag_name, - '--repo', - repository, - '--yes', + throw new Error('Published platform bundle verification failed'); + + // Each channel serializes its image builds and pointer writes as one release. + // A rerun of an earlier workflow is allowed to replace the same + // version/commit; an obsolete source commit must never advance either alias. + const aliases = development ? ['upgrade-develop'] : [tag, 'upgrade-master']; + for (const ref of aliases) { + if (!currentSource()) { + console.log( + 'Newer source exists; leaving version and channel unchanged', ); + return false; + } + ensureRelease( + ref, + ref === `upgrade-${branch}` + ? `Upgrade channel ${branch}` + : `Platform upgrade v${version}`, + ); + gh( + 'release', + 'upload', + ref, + path.join(directory, 'latest.json'), + '--repo', + repository, + '--clobber', + ); + gh('release', 'edit', ref, '--repo', repository, '--notes', notes); + if ( + !matches( + lookup(ref)?.assets.find((a) => a.name === 'latest.json'), + path.join(directory, 'latest.json'), + ) + ) + throw new Error('Published channel index verification failed'); + } + // Only prune development releases after the complete new batch and its + // channel pointer are verified. Production versions and aliases are retained. + if (development && currentSource()) { + const currentAssets = new Set(assets.map((file) => path.basename(file))); + for (const asset of lookup(tag).assets) { + if ( + !currentAssets.has(asset.name) && + (/^qinglong-[a-f0-9]{40}-linux-[\w-]+-\d+-\d+\.tar\.gz(?:\.json)?$/.test( + asset.name, + ) || + /^install-bundle-\d+-\d+\.cjs$/.test(asset.name)) + ) { + if (!currentSource()) break; + gh( + 'api', + '-X', + 'DELETE', + `repos/${repository}/releases/assets/${asset.id}`, + ); + } + } + const pages = JSON.parse( + gh( + 'api', + `repos/${repository}/releases?per_page=100`, + '--paginate', + '--slurp', + ), + ); + for (const release of pages.flat()) { + if ( + release.tag_name !== tag && + /^upgrade-build-develop-(?:v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-)?\d+-\d+$/.test( + release.tag_name, + ) && + release.prerelease === true && + release.draft === false + ) { + if (!currentSource()) break; + gh( + 'release', + 'delete', + release.tag_name, + '--repo', + repository, + '--yes', + ); + } } } + return true; + } finally { + fs.rmSync(staged.staged, { recursive: true, force: true }); } - return true; } module.exports = { releaseIndex, platforms, publishBundles }; if (require.main === module) { diff --git a/test/back/panel-upgrade.test.cjs b/test/back/panel-upgrade.test.cjs index 2d283466..3fe0dae1 100644 --- a/test/back/panel-upgrade.test.cjs +++ b/test/back/panel-upgrade.test.cjs @@ -19,7 +19,7 @@ const { sourceCommit, } = require('../helpers/upgrade-fixture.cjs'); -async function fixture(t) { +async function fixture(t, targetVersion = '2.24.0') { const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-')); const root = path.join(base, 'panel'), tmp = path.join(root, '.tmp'); @@ -30,7 +30,10 @@ async function fixture(t) { await fs.writeFile(name, contents); }; for (const dir of [root, source]) { - await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}'); + await write( + path.join(dir, 'package.json'), + JSON.stringify({ version: targetVersion }), + ); await write(path.join(dir, 'pnpm-lock.yaml'), 'lock'); await write(path.join(dir, 'sample/config.sample.sh'), '# config'); } @@ -39,7 +42,7 @@ async function fixture(t) { 'installed dependency', ); await write(path.join(root, 'version.yaml'), 'version: 2.23.0'); - await write(path.join(source, 'version.yaml'), 'version: 2.24.0'); + await write(path.join(source, 'version.yaml'), `version: ${targetVersion}`); await write(path.join(staticRoot, 'build/app.js'), 'new backend'); writeManifest(source, staticRoot); for (const repo of ['qinglong', 'qinglong-static']) @@ -81,7 +84,7 @@ fs.writeFileSync(args[args.indexOf('--output') + 1], buffer); ); await fs.chmod(path.join(base, 'bin/curl'), 0o700); const runtime = { - version: '2.24.0', + version: targetVersion, sourceCommit, instanceId: 'after-restart', parentPid: 100002, @@ -186,6 +189,21 @@ test('detached download survives its initiating request and is idempotent; apply ); }); +test('prerelease version is retained through download and restart verification', async (t) => { + const f = await fixture(t, '2.24.0-dev.0'); + await startJob(f, 'download'); + const ready = await finished(f.tmp); + assert.equal(ready.status, 'ready', ready.log); + assert.equal(ready.version, '2.24.0-dev.0'); + await startJob(f, 'apply'); + const complete = await finished(f.tmp); + assert.equal(complete.status, 'completed', complete.log); + assert.equal( + await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'), + 'version: 2.24.0-dev.0', + ); +}); + test('invalid staged payload fails asynchronously before stopping the panel', async (t) => { const f = await fixture(t); await startJob(f, 'download'); diff --git a/test/back/platform-bundle.test.cjs b/test/back/platform-bundle.test.cjs index 10b1def3..c8d941e2 100644 --- a/test/back/platform-bundle.test.cjs +++ b/test/back/platform-bundle.test.cjs @@ -591,7 +591,9 @@ test('publishing requires every platform and validates all immutable archive has ); } const index = releaseIndex(f.directory, sourceCommit, repository); - assert.equal(Object.keys(index.packages).length, 12); + assert.equal(Object.keys(index.packages).length, 11); + assert.ok(index.packages['linux-armv7-musl']); + assert.equal(index.packages['linux-armv7-glibc'], undefined); const armv6 = path.join( f.directory, `qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`, diff --git a/test/back/publish-upgrade-bundles.test.cjs b/test/back/publish-upgrade-bundles.test.cjs index 53581a89..f94024cb 100644 --- a/test/back/publish-upgrade-bundles.test.cjs +++ b/test/back/publish-upgrade-bundles.test.cjs @@ -66,6 +66,17 @@ function fixture(t) { const gh = (...args) => { state.calls.push(args); if (args[0] === 'api') { + if (args[1] === '-X' && args[2] === 'DELETE') { + if (state.failDelete) throw new Error('Delete unavailable'); + const id = Number(args[3].split('/').at(-1)); + for (const release of state.releases.values()) + for (const asset of release.values()) + if (asset.id === id) { + release.delete(asset.name); + return ''; + } + return notFound(); + } const route = args[1]; if (route.endsWith('/git/ref/heads/master')) return state.head; if (route.endsWith('/git/ref/heads/develop')) return state.developHead; @@ -127,7 +138,11 @@ function fixture(t) { digest: `sha256:${sha256(bytes)}`, }); } - if (tag === 'upgrade-develop' && state.corruptChannel) + if ( + files.some((file) => path.basename(file) === 'latest.json') && + ((tag === 'upgrade-develop' && state.corruptChannel) || + tag === state.corruptIndexTag) + ) release.get('latest.json').digest = 'sha256:wrong'; if (files.length > 1) { if (state.corruptUploadedBatch) @@ -162,14 +177,19 @@ test('publishes every platform and installer before advancing version and legacy assert.equal(index.version, '2.24.0'); assert.equal(index.buildId, '100-1'); assert.equal(index.sourceCommit, f.options.commit); - assert.equal(Object.keys(index.packages).length, 12); + assert.equal(Object.keys(index.packages).length, 11); + assert.ok(index.packages['linux-armv7-musl']); + assert.equal(index.packages['linux-armv7-glibc'], undefined); assert.deepEqual(index, f.index('upgrade-v2.24.0')); - const tag = 'upgrade-build-v2.24.0-100-1'; - assert.equal(f.state.releases.get(tag).size, 26); + const tag = 'upgrade-v2.24.0'; + assert.equal(f.state.releases.get(tag).size, 24); + assert.equal(f.state.releases.get('upgrade-master').size, 1); + assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-100-1')); for (const entry of [...Object.values(index.packages), index.installer]) { const url = new URL(entry.url), filename = path.basename(url.pathname); assert.ok(url.pathname.includes(`/releases/download/${tag}/`)); + assert.ok(filename.includes('-100-1.')); const bytes = f.state.releases.get(tag).get(filename).bytes; assert.equal(bytes.length, entry.size); assert.equal(sha256(bytes), entry.sha256); @@ -179,7 +199,7 @@ test('publishes every platform and installer before advancing version and legacy ); assert.deepEqual( writes.map((a) => a[2]), - [tag, 'upgrade-v2.24.0', 'upgrade-master'], + [tag, tag, 'upgrade-master'], ); assert.ok(writes.slice(1).every((a) => a.includes('--clobber'))); }); @@ -192,10 +212,10 @@ for (const sameCommit of [false, true]) f.publish(); const before = f.index(); const oldAssets = new Map( - Array.from( - f.state.releases.get('upgrade-build-v2.24.0-100-1'), - ([name, a]) => [name, a.bytes.toString('base64')], - ), + Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [ + name, + a.bytes.toString('base64'), + ]).filter(([name]) => name !== 'latest.json'), ); const commit = sameCommit ? f.options.commit : 'b'.repeat(40); f.state.head = commit; @@ -217,13 +237,14 @@ for (const sameCommit of [false, true]) assert.notEqual(after.installer.sha256, before.installer.sha256); assert.deepEqual( new Map( - Array.from( - f.state.releases.get('upgrade-build-v2.24.0-100-1'), - ([name, a]) => [name, a.bytes.toString('base64')], - ), + Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [ + name, + a.bytes.toString('base64'), + ]).filter(([name]) => oldAssets.has(name)), ), oldAssets, ); + assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 47); }); test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => { @@ -234,7 +255,8 @@ test('retrying a publication is idempotent, but changed bytes require a new buil (a) => a[0] === 'release' && a[1] === 'upload' && - a[2] === 'upgrade-build-v2.24.0-100-1', + a[2] === 'upgrade-v2.24.0' && + !a.includes('--clobber'), ).length; assert.equal(count(), 1); f.publish(); @@ -244,6 +266,41 @@ test('retrying a publication is idempotent, but changed bytes require a new buil assert.equal(f.index().buildId, '100-1'); }); +test('a new version keeps the previous version release while master advances', (t) => { + const f = fixture(t); + f.publish(); + const previous = f.index('upgrade-v2.24.0'); + const commit = 'b'.repeat(40); + f.state.head = commit; + f.write(commit, 'next version', '2.25.0'); + assert.equal( + f.publish({ commit, version: '2.25.0', buildId: '101-1' }), + true, + ); + assert.deepEqual(f.index('upgrade-v2.24.0'), previous); + assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24); + assert.equal(f.state.releases.get('upgrade-v2.25.0').size, 24); + assert.equal(f.state.releases.get('upgrade-master').size, 1); + assert.equal(f.index().version, '2.25.0'); + assert.ok(!f.state.releases.has('upgrade-build-v2.25.0-101-1')); +}); + +test('a failed version index verification leaves master on the previous version', (t) => { + const f = fixture(t); + f.publish(); + const previous = f.index(); + const commit = 'b'.repeat(40); + f.state.head = commit; + f.write(commit, 'next version', '2.25.0'); + f.state.corruptIndexTag = 'upgrade-v2.25.0'; + assert.throws( + () => f.publish({ commit, version: '2.25.0', buildId: '101-1' }), + /Published channel index verification failed/, + ); + assert.deepEqual(f.index(), previous); + assert.deepEqual(f.index('upgrade-v2.24.0'), previous); +}); + for (const fault of ['missing', 'corrupt', 'wrong-version']) test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => { const f = fixture(t); @@ -283,7 +340,7 @@ test('stale commits do not publish; a source change during upload cannot advance const before = f.index(); f.state.head = 'b'.repeat(40); assert.equal(f.publish({ buildId: '101-1' }), false); - assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1')); + assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24); f.state.head = f.options.commit; f.state.changeHeadAfterUpload = true; assert.equal(f.publish({ buildId: '102-1' }), false); @@ -335,38 +392,58 @@ test('existing master clients can migrate from indexes without version/build met assert.equal(f.index().buildId, '100-2'); }); -test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => { +test('automatic development publication keeps its prerelease version separate from production assets', (t) => { const f = fixture(t); f.publish(); const production = f.index(); - f.write(f.state.developHead, 'development'); + const developmentVersion = '2.24.0-dev.0'; + f.write(f.state.developHead, 'development', developmentVersion); assert.equal( f.publish({ branch: 'develop', commit: f.state.developHead, + version: developmentVersion, }), true, ); const development = f.index('upgrade-develop'); assert.equal(development.channel, 'develop'); + assert.equal(development.version, developmentVersion); assert.equal(development.sourceCommit, f.state.developHead); - assert.equal(Object.keys(development.packages).length, 12); + assert.equal(Object.keys(development.packages).length, 11); assert.ok(development.packages['linux-x64-musl']); assert.ok(development.packages['linux-x64-glibc']); assert.ok(development.packages['linux-armv6-musl']); assert.equal(development.packages['linux-armv6-glibc'], undefined); + assert.ok(development.packages['linux-armv7-musl']); + assert.equal(development.packages['linux-armv7-glibc'], undefined); for (const entry of [ ...Object.values(development.packages), development.installer, ]) - assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/')); + assert.ok(entry.url.includes('/upgrade-develop/')); + for (const entry of Object.values(development.packages)) + assert.ok(entry.url.endsWith('-100-1.tar.gz')); + assert.ok(development.installer.url.endsWith('/install-bundle-100-1.cjs')); + assert.ok( + f.state.calls.some( + (args) => + args[0] === 'release' && + args[1] === 'create' && + args[2] === 'upgrade-develop' && + args[args.indexOf('--title') + 1] === 'Upgrade channel develop', + ), + ); assert.deepEqual(f.index(), production); assert.deepEqual(f.index('upgrade-v2.24.0'), production); - assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26); - assert.equal( - f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size, - 26, + assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24); + assert.equal(f.state.releases.get('upgrade-develop').size, 24); + assert.ok(!f.state.releases.has('upgrade-build-develop-100-1')); + const archiveName = path.basename(development.packages['linux-x64-musl'].url); + const sidecar = JSON.parse( + f.state.releases.get('upgrade-develop').get(`${archiveName}.json`).bytes, ); + assert.equal(sidecar.filename, archiveName); }); test('development builds cannot publish obsolete development source', (t) => { @@ -416,14 +493,21 @@ for (const fault of [ const options = { branch: 'develop', commit: f.state.developHead }; f.write(options.commit, 'development'); f.publish(options); - const oldTag = 'upgrade-build-develop-v2.24.0-100-1'; + const oldTag = 'upgrade-build-develop-100-1'; + const legacyTag = 'upgrade-build-develop-v2.24.0-99-1'; + f.state.releases.set(oldTag, new Map()); + f.state.releases.set(legacyTag, new Map()); + const oldAsset = Array.from( + f.state.releases.get('upgrade-develop').keys(), + ).find((name) => name.endsWith('-100-1.tar.gz')); + assert.ok(oldAsset); // Never prune production, channel aliases, legacy/unclassified releases, // drafts or a non-prerelease even when its name resembles a dev build. const protectedTags = [ 'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc', 'v2.24.0', - 'upgrade-build-develop-v2.24.0-10-1', - 'upgrade-build-develop-v2.24.0-11-1', + 'upgrade-build-develop-10-1', + 'upgrade-build-develop-11-1', ]; for (const tag of protectedTags) f.state.releases.set(tag, new Map()); f.state.releaseMetadata = { @@ -441,7 +525,7 @@ for (const fault of [ assert.equal(f.publish({ ...options, buildId: '101-1' }), true); assert.equal(f.index('upgrade-develop').buildId, '101-1'); const deletion = f.state.calls.findIndex( - (a) => a[0] === 'release' && a[1] === 'delete', + (a) => a[0] === 'api' && a[1] === '-X' && a[2] === 'DELETE', ); const aliasEdit = f.state.calls.findLastIndex( (a) => a[0] === 'release' && a[1] === 'edit', @@ -455,9 +539,15 @@ for (const fault of [ a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'), ), ); - assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1')); + assert.ok(f.state.releases.has('upgrade-develop')); + assert.ok(!f.state.releases.has('upgrade-build-develop-101-1')); } assert.equal(f.state.releases.has(oldTag), Boolean(fault)); + assert.equal(f.state.releases.has(legacyTag), Boolean(fault)); + assert.equal( + f.state.releases.get('upgrade-develop').has(oldAsset), + Boolean(fault), + ); for (const tag of protectedTags) assert.ok(f.state.releases.has(tag)); assert.deepEqual(f.index(), production); assert.deepEqual(f.index('upgrade-v2.24.0'), production); @@ -468,17 +558,21 @@ test('failed development cleanup is retryable without overwriting immutable asse const options = { branch: 'develop', commit: f.state.developHead }; f.write(options.commit, 'development'); f.publish(options); + const oldAsset = Array.from( + f.state.releases.get('upgrade-develop').keys(), + ).find((name) => name.endsWith('-100-1.tar.gz')); + assert.ok(oldAsset); f.state.failDelete = true; assert.throws( () => f.publish({ ...options, buildId: '101-1' }), /Delete unavailable/, ); assert.equal(f.index('upgrade-develop').buildId, '101-1'); - assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1')); + assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset)); f.state.failDelete = false; f.publish({ ...options, buildId: '101-1' }); - assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1')); - assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1')); + assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset)); + assert.equal(f.state.releases.get('upgrade-develop').size, 24); }); test('a newer development commit during upload preserves the published channel and its old package', (t) => { @@ -487,10 +581,14 @@ test('a newer development commit during upload preserves the published channel a f.write(options.commit, 'development'); f.publish(options); const before = f.index('upgrade-develop'); + const oldAsset = Array.from( + f.state.releases.get('upgrade-develop').keys(), + ).find((name) => name.endsWith('-100-1.tar.gz')); + assert.ok(oldAsset); f.state.changeDevelopHeadAfterUpload = true; assert.equal(f.publish({ ...options, buildId: '101-1' }), false); assert.deepEqual(f.index('upgrade-develop'), before); - assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1')); + assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset)); assert.ok( !f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'), ); diff --git a/test/back/runtime-build.test.cjs b/test/back/runtime-build.test.cjs index 0b74450a..6b762f37 100644 --- a/test/back/runtime-build.test.cjs +++ b/test/back/runtime-build.test.cjs @@ -32,7 +32,7 @@ test('a running process retains its own build identity when installed files chan const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); fs.mkdirSync(path.join(root, 'static/build'), { recursive: true }); - fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0'); + fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0-dev.0'); fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend'); fs.writeFileSync( path.join(root, 'static/build-info.json'), @@ -45,7 +45,7 @@ test('a running process retains its own build identity when installed files chan JSON.stringify({ sourceCommit: 'b'.repeat(40) }), ); fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0'); - assert.equal(running.version, '2.24.0'); + assert.equal(running.version, '2.24.0-dev.0'); assert.equal(running.sourceCommit, 'a'.repeat(40)); assert.equal( running.backendSha256, diff --git a/test/back/upgrade-prestart.test.cjs b/test/back/upgrade-prestart.test.cjs index a6a009f7..853e98d5 100644 --- a/test/back/upgrade-prestart.test.cjs +++ b/test/back/upgrade-prestart.test.cjs @@ -10,8 +10,12 @@ const { repairDependencies, } = require('../../cli/src/internal/maintenance/upgradePrestart.cjs'); -for (const exitCode of [0, 1]) - test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => { +for (const [version, exitCode] of [ + ['2.23.0', 0], + ['2.23.0', 1], + ['2.24.0-dev.0', 0], +]) + test(`prestart ${version} exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const source = path.join(root, 'source'), @@ -19,7 +23,7 @@ for (const exitCode of [0, 1]) survivor = path.join(root, 'survivor'); await fs.mkdir(path.join(source, 'node_modules'), { recursive: true }); await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true }); - await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0'); + await fs.writeFile(path.join(source, 'version.yaml'), `version: ${version}`); await fs.writeFile(path.join(statics, 'build/app.js'), 'backend'); await fs.writeFile( path.join(statics, 'build-info.json'), @@ -36,7 +40,7 @@ for (const exitCode of [0, 1]) )}],{stdio:'inherit'}); process.send({type:'upgrade-probe',ok:${ exitCode === 0 - },message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode})); + },message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:${JSON.stringify(version)},backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode})); `, ); const operation = prestartBackend( diff --git a/version.yaml b/version.yaml index 3f69801a..dfd071a7 100644 --- a/version.yaml +++ b/version.yaml @@ -1,15 +1,9 @@ -version: 2.23.0 -changeLogLink: https://github.com/whyour/qinglong/blob/master/docs/releases/2.23.0.md -publishTime: "2026-10-11 12:27:02" +version: 2.24.0-dev.0 +changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.24.0-dev.0.md +publishTime: "2026-10-25 19:22:54" changeLog: | - 1. 新增脚本历史版本查看、对比与恢复,支持 CLI 和 OpenAPI;恢复前检查当前内容哈希,避免覆盖并发修改 - 2. 普通任务与订阅调度迁移到 node-cron,保留旧规则兼容及日期/星期 OR 语义,补充宏和夏令时说明 - 3. 修复已失效排队任务仍被执行的问题,完善任务进程超时、停止和 Shell 环境传递 - 4. 修复系统令牌主动续期与认证缓存并发更新;Shell API 遇到 401 时刷新令牌并重试一次 - 5. 降低任务启动开销,JS/Python 通知依赖改为首次调用时加载 - 6. 加固环境变量、订阅参数、文件访问和 SSH 配置输入,改善资源冲突与 CLI 错误反馈 - 7. 修复页面升级、ql update 与静态资源版本同步,完善离线备份和升级产物校验 - 8. 修复 Debian 依赖安装、通知与调度回归,补齐前端中英文翻译 - 9. 最小修补 HTTP 压缩、上传、gRPC、代理 IP、WebSocket 和 JSON 查询相关依赖漏洞 - 10. 远程 CLI 更新至 0.1.2,新增脚本历史命令,最低 Node.js 版本仍为 22.12 - 11. 本次为 Python 3.10 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知 + 1. 开发候选 2.24.0-dev.0,尚未正式发布;以 2.23.0 为上一正式版本 + 2. Alpine 与 Debian 镜像统一使用 Node.js 24,升级 sqlite3 至 1.1.4 + 3. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7 镜像 + 4. ARM64 升级包在原生 runner 构建,避免 x64 QEMU 下的归档异常 + 5. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知