Compare commits

..
35 changed files with 645 additions and 393 deletions
+9 -153
View File
@@ -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
@@ -405,151 +405,6 @@ jobs:
cache-from: type=registry,ref=whyour/qinglong:cache-debian
cache-to: type=registry,ref=whyour/qinglong:cache-debian,mode=max
build-alpine310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Alpine Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
context: .
file: ./docker/Dockerfile.310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-alpine-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-alpine-python3.10,mode=max
build-debian310:
if: ${{ github.ref_name == 'master' }}
needs: build-static
runs-on: ubuntu-22.04
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Read version from version.yaml
id: version
run: |
VERSION=$(grep '^version:' version.yaml | awk '{print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Setup timezone
run: sudo timedatectl set-timezone Asia/Shanghai
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
cache-image: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push (Debian Python 3.10)
uses: docker/build-push-action@v7
with:
build-args: |
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
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
context: .
file: ./docker/Dockerfile.debian310
push: true
provenance: mode=max
sbom: true
tags: |
whyour/qinglong:debian-python3.10
whyour/qinglong:${{ steps.version.outputs.version }}-debian-python3.10
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
# One unmodified amd64 package for migration acceptance only. This does not
# publish bundles or advance development/formal channel indexes.
build-npm-migration-bundle:
@@ -616,7 +471,7 @@ jobs:
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
needs: [build-alpine, build-debian, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -652,7 +507,7 @@ jobs:
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
@@ -662,7 +517,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 +535,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 +577,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'
+20 -2
View File
@@ -25,7 +25,7 @@ on:
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
type: string
default: ''
@@ -93,8 +93,26 @@ jobs:
if-no-files-found: error
retention-days: 14
platform-smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run build:cli
- run: node --test cli/test/remote/platformSmoke.test.cjs
publish:
needs: package
needs: [package, platform-smoke]
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
+1 -9
View File
@@ -69,15 +69,7 @@ jobs:
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- 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:
+1 -1
View File
@@ -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(),
+4 -4
View File
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
```
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
Credentials/token are stored in `~/.config/qinglong/cli.json` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
### Direct access token
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
The CLI stable version is maintained independently in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
+4 -4
View File
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
```
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
### 直接使用访问令牌
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动运行需选择 master 并勾选 publish;PR、develop 和 fork 不发布。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
CLI 稳定版版本号独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
## 面板内部工具
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.3",
"version": "0.1.4",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
@@ -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;
}
@@ -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 = {
+15 -7
View File
@@ -17,20 +17,28 @@ export function configPath(): string {
export function readConfig(): StoredConfig {
let fd: number | undefined;
try {
fd = fs.openSync(
configPath(),
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
const file = configPath();
const pathStat = fs.lstatSync(file);
if (!pathStat.isFile())
fail(
translate(
process.env,
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
const stat = fs.fstatSync(fd);
if (
!stat.isFile() ||
stat.mode & 0o077 ||
(process.getuid && stat.uid !== process.getuid())
pathStat.dev !== stat.dev ||
pathStat.ino !== stat.ino
) {
fail(
translate(
process.env,
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
}
+2 -2
View File
@@ -175,8 +175,8 @@ export const english: Record<string, string> = {
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
'API 请求被拒绝(code %s),请检查%s。':
'API request rejected (code %s). Check %s.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置必须是普通文件,且不能是符号链接。':
'CLI config must be a regular file, not a symbolic link.',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
+66 -3
View File
@@ -296,11 +296,13 @@ test('malformed successful responses are rejected, including expired authenticat
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
});
test('config permissions, malformed content and symlinks fail closed', async (t) => {
test('config content and symlinks are checked independently of permission bits', async (t) => {
const f = await fixture(t);
await f.login();
fs.chmodSync(f.file, 0o644);
failure(await f.invoke(['task', 'list', '--json']), 1);
const sharedMode = await f.invoke(['task', 'list', '--json']);
assert.equal(sharedMode.code, 0, sharedMode.err);
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
fs.chmodSync(f.file, 0o600);
fs.writeFileSync(f.file, '{broken');
failure(await f.invoke(['task', 'list', '--json']), 1);
@@ -312,12 +314,73 @@ test('config permissions, malformed content and symlinks fail closed', async (t)
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json']), 1);
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
failure(linked, 1);
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
await f.login();
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
});
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'windows-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
const originalOpenSync = fs.openSync;
fs.openSync = function (file, flags, ...args) {
return originalOpenSync(file, typeof flags === 'number'
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
};
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json'], env), 1);
});
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'arm-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
stat.uid = 999999;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
});
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
const f = await fixture(t);
const dist = path.join(f.dir, 'standalone');
+74
View File
@@ -0,0 +1,74 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
test('saved login works on the host filesystem', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
const config = path.join(dir, 'cli.json');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
res.setHeader('content-type', 'application/json');
if (url.pathname === '/panel/open/auth/token') {
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
res.end(JSON.stringify({
code: 200,
data: {
token: 'smoke-token',
expiration: Math.floor(Date.now() / 1000) + 3600,
},
}));
return;
}
assert.equal(req.headers.authorization, 'Bearer smoke-token');
assert.equal(url.pathname, '/panel/open/crons');
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const env = {
...process.env,
QL_CLI_CONFIG: config,
QL_CLIENT_ID: 'smoke-id',
QL_CLIENT_SECRET: 'smoke-secret',
QL_URL: '',
QL_ACCESS_TOKEN: '',
};
const invoke = (args) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, [entry, ...args], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (data) => (out += data));
child.stderr.on('data', (data) => (err += data));
child.once('error', reject);
child.once('close', (code) => resolve({ code, out, err }));
});
const url = `http://127.0.0.1:${server.address().port}/panel`;
const login = await invoke(['login', '--url', url, '--json']);
assert.equal(login.code, 0, login.err);
assert.equal(JSON.parse(login.out).data.authenticated, true);
assert.equal(fs.lstatSync(config).isFile(), true);
const status = await invoke(['auth', 'status', '--json']);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.authenticated, true);
const list = await invoke(['task', 'list', '--json']);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 0);
});
+3 -2
View File
@@ -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 \
+3 -2
View File
@@ -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 \
+9 -3
View File
@@ -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 && \
+10 -4
View File
@@ -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 && \
+22
View File
@@ -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
+11 -11
View File
@@ -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<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `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<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
每个正式版本只使用一个 GitHub prerelease `upgrade-v<version>`,其中存放完整平台包、校验文件、安装器和 `latest.json`;在线正式渠道 `upgrade-master` 只保留指向最新版本的 `latest.json`,兼容现有客户端。不再创建 `upgrade-build-v<version>-<run-id>-<run-attempt>` 构建批次 release。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及该版本 release 中带构建 ID 的固定资产 URL。相同版本的镜像 tag 重新发布时,先上传并校验新资产,再更新版本索引和正式渠道索引;同一提交重跑也允许产生新内容。旧批次资产保留在同一版本 release,已选中旧索引的下载可继续完成,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v<version>-<run-id>-<run-attempt>` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和带构建 ID 的独立安装器;两者都来自该版本 release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同一 release 的 `latest.json` 核对归档 SHA256。
已有安装(默认应用并重启):
```sh
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl-<build-id>.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only
node install-bundle-<build-id>.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-<build-id>.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-<build-id>.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 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
+11
View File
@@ -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 下归档时发生异常。
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
+23
View File
@@ -0,0 +1,23 @@
# 青龙 2.24.0
## 新增功能
- Node.js 原生部署支持 Python venv 隔离环境。Python 依赖可使用包名、Git URL,以及位于脚本或订阅仓库中的 requirements 文件和 pyproject.toml;拉取订阅不会自动安装仓库声明的依赖。
- 新增通知审计日志,便于查看通知发送结果和失败原因。
- 提供经过平台校验的升级包,支持在线暂存、离线安装及失败回退。升级过程保留账号、配置、环境变量、任务和脚本等数据。
## 修复与优化
- 默认 Alpine 与 Debian 镜像统一使用 Node.js 24;SQLite 依赖升级至 1.1.4。ARM64 升级包使用原生构建环境。
- 修复额外调度规则校验、单实例任务运行期间的新触发排队、订阅同步、Python 依赖安装与脚本发现等问题。
- 修复系统日志清理时活跃日志文件被移除、页面加载时序和升级流程中的异常情况,并补齐相关中英文提示。
- 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0。
## 镜像与升级提醒
- `2.24.0` 的 Alpine 镜像支持 amd64、arm/v6、arm/v7、arm64、ppc64le、s390x 和 386;`2.24.0-debian` 支持 amd64、arm64、ppc64le 和 s390x。Debian 暂不提供 arm/v7。
- 2.23.0 是 Python 3.10 兼容镜像的最后一个版本;2.24.0 不再更新该变体。需要 Python 3.10 的用户请先评估迁移方案。
- 升级前停止任务并备份完整持久化数据目录,记录原镜像摘要和启动配置。升级后检查脚本、依赖、订阅与通知;如需回退,请同时恢复与旧版本匹配的数据备份。
- 主 npm 包 `@whyour/qinglong` 与独立远程 CLI 包 `@whyour/qinglong-cli` 均提供 `ql` 命令。在已有面板的主机上安装远程 CLI 时,使用独立 npm prefix 或 `npm exec --package=@whyour/qinglong-cli -- ql ...`,避免覆盖面板内置入口。
本版本以 2.23.0 为上一正式版本。开发候选的源码、npm 包与 11 个发布镜像架构已完成隔离验收;正式产物发布后仍以各目标渠道的实际构建结果为准。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.23.0",
"version": "2.24.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
+168 -112
View File
@@ -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) {
-2
View File
@@ -51,8 +51,6 @@ test('release images receive the same-run artifact and verify it before use', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
const job = workflow.jobs[name];
assert.equal(job.needs, 'build-static');
-2
View File
@@ -273,8 +273,6 @@ test('Gitee workflows verify both mirrors and do not gate image publication', ()
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
}
+1 -1
View File
@@ -220,7 +220,7 @@ test('npm publication consumes the exact archive whose isolated installation pas
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
assert.match(publication.run, /npm publish "\.\/\$archive" --ignore-scripts/);
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
});
+2 -4
View File
@@ -12,12 +12,10 @@ const publication = workflow.jobs.publish.steps.find(
(step) => step.name === 'Publish npm package with OIDC',
).run;
test('npm publication waits for every release image, including Python 3.10', () => {
test('npm publication waits for every supported release image', () => {
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
])
assert.ok(workflow.jobs.publish.needs.includes(name), name);
});
@@ -77,7 +75,7 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
assert.equal(fs.existsSync(path.join(root, 'published')), published);
if (published)
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
'./npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
]);
});
}
+22 -4
View File
@@ -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');
+3 -1
View File
@@ -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`,
+130 -32
View File
@@ -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'),
);
+2 -2
View File
@@ -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,
-2
View File
@@ -225,8 +225,6 @@ test('static backfill uses existing snapshots and skips all source, build and im
'verify-gitee-mirrors',
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
]) {
assert.equal(workflow.jobs[name].needs, 'build-static');
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
+8 -4
View File
@@ -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(
+11 -14
View File
@@ -1,15 +1,12 @@
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
changeLogLink: https://github.com/whyour/qinglong/blob/v2.24.0/docs/releases/2.24.0.md
publishTime: "2026-10-29 02:14:58"
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. Alpine 与 Debian 默认镜像统一使用 Node.js 24,SQLite 依赖升级至 1.1.4
2. 新增 Node.js 原生部署的 Python venv 隔离支持,以及 Python Git URL、requirements、pyproject 依赖源
3. 新增通知审计日志,完善通知失败记录与配置提示
4. 升级包按平台构建并校验;支持在线暂存、离线安装、失败回退及保留数据迁移
5. 修复任务调度、额外规则、依赖安装、脚本发现和订阅同步等问题
6. 独立远程 CLI 更新至 0.1.3,最低 Node.js 版本为 22.12.0
7. Alpine 保留 arm/v6 和 arm/v7;Debian 暂不提供 arm/v7,Python 3.10 兼容镜像停止更新
8. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知