mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-04 09:28:35 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ecc5bb01b | ||
|
|
af93fb6cf0 | ||
|
|
2218c2c175 | ||
|
|
32c20ba556 |
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
@@ -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-<run-id>-<run-attempt>`,tag 不带版本号;索引中仍保留开发版本供客户端判断更新。开发批次不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
||||
|
||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,清理 `upgrade-develop` 中旧批次资产及旧命名的开发构建 prerelease;不删除正式包或无法分类的 release。构建或上传失败不会删除上一套可用包。旧资产被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||
|
||||
开发和正式包都必须在 11 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 和 ARMv7 仅提供 musl 包,Debian 不再发布 ARMv7 镜像或 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,7 +58,7 @@ 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
|
||||
```
|
||||
|
||||
|
||||
+118
-170
@@ -18,12 +18,11 @@ const platforms = [
|
||||
'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 +47,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 +75,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 = development
|
||||
? `upgrade-build-develop-${buildId}`
|
||||
: `upgrade-build-v${version}-${buildId}`;
|
||||
const index = {
|
||||
...releaseIndex(directory, commit, repository, {
|
||||
version,
|
||||
releaseTag: tag,
|
||||
assetBuildId: buildId,
|
||||
}),
|
||||
version,
|
||||
buildId,
|
||||
@@ -130,7 +93,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 +113,7 @@ function publishBundles({
|
||||
}
|
||||
};
|
||||
const notes =
|
||||
`${
|
||||
development ? 'Development' : 'Verified'
|
||||
} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
|
||||
`${development ? 'Development' : 'Verified'} platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` +
|
||||
Object.entries(index.packages)
|
||||
.map(([target, entry]) => `- [${target}](${entry.url})`)
|
||||
.join('\n') +
|
||||
@@ -186,136 +147,123 @@ 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,
|
||||
development
|
||||
? `Development platform upgrade (${buildId})`
|
||||
: `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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -181,15 +166,12 @@ test('publishes every platform and installer before advancing version and legacy
|
||||
assert.ok(index.packages['linux-armv7-musl']);
|
||||
assert.equal(index.packages['linux-armv7-glibc'], undefined);
|
||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||
const tag = 'upgrade-v2.24.0';
|
||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||
assert.equal(f.state.releases.get(tag).size, 24);
|
||||
assert.equal(f.state.releases.get('upgrade-master').size, 1);
|
||||
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-100-1'));
|
||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||
const url = new URL(entry.url),
|
||||
filename = path.basename(url.pathname);
|
||||
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
|
||||
assert.ok(filename.includes('-100-1.'));
|
||||
const bytes = f.state.releases.get(tag).get(filename).bytes;
|
||||
assert.equal(bytes.length, entry.size);
|
||||
assert.equal(sha256(bytes), entry.sha256);
|
||||
@@ -199,7 +181,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 +194,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 +219,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 +236,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 +246,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 +285,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);
|
||||
@@ -421,29 +366,24 @@ test('automatic development publication keeps its prerelease version separate fr
|
||||
...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(entry.url.includes('/upgrade-build-develop-100-1/'));
|
||||
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',
|
||||
args[2] === 'upgrade-build-develop-100-1' &&
|
||||
args[args.indexOf('--title') + 1] ===
|
||||
'Development platform upgrade (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, 24);
|
||||
assert.equal(
|
||||
f.state.releases.get('upgrade-build-develop-100-1').size,
|
||||
24,
|
||||
);
|
||||
assert.equal(sidecar.filename, archiveName);
|
||||
});
|
||||
|
||||
test('development builds cannot publish obsolete development source', (t) => {
|
||||
@@ -495,12 +435,7 @@ for (const fault of [
|
||||
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);
|
||||
// Never prune production, channel aliases, legacy/unclassified releases,
|
||||
// drafts or a non-prerelease even when its name resembles a dev build.
|
||||
const protectedTags = [
|
||||
@@ -525,7 +460,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 +474,10 @@ 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-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 +488,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-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-100-1'));
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-101-1'));
|
||||
});
|
||||
|
||||
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
||||
@@ -581,14 +507,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-100-1'));
|
||||
assert.ok(
|
||||
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user