Compare commits

..
12 changed files with 593 additions and 40 deletions
+302
View File
@@ -0,0 +1,302 @@
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;
});
+147 -2
View File
@@ -405,6 +405,151 @@ 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: "24"
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: "24"
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/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:
@@ -471,7 +616,7 @@ jobs:
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, verify-npm-package]
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -507,7 +652,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
@@ -0,0 +1,116 @@
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
+8
View File
@@ -70,6 +70,14 @@ jobs:
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,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/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
-23
View File
@@ -1,23 +0,0 @@
# 青龙 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.24.0",
"version": "2.24.0-dev.0",
"description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
"repository": {
"type": "git",
+2
View File
@@ -51,6 +51,8 @@ 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,6 +273,8 @@ 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 || '')));
});
+4 -2
View File
@@ -12,10 +12,12 @@ const publication = workflow.jobs.publish.steps.find(
(step) => step.name === 'Publish npm package with OIDC',
).run;
test('npm publication waits for every supported release image', () => {
test('npm publication waits for every release image, including Python 3.10', () => {
for (const name of [
'build-alpine',
'build-debian',
'build-alpine310',
'build-debian310',
])
assert.ok(workflow.jobs.publish.needs.includes(name), name);
});
@@ -75,7 +77,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',
]);
});
}
+2
View File
@@ -225,6 +225,8 @@ 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 -11
View File
@@ -1,12 +1,9 @@
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"
version: 2.24.0-dev.0
changeLogLink: https://github.com/whyour/qinglong/blob/develop/docs/releases/2.24.0-dev.0.md
publishTime: "2026-10-25 19:22:54"
changeLog: |
1. 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. 升级前备份完整数据,并在目标设备验证脚本、依赖、订阅和通知
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. 升级前备份完整数据,并在目标设备验证脚本、依赖和通知