Compare commits

..
Author SHA1 Message Date
whyour e6aad966a9 build: use sqlite3 1.1.3 ARM musl prebuilds 2026-10-24 17:08:09 +08:00
27 changed files with 323 additions and 1143 deletions
-302
View File
@@ -1,302 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const crypto = require('node:crypto');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const base = process.env.QL_URL || 'http://127.0.0.1:5700';
const slug = process.env.QL_ARCH_SLUG || 'unknown';
const platform = process.env.QL_PLATFORM || 'unknown';
const image = process.env.QL_IMAGE || 'unknown';
const expectedCommit = '2efc5e45037662aaf898cd7f8db68421df0908b2';
const output = process.env.QL_OUTPUT || path.resolve('evidence', slug);
const cliPath = process.env.QL_CLI;
const nonce = crypto.randomBytes(4).toString('hex');
const username = `acceptance-${slug}-${nonce}`;
const password = crypto.randomBytes(24).toString('base64url');
const marker = `QL_MULTIARCH_${slug.toUpperCase().replaceAll('-', '_')}_${nonce}`;
const state = { token: '', taskId: null, taskName: `image acceptance ${slug}`, envName: `QL_TEST_${nonce.toUpperCase()}`, envValue: `value_${nonce}` };
const result = { image, platform, slug, expectedCommit, startedAt: new Date().toISOString(), cases: [] };
fs.mkdirSync(output, { recursive: true });
function multiarchWriteResult() {
result.finishedAt = new Date().toISOString();
result.status = result.cases.every(item => item.status === 'PASS') ? 'PASS' : 'FAIL';
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2));
}
async function multiarchRequest(method, route, body, expectedCode = 200) {
const response = await fetch(`${base}/api${route}`, {
method,
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(90000),
});
const text = await response.text();
let data;
try { data = JSON.parse(text); } catch { throw new Error(`${method} ${route}: non-JSON HTTP ${response.status}: ${text.slice(0, 300)}`); }
assert.equal(data.code, expectedCode, `${method} ${route}: ${JSON.stringify(data).slice(0, 500)}`);
return data;
}
function multiarchCli(args) {
assert.ok(cliPath, 'QL_CLI is missing');
const call = spawnSync(process.execPath, [cliPath, ...args, '--json'], {
encoding: 'utf8', timeout: 120000,
env: { ...process.env, QL_URL: base, QL_ACCESS_TOKEN: state.token },
});
assert.equal(call.status, 0, `${args.join(' ')}: ${call.stderr.slice(0, 700)}`);
let response;
try { response = JSON.parse(call.stdout); } catch { throw new Error(`${args.join(' ')}: invalid JSON ${call.stdout.slice(0, 300)}`); }
assert.equal(response.code, 200, `${args.join(' ')}: ${JSON.stringify(response).slice(0, 500)}`);
return response.data;
}
async function multiarchPoll(label, attempt, tries = 80, interval = 3000) {
let last;
for (let i = 0; i < tries; i++) {
last = await attempt();
if (last?.done) return last.value;
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error(`${label} timed out; last=${JSON.stringify(last).slice(0, 500)}`);
}
async function multiarchCase(name, action) {
const started = Date.now();
try {
const details = await action();
result.cases.push({ name, elapsedMs: Date.now() - started, ...(details || {}), status: 'PASS' });
process.stdout.write(`PASS ${name}\n`);
} catch (error) {
result.cases.push({ name, status: 'FAIL', elapsedMs: Date.now() - started, error: String(error.stack || error).slice(0, 2000) });
process.stderr.write(`FAIL ${name}: ${error.stack || error}\n`);
}
multiarchWriteResult();
}
async function multiarchRunTask(extension, source, command, expectedMarker, name) {
const filename = `acceptance_${slug.replaceAll('-', '_')}_${nonce}.${extension}`;
const localFile = path.join(output, filename);
fs.writeFileSync(localFile, source);
multiarchCli(['script', 'create', '--file', localFile, '--data', JSON.stringify({ filename, path: '' })]);
assert.ok(JSON.stringify(multiarchCli(['script', 'get', '--query', JSON.stringify({ file: filename, path: '' })])).includes(expectedMarker));
const task = multiarchCli(['task', 'create', '--data', JSON.stringify({
name,
command: `${command} /ql/data/scripts/${filename}`,
schedule: '0 0 * * *',
extra_schedules: [{ schedule: '30 0 * * *' }],
allow_multiple_instances: 0,
})]);
assert.ok(Number.isInteger(task.id));
const detail = multiarchCli(['task', 'get', String(task.id)]);
assert.ok(JSON.stringify(detail.extra_schedules).includes('30 0 * * *'));
multiarchCli(['task', 'run', String(task.id)]);
const log = await multiarchPoll(`${extension} task`, () => {
const data = multiarchCli(['task', 'logs', String(task.id), '--tail', '200']);
const text = typeof data === 'string' ? data : JSON.stringify(data);
return { done: text.includes(expectedMarker) && (text.includes('Completed') || text.includes('执行结束') || text.includes('完成')), value: text };
}, 90, 2000);
assert.ok(log.includes('Completed') || log.includes('执行结束') || log.includes('完成'), log.slice(0, 400));
fs.writeFileSync(path.join(output, `task-${extension}.log`), log);
return { taskId: task.id, filename, logMarker: expectedMarker };
}
async function multiarchRunUi() {
const { chromium } = require(process.env.PLAYWRIGHT_CORE);
const browser = await chromium.launch({ executablePath: process.env.CHROME_BIN || undefined, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const pages = [
['dashboard', '仪表盘'], ['crontab', '定时任务'], ['subscription', '订阅管理'],
['env', '环境变量'], ['config', '配置文件'], ['script', '脚本管理'],
['dependence', '依赖管理'], ['log', '日志管理'], ['diff', '对比工具'], ['setting', '系统设置'],
];
try {
await page.goto(`${base}/login`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('input[type="text"]').first().fill(username);
await page.locator('input[type="password"]').first().fill(password);
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 60000 });
await page.locator('.ant-notification-notice-close').first().click({ timeout: 3000 }).catch(() => {});
const visited = [];
for (const [route, title] of pages) {
await page.goto(`${base}/${route}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('.ant-layout-content').first().waitFor({ timeout: 30000 });
await page.waitForTimeout(route === 'diff' ? 2500 : 900);
const body = await page.locator('body').innerText();
assert.ok(body.length > 50, `${route}: empty page`);
assert.ok(!body.includes('Application error'), `${route}: application error`);
await page.screenshot({ path: path.join(output, `ui-${route}.png`), fullPage: true });
visited.push({ route, title: await page.title(), bodyLength: body.length });
}
await page.goto(`${base}/crontab`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.getByText(state.taskName, { exact: true }).first().waitFor({ timeout: 30000 });
const row = page.locator('tr').filter({ hasText: state.taskName }).first();
await row.getByText(/^(Log|日志)$/).click();
await page.getByText(marker, { exact: false }).first().waitFor({ timeout: 30000 });
await page.screenshot({ path: path.join(output, 'ui-task-log.png'), fullPage: true });
return { visited };
} finally {
await browser.close();
}
}
async function runMultiarchAcceptance() {
await multiarchCase('health and build identity', async () => {
const health = await multiarchRequest('GET', '/health');
assert.equal(health.data.status, 'ok');
assert.equal(health.data.build.sourceCommit, expectedCommit);
assert.equal(health.data.build.version, '2.24.0-dev.0');
assert.equal(health.data.services.http, true);
assert.equal(health.data.services.grpc, true);
result.build = health.data.build;
return { version: health.data.build.version, sourceCommit: health.data.build.sourceCommit };
});
await multiarchCase('account initialization and login', async () => {
await multiarchRequest('PUT', '/user/init', { username, password });
const login = await multiarchRequest('POST', '/user/login', { username, password });
assert.ok(login.data.token);
state.token = login.data.token;
await multiarchRequest('GET', '/user');
await multiarchRequest('GET', '/user/login-log');
});
if (!state.token) throw new Error('cannot continue without an authenticated test account');
await multiarchCase('CLI access scopes and application list', async () => {
for (const scope of ['crons', 'envs', 'scripts']) {
assert.equal(multiarchCli(['auth', 'status', '--scope', scope]).scopeChecked, scope);
}
multiarchCli(['app', 'list']);
});
await multiarchCase('environment create, edit, disable and enable', async () => {
const created = multiarchCli(['env', 'create', '--data', JSON.stringify([{ name: state.envName, value: state.envValue, remarks: 'multiarch acceptance' }])]);
const envId = Array.isArray(created) ? created[0]?.id : created?.id;
assert.ok(Number.isInteger(envId));
state.envId = envId;
assert.ok(JSON.stringify(multiarchCli(['env', 'list'])).includes(state.envName));
await multiarchRequest('PUT', '/envs', { id: envId, name: state.envName, value: `edited_${nonce}`, remarks: 'edited' });
assert.equal((await multiarchRequest('GET', `/envs/${envId}`)).data.value, `edited_${nonce}`);
await multiarchRequest('PUT', '/envs/disable', [envId]);
await multiarchRequest('PUT', '/envs/enable', [envId]);
await multiarchRequest('PUT', '/envs', { id: envId, name: state.envName, value: state.envValue, remarks: 'restored' });
return { envId };
});
await multiarchCase('configuration read, save and compare source', async () => {
const filename = `acceptance_${slug.replaceAll('-', '_')}.sh`;
const content = `export ${state.envName}=${state.envValue}\n`;
await multiarchRequest('GET', '/configs/samples');
await multiarchRequest('POST', '/configs/save', { name: filename, content });
const files = await multiarchRequest('GET', '/configs/files');
assert.ok(JSON.stringify(files.data).includes(filename));
const detail = await multiarchRequest('GET', `/configs/detail?path=${encodeURIComponent(filename)}`);
assert.ok(JSON.stringify(detail.data).includes(state.envName));
return { filename };
});
await multiarchCase('JavaScript script and scheduled task', async () => {
const source = `console.log('${marker}', process.version, process.env.${state.envName});\n`;
const task = await multiarchRunTask('js', source, 'node', marker, state.taskName);
state.taskId = task.taskId;
const log = fs.readFileSync(path.join(output, 'task-js.log'), 'utf8');
assert.ok(log.includes(state.envValue));
await multiarchRequest('PUT', '/crons/disable', [task.taskId]);
await multiarchRequest('PUT', '/crons/enable', [task.taskId]);
return task;
});
await multiarchCase('Python script and scheduled task', async () => {
const pyMarker = `${marker}_PY`;
return await multiarchRunTask('py', `print('${pyMarker}')\n`, 'python3', pyMarker, `python acceptance ${slug}`);
});
await multiarchCase('Shell script and scheduled task', async () => {
const shMarker = `${marker}_SH`;
return await multiarchRunTask('sh', `#!/bin/bash\necho '${shMarker}'\n`, 'bash', shMarker, `shell acceptance ${slug}`);
});
await multiarchCase('TypeScript script and scheduled task', async () => {
const tsMarker = `${marker}_TS`;
return await multiarchRunTask('ts', `const value: string = '${tsMarker}';\nconsole.log(value);\n`, 'node', tsMarker, `typescript acceptance ${slug}`);
});
await multiarchCase('dashboard and task logs', async () => {
for (const route of ['/dashboard/overview', '/dashboard/trend', '/dashboard/runtime', '/dashboard/system', '/logs']) {
await multiarchRequest('GET', route);
}
assert.ok(JSON.stringify(multiarchCli(['task', 'logs', String(state.taskId), '--tail', '100'])).includes(marker));
});
await multiarchCase('dependency installation and status', async () => {
const created = await multiarchRequest('POST', '/dependencies', [{ name: 'is-number@7.0.0', type: 0, remark: 'multiarch acceptance' }]);
const depId = Array.isArray(created.data) ? created.data[0]?.id : created.data?.id;
assert.ok(Number.isInteger(depId));
const dependency = await multiarchPoll('dependency install', async () => {
const list = await multiarchRequest('GET', '/dependencies');
const doc = list.data.find(item => item.id === depId);
return { done: doc?.status === 1 || doc?.status === 2, value: doc };
}, 120, 5000);
assert.equal(dependency.status, 1, JSON.stringify(dependency).slice(0, 500));
return { dependencyId: depId, status: dependency.status };
});
await multiarchCase('subscription download and automatic task', async () => {
const fixtureMarker = `${marker}_SUB`;
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/javascript' });
response.end(`console.log('${fixtureMarker}');\n`);
});
await new Promise(resolve => server.listen(18080, '0.0.0.0', resolve));
try {
const created = await multiarchRequest('POST', '/subscriptions', {
type: 'file', schedule_type: 'crontab', schedule: '0 0 1 1 *',
name: `subscription acceptance ${slug}`, alias: `sub_${nonce}`,
url: 'http://host.docker.internal:18080/fixture.js', autoAddCron: true, autoDelCron: false,
});
const subscriptionId = created.data?.id;
assert.ok(Number.isInteger(subscriptionId));
await multiarchRequest('PUT', '/subscriptions/run', [subscriptionId]);
const run = await multiarchPoll('subscription download', async () => {
const log = await multiarchRequest('GET', `/subscriptions/${subscriptionId}/log?tail=true`);
const text = typeof log.data === 'string' ? log.data : JSON.stringify(log.data);
const cronList = await multiarchRequest('GET', '/crons');
return { done: text.includes('fixture.js') && JSON.stringify(cronList.data).includes('fixture.js'), value: { log: text, tasks: cronList.data } };
}, 90, 3000);
fs.writeFileSync(path.join(output, 'subscription.log'), run.log);
return { subscriptionId, generatedTask: true };
} finally {
await new Promise(resolve => server.close(resolve));
}
});
await multiarchCase('notification audit and system settings', async () => {
const notify = await multiarchRequest('PUT', '/system/notify', { title: marker, content: 'isolated acceptance without a push provider' }, 400);
assert.equal(notify.code, 400);
const audit = await multiarchPoll('notification audit', async () => {
const logs = await multiarchRequest('GET', '/system/notify-log');
const entry = logs.data.find(item => item.title === marker);
return { done: Boolean(entry), value: entry };
}, 30, 1000);
assert.equal(audit.status, 1);
assert.equal(audit.source, 'system');
await multiarchRequest('GET', '/system');
await multiarchRequest('PUT', '/system/config/panel-title', { panelTitle: `Acceptance ${slug}` });
return { auditStatus: audit.status };
});
await multiarchCase('all panel module routes and screenshots', multiarchRunUi);
multiarchWriteResult();
if (result.status !== 'PASS') process.exitCode = 1;
}
runMultiarchAcceptance().catch(error => {
result.cases.push({ name: 'fatal prerequisite', status: 'FAIL', error: String(error.stack || error).slice(0, 2000) });
multiarchWriteResult();
process.stderr.write(`${error.stack || error}\n`);
process.exitCode = 1;
});
+9 -10
View File
@@ -247,7 +247,7 @@ jobs:
version: "8.3.1"
- uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "20"
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: "24"
node-version: "20"
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/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,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: "24"
node-version: "20"
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: "24"
node-version: "20"
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/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
context: .
file: ./docker/Dockerfile.debian310
push: true
@@ -662,8 +662,7 @@ 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]
# Node 24 can SIGILL while archiving ARM64 musl under x64 QEMU.
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
runs-on: ubuntu-22.04
timeout-minutes: 90
permissions:
contents: read
@@ -680,13 +679,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
@@ -722,7 +721,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
node-version: '20'
- uses: pnpm/action-setup@v6
with:
version: '8.3.1'
@@ -1,116 +0,0 @@
name: Dev multiarch acceptance
on:
push:
branches:
- codex/dev-multiarch-acceptance-2efc5e4
paths:
- '.github/workflows/dev-multiarch-acceptance.yml'
- '.github/scripts/multiarch-acceptance.cjs'
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
image-main-flows:
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 65
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- { slug: develop-amd64, channel: develop, platform: linux/amd64, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-armv6, channel: develop, platform: linux/arm/v6, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-armv7, channel: develop, platform: linux/arm/v7, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-arm64, channel: develop, platform: linux/arm64, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-ppc64le, channel: develop, platform: linux/ppc64le, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-s390x, channel: develop, platform: linux/s390x, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: develop-386, channel: develop, platform: linux/386, digest: 'sha256:1dbee7a5228cd854c14e3d726cdff501c2dec09f96fc32070331faee8953b11c' }
- { slug: debian-dev-amd64, channel: debian-dev, platform: linux/amd64, digest: 'sha256:e3d2f52b8d8f06e4d4a58994ec2af2081d415601bc2076bb7eae23a5bee69f6a' }
- { slug: debian-dev-arm64, channel: debian-dev, platform: linux/arm64, digest: 'sha256:e3d2f52b8d8f06e4d4a58994ec2af2081d415601bc2076bb7eae23a5bee69f6a' }
- { slug: debian-dev-ppc64le, channel: debian-dev, platform: linux/ppc64le, digest: 'sha256:e3d2f52b8d8f06e4d4a58994ec2af2081d415601bc2076bb7eae23a5bee69f6a' }
- { slug: debian-dev-s390x, channel: debian-dev, platform: linux/s390x, digest: 'sha256:e3d2f52b8d8f06e4d4a58994ec2af2081d415601bc2076bb7eae23a5bee69f6a' }
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- uses: docker/setup-qemu-action@v4
if: ${{ matrix.platform != 'linux/amd64' && matrix.platform != 'linux/arm64' }}
with:
cache-image: false
- uses: actions/download-artifact@v7
with:
name: qinglong-cli-2efc5e45037662aaf898cd7f8db68421df0908b2
run-id: '40349395090'
github-token: ${{ secrets.GITHUB_TOKEN }}
path: cli-package
- name: Prepare CLI and browser
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/cli"
tar -xzf cli-package/whyour-qinglong-cli-0.1.3.tgz -C "$RUNNER_TEMP/cli"
test -f "$RUNNER_TEMP/cli/package/dist/npm/ql.js"
npm install --no-save --prefix "$RUNNER_TEMP/browser" playwright-core@1.55.0
if ! command -v google-chrome && ! command -v chromium && ! command -v chromium-browser; then
node "$RUNNER_TEMP/browser/node_modules/playwright-core/cli.js" install --with-deps chromium
fi
- name: Pull and start exact platform image
shell: bash
env:
IMAGE_DIGEST: ${{ matrix.digest }}
PLATFORM: ${{ matrix.platform }}
SLUG: ${{ matrix.slug }}
run: |
set -euo pipefail
image="whyour/qinglong@${IMAGE_DIGEST}"
mkdir -p "evidence/${SLUG}" "$RUNNER_TEMP/qinglong-data-${SLUG}"
docker pull --platform "$PLATFORM" "$image"
docker image inspect "$image" > "evidence/${SLUG}/image-inspect.json"
qemu_env=()
if [ "$PLATFORM" = linux/arm/v6 ]; then qemu_env=(--env QEMU_CPU=arm1176); fi
docker run --detach --name qinglong-acceptance --platform "$PLATFORM" "${qemu_env[@]}" \
--add-host=host.docker.internal:host-gateway \
--publish 127.0.0.1:5700:5700 \
--volume "$RUNNER_TEMP/qinglong-data-${SLUG}:/ql/data" \
"$image"
for attempt in $(seq 1 120); do
if curl --fail --silent http://127.0.0.1:5700/api/health > "evidence/${SLUG}/health.json"; then exit 0; fi
sleep 5
done
docker logs qinglong-acceptance > "evidence/${SLUG}/startup.log" 2>&1 || true
exit 1
- name: Exercise all module main flows and capture UI
shell: bash
env:
QL_URL: http://127.0.0.1:5700
QL_ARCH_SLUG: ${{ matrix.slug }}
QL_PLATFORM: ${{ matrix.platform }}
QL_IMAGE: ${{ matrix.channel }}@${{ matrix.digest }}
QL_OUTPUT: evidence/${{ matrix.slug }}
run: |
set -euo pipefail
export QL_CLI="$RUNNER_TEMP/cli/package/dist/npm/ql.js"
export PLAYWRIGHT_CORE="$RUNNER_TEMP/browser/node_modules/playwright-core"
export CHROME_BIN="$(command -v google-chrome || command -v chromium || command -v chromium-browser || true)"
node .github/scripts/multiarch-acceptance.cjs 2>&1 | tee "evidence/${QL_ARCH_SLUG}/acceptance.log"
- name: Save container logs
if: always()
shell: bash
env:
SLUG: ${{ matrix.slug }}
run: |
mkdir -p "evidence/${SLUG}"
docker logs qinglong-acceptance > "evidence/${SLUG}/container.log" 2>&1 || true
docker inspect qinglong-acceptance > "evidence/${SLUG}/container-inspect.json" 2>&1 || true
docker rm --force qinglong-acceptance || true
- uses: actions/upload-artifact@v6
if: always()
with:
name: dev-multiarch-${{ matrix.slug }}
path: evidence/${{ matrix.slug }}/
if-no-files-found: warn
+2 -2
View File
@@ -69,7 +69,7 @@ jobs:
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,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/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
+28 -85
View File
@@ -4,6 +4,7 @@ import taskLimit from './pLimit';
import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess';
@@ -16,7 +17,7 @@ export function runCron(
const execute = async () => {
try {
if (!isCurrent()) return;
// A single-instance trigger must not interrupt work already in progress.
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
@@ -29,93 +30,35 @@ export function runCron(
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
if (allowSingleInstances) {
// A queued manual run is an active reservation, even if its stored PID
// belongs to a previous execution.
if (
existingCron.status === CrontabStatus.queued &&
existingCron.pid
) {
Logger.info(
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
if (
existingCron.status === CrontabStatus.running &&
existingCron.pid
) {
let missing = false;
try {
process.kill(existingCron.pid, 0);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH') missing = true;
else if (code !== 'EPERM') throw error;
}
if (!missing) {
Logger.info(
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
return;
}
// Capture old instance IDs before releasing the stale cron state so
// a concurrent replacement cannot have its instance marked stopped.
const staleInstances = await RunningInstanceModel.findAll({
attributes: ['id'],
if (
allowSingleInstances &&
existingCron &&
existingCron.pid &&
(existingCron.status === CrontabStatus.running ||
existingCron.status === CrontabStatus.queued)
) {
Logger.info(
t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'),
cron.id,
existingCron.pid,
);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{
where: {
cron_id: Number(cron.id),
pid: existingCron.pid,
status: InstanceStatus.running,
},
});
if (!isCurrent()) return;
const [recovered] = await CrontabModel.update(
{
status: CrontabStatus.idle,
pid: null,
queued_token: null,
} as any,
{
where: {
id: Number(cron.id),
pid: existingCron.pid,
status: CrontabStatus.running,
...(existingCron.log_path !== undefined
? { log_path: existingCron.log_path }
: {}),
...(existingCron.queued_token !== undefined
? { queued_token: existingCron.queued_token }
: {}),
...(existingCron.last_execution_time !== undefined
? { last_execution_time: existingCron.last_execution_time }
: {}),
...(existingCron.isDisabled !== undefined
? { isDisabled: existingCron.isDisabled }
: {}),
allow_multiple_instances: 0,
},
},
);
if (recovered !== 1) return;
const staleIds = staleInstances.map((instance) => instance.id!);
if (staleIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: staleIds, status: InstanceStatus.running } },
);
}
Logger.info(
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
cron.id,
existingCron.pid,
);
}
},
);
// Update the status to idle after killing
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: Number(cron.id) } },
);
}
} catch (error) {
Logger.error(
@@ -126,7 +69,7 @@ export function runCron(
throw error;
}
// Invalidation can happen during the asynchronous database check.
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
t('[schedule][开始执行任务] 参数 %s'),
+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+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m)?.[1];
.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
export const runtimeBuild = Object.freeze({
instanceId: randomUUID(),
@@ -61,9 +61,7 @@ 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+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
const version = text.match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
if (!version) throw new Error('Invalid update version');
return version;
}
@@ -92,9 +92,7 @@ async function prestartBackend(
.digest('hex');
const version = (
await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8')
).match(
/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)/m,
)?.[1];
).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1];
// An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and
// package-manager settings from escaping into the disposable backend.
const env = {
+2 -3
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:24.18.1-alpine3.24 AS builder
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 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-alpine3.24
FROM python:3.11-alpine
ARG TARGETARCH
ARG TARGETVARIANT
@@ -80,7 +80,6 @@ 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 \
+2 -3
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:24.18.1-alpine3.24 AS builder
FROM --platform=$BUILDPLATFORM node:18-alpine3.18 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-alpine3.24
FROM python:3.10-alpine
ARG TARGETARCH
ARG TARGETVARIANT
@@ -80,7 +80,6 @@ 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 -9
View File
@@ -1,10 +1,6 @@
# 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}"
# 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
FROM python:3.11.14-slim-bookworm AS builder
COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
@@ -15,7 +11,6 @@ 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
@@ -76,7 +71,6 @@ 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 && \
+4 -10
View File
@@ -1,12 +1,8 @@
# 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}"
# 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
# Keep s390x on the same immutable Python base as the other Debian targets.
# Keep the full release matrix, including s390x, on the same immutable base.
# 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/
@@ -17,7 +13,6 @@ 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
@@ -76,7 +71,6 @@ 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 && \
+7 -5
View File
@@ -6,11 +6,13 @@ ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
USER root
RUN ulimit -c 0; \
if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \
export QEMU_CPU=arm1176; \
set -- --jitless; \
fi; \
node "$@" /ql/scripts/build-upgrade-bundle.cjs /ql /out
node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \
for archive in /out/*.tar.gz; do \
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \
rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \
done; \
else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi
FROM scratch
COPY --from=bundle /out/ /
-22
View File
@@ -1,22 +0,0 @@
#!/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` 这一个 prerelease。`develop` 和 `debian-dev` 镜像读取其中的 `latest.json`,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。新批次先以带构建 ID 的文件名上传并校验,再切换 `latest.json`;索引仍保留开发版本供客户端判断更新。开发渠道不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 prerelease;不删除正式包或无法分类的 release。构建或上传失败不会删除上一套可用包。旧资产被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 glibc 平台包。
开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。
每个正式版本只使用一个 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 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
版本入口为 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 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
## 手动离线安装和升级
从 `upgrade-v<version>` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和带构建 ID 的独立安装器;两者都来自该版本 release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同一 release 的 `latest.json` 核对归档 SHA256。
从 `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。
已有安装(默认应用并重启):
```sh
node install-bundle-<build-id>.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl-<build-id>.tar.gz
node install-bundle.cjs --root /ql --file /absolute/path/qinglong-<commit>-linux-arm64-musl.tar.gz
```
只准备,稍后手动重启:
```sh
node install-bundle-<build-id>.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-<build-id>.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-<build-id>.cjs --root /ql --file /absolute/path/package.tar.
安装到空目录:
```sh
node install-bundle-<build-id>.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 位系统)不在平台包支持范围内,不能使用其他架构或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 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 保留独立包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
- ARMv6 与 ARMv7 使用不同包名。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
@@ -1,11 +0,0 @@
# 青龙 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 下归档时发生异常。
升级前请备份完整持久化数据,并在目标设备验证脚本、依赖和通知。正式发布说明将在候选验收后整理。
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@whyour/qinglong",
"packageManager": "pnpm@8.3.1",
"version": "2.24.0-dev.0",
"version": "2.23.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
@@ -111,7 +111,7 @@
}
},
"overrides": {
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
@@ -159,7 +159,7 @@
"request-ip": "3.3.0",
"sequelize": "6.37.8",
"sockjs": "^0.3.24",
"sqlite3": "npm:@whyour/sqlite3@1.1.4",
"sqlite3": "npm:@whyour/sqlite3@1.1.3",
"toad-scheduler": "^3.0.1",
"typedi": "^0.10.0",
"undici": "^7.9.0",
+9 -9
View File
@@ -1,7 +1,7 @@
lockfileVersion: '6.0'
overrides:
sqlite3: npm:@whyour/sqlite3@1.1.4
sqlite3: npm:@whyour/sqlite3@1.1.3
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
proxy-addr: 2.0.8
@@ -120,13 +120,13 @@ dependencies:
version: 3.3.0
sequelize:
specifier: 6.37.8
version: 6.37.8(@whyour/sqlite3@1.1.4)
version: 6.37.8(@whyour/sqlite3@1.1.3)
sockjs:
specifier: ^0.3.24
version: 0.3.24
sqlite3:
specifier: npm:@whyour/sqlite3@1.1.4
version: /@whyour/sqlite3@1.1.4
specifier: npm:@whyour/sqlite3@1.1.3
version: /@whyour/sqlite3@1.1.3
tar:
specifier: 7.5.20
version: 7.5.20
@@ -3193,7 +3193,7 @@ packages:
resolution: {integrity: sha512-Ngs9jhElXN7efS9WvvCC/p6rXMbihna8eNLVBc421Zf+VcFd+pR4DOcS6yA9V22EKtAy4DEj7LtvEEIm0bb80A==}
engines: {node: '>= 18'}
dependencies:
sqlite3: /@whyour/sqlite3@1.1.4
sqlite3: /@whyour/sqlite3@1.1.3
transitivePeerDependencies:
- encoding
- supports-color
@@ -5611,8 +5611,8 @@ packages:
- supports-color
dev: true
/@whyour/sqlite3@1.1.4:
resolution: {integrity: sha512-MlWzqOdpZ0KPuBx/FYRrHZ8p3S0eCbaf2d8duDzWmfko/aky25Xoss67qDjFgbRjnwj9z0i0KNdNtQI02dnWKA==}
/@whyour/sqlite3@1.1.3:
resolution: {integrity: sha512-VeiureU2gq11OMeSMWlpckNfbY5QF+V0KEpRi1Zqh/s5GEPgCazt06xIg528tPAwK0MS9KCUEyLb+dKDIIFoEg==}
requiresBuild: true
peerDependenciesMeta:
node-gyp:
@@ -13903,7 +13903,7 @@ packages:
engines: {node: '>= 10.0.0'}
dev: false
/sequelize@6.37.8(@whyour/sqlite3@1.1.4):
/sequelize@6.37.8(@whyour/sqlite3@1.1.3):
resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==}
engines: {node: '>=10.0.0'}
peerDependencies:
@@ -13948,7 +13948,7 @@ packages:
retry-as-promised: 7.0.4
semver: 7.7.4
sequelize-pool: 7.1.0
sqlite3: /@whyour/sqlite3@1.1.4
sqlite3: /@whyour/sqlite3@1.1.3
toposort-class: 1.0.1
uuid: 8.3.2
validator: 13.12.0
+114 -170
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}`, assetBuildId } = options;
const { version, releaseTag = `upgrade-${commit}` } = options;
if (
!/^[a-f0-9]{40}$/.test(commit) ||
!/^[\w.-]+\/[\w.-]+$/.test(repository) ||
!/^[\w.+-]+$/.test(releaseTag) ||
(assetBuildId && !/^\d+-\d+$/.test(assetBuildId))
!/^[\w.+-]+$/.test(releaseTag)
)
throw new Error('Invalid release source');
const packages = {};
@@ -48,48 +48,11 @@ function releaseIndex(directory, commit, repository, options = {}) {
sha256: record.sha256,
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
releaseTag,
)}/${
assetBuildId
? filename.replace(/\.tar\.gz$/, `-${assetBuildId}.tar.gz`)
: filename
}`,
)}/${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,
@@ -113,12 +76,13 @@ 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 = development ? 'upgrade-develop' : `upgrade-v${version}`;
const tag = `upgrade-build-${
development ? 'develop-' : ''
}v${version}-${buildId}`;
const index = {
...releaseIndex(directory, commit, repository, {
version,
releaseTag: tag,
assetBuildId: buildId,
}),
version,
buildId,
@@ -130,7 +94,7 @@ function publishBundles({
sha256: sha256(installer),
url: `https://github.com/${repository}/releases/download/${encodeURIComponent(
tag,
)}/install-bundle-${buildId}.cjs`,
)}/install-bundle.cjs`,
};
fs.writeFileSync(
path.join(directory, 'latest.json'),
@@ -150,9 +114,7 @@ function publishBundles({
}
};
const notes =
`${
development ? 'Development' : 'Verified'
} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
`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') +
@@ -186,136 +148,118 @@ function publishBundles({
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(
tag,
development ? 'Upgrade channel develop' : `Platform upgrade v${version}`,
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 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)}`
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)}`,
);
};
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;
}
if (missing.length)
gh('release', 'upload', tag, ...missing, '--repo', repository);
const uploaded = lookup(tag);
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 (
!assets.every((file) =>
matches(
uploaded.assets.find((a) => a.name === path.basename(file)),
file,
),
!matches(
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
path.join(directory, 'latest.json'),
)
)
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 });
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',
);
}
}
}
return true;
}
module.exports = { releaseIndex, platforms, publishBundles };
if (require.main === module) {
+4 -22
View File
@@ -19,7 +19,7 @@ const {
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
async function fixture(t, targetVersion = '2.24.0') {
async function fixture(t) {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
const root = path.join(base, 'panel'),
tmp = path.join(root, '.tmp');
@@ -30,10 +30,7 @@ async function fixture(t, targetVersion = '2.24.0') {
await fs.writeFile(name, contents);
};
for (const dir of [root, source]) {
await write(
path.join(dir, 'package.json'),
JSON.stringify({ version: targetVersion }),
);
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
}
@@ -42,7 +39,7 @@ async function fixture(t, targetVersion = '2.24.0') {
'installed dependency',
);
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
await write(path.join(source, 'version.yaml'), `version: ${targetVersion}`);
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
writeManifest(source, staticRoot);
for (const repo of ['qinglong', 'qinglong-static'])
@@ -84,7 +81,7 @@ fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
);
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
const runtime = {
version: targetVersion,
version: '2.24.0',
sourceCommit,
instanceId: 'after-restart',
parentPid: 100002,
@@ -189,21 +186,6 @@ 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');
+1 -3
View File
@@ -591,9 +591,7 @@ 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, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
assert.equal(Object.keys(index.packages).length, 12);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
+32 -130
View File
@@ -66,17 +66,6 @@ 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;
@@ -138,11 +127,7 @@ function fixture(t) {
digest: `sha256:${sha256(bytes)}`,
});
}
if (
files.some((file) => path.basename(file) === 'latest.json') &&
((tag === 'upgrade-develop' && state.corruptChannel) ||
tag === state.corruptIndexTag)
)
if (tag === 'upgrade-develop' && state.corruptChannel)
release.get('latest.json').digest = 'sha256:wrong';
if (files.length > 1) {
if (state.corruptUploadedBatch)
@@ -177,19 +162,14 @@ 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, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
assert.equal(Object.keys(index.packages).length, 12);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
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'));
const tag = 'upgrade-build-v2.24.0-100-1';
assert.equal(f.state.releases.get(tag).size, 26);
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);
@@ -199,7 +179,7 @@ test('publishes every platform and installer before advancing version and legacy
);
assert.deepEqual(
writes.map((a) => a[2]),
[tag, tag, 'upgrade-master'],
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
);
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
@@ -212,10 +192,10 @@ for (const sameCommit of [false, true])
f.publish();
const before = f.index();
const oldAssets = new Map(
Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => name !== 'latest.json'),
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
);
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
f.state.head = commit;
@@ -237,14 +217,13 @@ 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-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => oldAssets.has(name)),
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
),
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) => {
@@ -255,8 +234,7 @@ test('retrying a publication is idempotent, but changed bytes require a new buil
(a) =>
a[0] === 'release' &&
a[1] === 'upload' &&
a[2] === 'upgrade-v2.24.0' &&
!a.includes('--clobber'),
a[2] === 'upgrade-build-v2.24.0-100-1',
).length;
assert.equal(count(), 1);
f.publish();
@@ -266,41 +244,6 @@ 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);
@@ -340,7 +283,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.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
f.state.head = f.options.commit;
f.state.changeHeadAfterUpload = true;
assert.equal(f.publish({ buildId: '102-1' }), false);
@@ -392,58 +335,38 @@ test('existing master clients can migrate from indexes without version/build met
assert.equal(f.index().buildId, '100-2');
});
test('automatic development publication keeps its prerelease version separate from production assets', (t) => {
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
const developmentVersion = '2.24.0-dev.0';
f.write(f.state.developHead, 'development', developmentVersion);
f.write(f.state.developHead, 'development');
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, 11);
assert.equal(Object.keys(development.packages).length, 12);
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-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.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
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(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(sidecar.filename, archiveName);
});
test('development builds cannot publish obsolete development source', (t) => {
@@ -493,21 +416,14 @@ 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-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);
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
// 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-10-1',
'upgrade-build-develop-11-1',
'upgrade-build-develop-v2.24.0-10-1',
'upgrade-build-develop-v2.24.0-11-1',
];
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
f.state.releaseMetadata = {
@@ -525,7 +441,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] === 'api' && a[1] === '-X' && a[2] === 'DELETE',
(a) => a[0] === 'release' && a[1] === 'delete',
);
const aliasEdit = f.state.calls.findLastIndex(
(a) => a[0] === 'release' && a[1] === 'edit',
@@ -539,15 +455,9 @@ for (const fault of [
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
),
);
assert.ok(f.state.releases.has('upgrade-develop'));
assert.ok(!f.state.releases.has('upgrade-build-develop-101-1'));
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-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);
@@ -558,21 +468,17 @@ 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.get('upgrade-develop').has(oldAsset));
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
f.state.failDelete = false;
f.publish({ ...options, buildId: '101-1' });
assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset));
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
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'));
});
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
@@ -581,14 +487,10 @@ 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.get('upgrade-develop').has(oldAsset));
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
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-dev.0');
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.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-dev.0');
assert.equal(running.version, '2.24.0');
assert.equal(running.sourceCommit, 'a'.repeat(40));
assert.equal(
running.backendSha256,
-192
View File
@@ -1,192 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
function fixture(row, { claim = true, onSpawn = () => {} } = {}) {
const spawned = [],
cronWrites = [],
instanceWrites = [],
instanceReads = [];
const { runCron } = load('back/shared/runCron.ts', {
'./i18n': { t: (s) => s },
'cross-spawn': {
spawn: (cmd) => {
spawned.push(cmd);
onSpawn(row);
return { pid: process.pid };
},
},
'./pLimit': {
runWithCronLimit: (_cron, execute) => execute(),
removeQueuedCron() {},
},
'../loaders/logger': { info() {}, error() {} },
'../data/cron': {
CrontabModel: {
findOne: async () => row,
update: async (...args) => {
cronWrites.push(args);
if (!claim) return [0];
Object.assign(row, args[0]);
return [1];
},
},
CrontabStatus: { running: 0, idle: 1, queued: 3 },
},
'../data/runningInstance': {
RunningInstanceModel: {
findAll: async (options) => {
instanceReads.push(options);
return [{ id: 7 }];
},
update: async (...args) => instanceWrites.push(args),
},
InstanceStatus: { running: 0, stopped: 2 },
},
dayjs: () => ({ unix: () => 123 }),
'./childProcess': {
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
asError: (e) => e,
},
});
return {
run: () => runCron('task example.py', { id: '1' }),
spawned,
cronWrites,
instanceWrites,
instanceReads,
};
}
function exitedPid() {
const child = spawnSync(process.execPath, ['-e', '']);
assert.equal(child.status, 0);
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
return child.pid;
}
test('running single-instance task stays untouched while its process is alive', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: process.pid,
status: 0,
});
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
assert.deepEqual(f.instanceWrites, []);
});
test('queued single-instance task keeps its reservation even with an exited PID', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: exitedPid(),
status: 3,
});
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
assert.deepEqual(f.instanceWrites, []);
});
test('exited running task is recovered and the next trigger sees the new active run', async () => {
const oldPid = exitedPid();
const f = fixture(
{
allow_multiple_instances: 0,
pid: oldPid,
status: 0,
log_path: 'old.log',
queued_token: 'old-token',
last_execution_time: 10,
isDisabled: 0,
},
{
onSpawn: (row) => {
row.status = 0;
row.pid = process.pid;
},
},
);
await f.run();
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.instanceReads, [
{
attributes: ['id'],
where: { cron_id: 1, pid: oldPid, status: 0 },
},
]);
assert.deepEqual(f.cronWrites, [
[
{ status: 1, pid: null, queued_token: null },
{
where: {
id: 1,
pid: oldPid,
status: 0,
log_path: 'old.log',
queued_token: 'old-token',
last_execution_time: 10,
isDisabled: 0,
allow_multiple_instances: 0,
},
},
],
]);
assert.deepEqual(f.instanceWrites, [
[{ status: 2, finished_at: 123 }, { where: { id: [7], status: 0 } }],
]);
});
test('a lost stale-state claim does not spawn or change running instances', async () => {
const f = fixture(
{ allow_multiple_instances: 0, pid: exitedPid(), status: 0 },
{ claim: false },
);
await f.run();
assert.equal(f.cronWrites.length, 1);
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.instanceWrites, []);
});
test('EPERM means an existing process must still block a new run', async () => {
const pid = exitedPid();
const originalKill = process.kill;
process.kill = (target, signal) => {
if (target === pid && signal === 0)
throw Object.assign(new Error('denied'), { code: 'EPERM' });
return originalKill(target, signal);
};
try {
const f = fixture({ allow_multiple_instances: 0, pid, status: 0 });
await f.run();
assert.deepEqual(f.spawned, []);
assert.deepEqual(f.cronWrites, []);
} finally {
process.kill = originalKill;
}
});
test('single-instance still starts idle tasks with a stale stored PID', async () => {
const f = fixture({
allow_multiple_instances: 0,
pid: exitedPid(),
status: 1,
});
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.cronWrites, []);
});
test('multi-instance keeps its existing concurrent execution behavior', async () => {
const f = fixture({
allow_multiple_instances: 1,
pid: process.pid,
status: 0,
});
await f.run();
assert.equal(f.spawned.length, 1);
assert.deepEqual(f.cronWrites, []);
});
+69
View File
@@ -4,6 +4,75 @@ const load = require('../helpers/load-security-module.cjs');
const logger = { info() {}, error() {} };
function cronFixture(killTask) {
const events = [];
const { runCron } = load('back/shared/runCron.ts', {
'cross-spawn': {
spawn: () => {
events.push('spawn');
return {};
},
},
'./childProcess': {
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
asError: (e) => e,
},
'./pLimit': {
runWithCronLimit: async (_, fn) => fn(),
removeQueuedCron: () => events.push('release'),
},
'../loaders/logger': logger,
'../config/util': { killTask },
'../data/cron': {
CrontabModel: {
findOne: async () => ({
pid: 100,
status: 0,
allow_multiple_instances: 0,
}),
update: async () => events.push('idle'),
},
CrontabStatus: { running: 0, idle: 1, queued: 3 },
},
'../data/runningInstance': {
RunningInstanceModel: { update: async () => events.push('stopped') },
InstanceStatus: { running: 0, stopped: 2 },
},
});
return { run: () => runCron('ignored', { id: '1' }), events };
}
test('single-instance replacement does not spawn or finalize when termination fails', async () => {
const f = cronFixture(async () => {
throw Error('EPERM');
});
await f.run();
assert.deepEqual(f.events, ['release']);
});
test('single-instance replacement waits for verified exit before spawning', async () => {
let finish, entered, verified;
const pending = new Promise((resolve) => {
entered = resolve;
});
const gate = new Promise((resolve) => {
finish = resolve;
});
const f = cronFixture(async (pid, wait) => {
assert.equal(pid, 100);
verified = wait;
entered();
await gate;
});
const running = f.run();
await pending;
assert.deepEqual(f.events, []);
finish();
await running;
assert.equal(verified, true);
assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']);
});
function subscriptionFixture(killTask) {
const updates = [];
const Service = load('back/services/subscription.ts', {
+4 -8
View File
@@ -10,12 +10,8 @@ const {
repairDependencies,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
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) => {
for (const exitCode of [0, 1])
test(`prestart 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'),
@@ -23,7 +19,7 @@ for (const [version, exitCode] of [
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: ${version}`);
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
@@ -40,7 +36,7 @@ for (const [version, exitCode] of [
)}],{stdio:'inherit'});
process.send({type:'upgrade-probe',ok:${
exitCode === 0
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:${JSON.stringify(version)},backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
`,
);
const operation = prestartBackend(
+14 -8
View File
@@ -1,9 +1,15 @@
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"
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"
changeLog: |
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. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知
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 兼容镜像的最后一个版本,发布后停止维护;升级前备份完整数据并验证脚本、依赖和通知