fix: ship verified npm panel packages and test production migrations

This commit is contained in:
whyour
2026-10-19 14:17:53 +08:00
parent e8b6a30bd9
commit 3f02b04dff
10 changed files with 747 additions and 28 deletions
+91 -20
View File
@@ -94,6 +94,9 @@ jobs:
pnpm build:back
pnpm build:info
- name: Pack the npm panel archive from the verified build
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
@@ -101,6 +104,13 @@ jobs:
if-no-files-found: error
include-hidden-files: true
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
if-no-files-found: error
retention-days: 14
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
@@ -538,30 +548,78 @@ jobs:
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:
needs: build-debian
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Export the official platform package from this run's image digest
env:
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
run: |
set -euo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
- uses: actions/upload-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/*
if-no-files-found: error
retention-days: 3
verify-npm-package:
needs: [build-static, build-npm-migration-bundle]
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
node: ['20.18.1', '24']
container: node:${{ matrix.node }}-bookworm
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel/
- uses: actions/download-artifact@v6
with:
name: npm-migration-bundle-${{ github.sha }}
path: ${{ runner.temp }}/npm-migration-bundle/
- name: Install isolated service test tools
run: |
apt-get update
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
# The published legacy launcher resets PATH to standard directories.
# /usr/local is confined to this disposable job container; PM2_HOME
# remains private to each verification run.
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
- name: Install and upgrade the exact npm archive
env:
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
- uses: actions/upload-artifact@v6
with:
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
path: ${{ runner.temp }}/npm-panel-results/
if-no-files-found: error
retention-days: 14
publish:
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: "8.3.1"
- uses: actions/setup-node@v3
with:
node-version: "20"
cache: "pnpm"
- name: build front and back
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: Set up Node for trusted publishing
uses: actions/setup-node@v6
with:
@@ -569,17 +627,30 @@ jobs:
registry-url: https://registry.npmjs.org
package-manager-cache: false
- uses: actions/download-artifact@v6
with:
name: qinglong-npm-${{ github.sha }}
path: npm-panel/
- name: Publish npm package with OIDC
shell: bash
run: |
set -euo pipefail
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
shopt -s nullglob
archives=(npm-panel/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified panel npm archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
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 --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
+1
View File
@@ -29,6 +29,7 @@ __pycache__
/shell/preload/*-notify.json
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/upgrade-lock.yaml
.deepseek/
.claude/
+10 -4
View File
@@ -4,19 +4,25 @@
/.history/
/back/**/*.ts
/back/**/*.json
/cli/
/data/
/src/
/static/**/*.js.map
/static/**/*.gz
/.editorconfig
/.gitignore
/.prettierignore
/.prettierrc
/.umirc.ts
/nodemon.json
/pnpm-lock.yaml
/tsconfig.back.json
/tsconfig.json
/typings.d.ts
/.env
/sample/tool.ts
/shell/pub.sh
/shell/preload/env.*
/shell/preload/notify.*
/shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
/shell/preload/*-notify.json
**/__pycache__/
**/*.pyc
**/.DS_Store
@@ -64,6 +64,20 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
## npm 面板包
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
通过 npm 覆盖升级前,应先停止服务,备份安装目录中的自定义 `.env`,执行新包安装,再恢复 `.env` 并启动服务;持久数据必须放在包目录以外的 `QL_DATA_DIR`。npm 会替换整个包目录,不负责保留包内用户文件。验收按这一流程备份和恢复自定义 `.env`,并直接校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
## 平台兼容
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
+42 -1
View File
@@ -12,6 +12,45 @@
"bugs": {
"url": "https://github.com/whyour/qinglong/issues"
},
"engines": {
"node": ">=20.18.1"
},
"files": [
".env.example",
"back/protos/api.proto",
"cli/src/internal/maintenance/*.cjs",
"ecosystem.config.js",
"README*.md",
"sample/*.sample.*",
"sample/notify.js",
"sample/notify.py",
"sample/ql_sample.js",
"sample/ql_sample.py",
"shell/*.cjs",
"shell/api.sh",
"shell/bot.sh",
"shell/check.sh",
"shell/env.sh",
"shell/lang/*.sh",
"shell/node_path_cache.sh",
"shell/otask.sh",
"shell/preload/client.js",
"shell/preload/client.py",
"shell/preload/esm-loader.mjs",
"shell/preload/notificationAudit.js",
"shell/preload/sitecustomize.js",
"shell/preload/sitecustomize.py",
"shell/rmlog.sh",
"shell/share.sh",
"shell/start-simplify.sh",
"shell/start.sh",
"shell/task.sh",
"shell/task-timeout.sh",
"shell/update.sh",
"shell/upgrade-lock.yaml",
"static/",
"version.yaml"
],
"scripts": {
"build:cli": "node cli/scripts/build.cjs",
"check:cli": "npm --prefix cli run check",
@@ -28,7 +67,9 @@
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true",
"prepack": "node scripts/prepare-npm-package.cjs",
"postpack": "node scripts/prepare-npm-package.cjs --clean",
"postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').restorePackageLock(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)",
"start:simple": "bash shell/start-simplify.sh"
},
"gitHooks": {
+20
View File
@@ -0,0 +1,20 @@
// npm-packlist always excludes the root pnpm lockfile, even with a files entry.
// Carry a checked copy under a runtime filename and restore it after installation.
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const copy = path.join(root, 'shell/upgrade-lock.yaml');
if (process.argv.includes('--clean')) {
fs.rmSync(copy, { force: true });
} else {
if (execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }).trim())
throw new Error('Pack only from a clean source checkout');
// The native npm pack/publish hooks must enforce the same checks as CI:
// current commit, clean build, lockfile and every output file's checksum.
// Capture stdout so npm pack --json remains machine-readable.
execFileSync(process.execPath, [path.join(root, 'docker/verify-build.cjs')], { cwd: root });
const bytes = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'));
fs.rmSync(copy, { force: true });
fs.writeFileSync(copy, bytes, { flag: 'wx', mode: 0o644 });
}
+369
View File
@@ -0,0 +1,369 @@
// Run service tests only inside a disposable Linux container or CI job.
// Unlike source-tree tests, these load the published entrypoints and dependencies.
const fs = require('node:fs/promises');
const sync = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const net = require('node:net');
const assert = require('node:assert/strict');
const { createHash, randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { setTimeout: delay } = require('node:timers/promises');
const execute = promisify(execFile);
const repository = path.resolve(__dirname, '..');
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
const run = (program, args, options = {}) => execute(program, args, {
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000,
...options,
});
const requiredFiles = [
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
'shell/upgrade-lock.yaml', 'shell/start.sh', 'shell/start-simplify.sh',
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
'sample/config.sample.sh', 'sample/auth.sample.json',
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
'static/build/bootstrap/upgradeProbe.js', 'static/dist/index.html',
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade']
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
];
function checkPackedFiles(files) {
const names = new Set(files.map((file) => file.path));
for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`);
for (const name of names) {
assert.ok(/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(name), `Unexpected npm file: ${name}`);
assert.ok(!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(name), `Generated or release-only runtime file: ${name}`);
assert.ok(!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name);
assert.notEqual(name, 'sample/tool.ts');
}
}
async function packPanel(output) {
const git = (...args) => run('git', args, { cwd: repository });
assert.equal((await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout');
await run(process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository });
await fs.mkdir(output, { recursive: true });
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
try {
const result = await run('npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository });
const [manifest] = JSON.parse(result.stdout);
checkPackedFiles(manifest.files);
assert.equal(manifest.name, '@whyour/qinglong');
const build = await read(path.join(repository, 'static/build-info.json'));
const names = new Set(manifest.files.map((file) => file.path));
for (const name of Object.keys(build.files)) assert.ok(names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`);
const receipt = {
filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length,
};
await fs.writeFile(path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
return receipt;
} finally {
await fs.rm(cache, { recursive: true, force: true });
// Also recover an interrupted npm pack whose postpack hook did not execute.
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true });
}
}
async function unusedPort() {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise((resolve) => server.close(resolve));
return port;
}
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
async function platformFixture(root, directory, expectedCommit) {
assert.ok(directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output');
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'));
assert.equal(names.length, 1, 'Expected one official platform package');
const archive = path.resolve(directory, names[0]);
const record = await read(archive + '.json');
const bytes = await fs.readFile(archive);
assert.equal(record.format, 2);
assert.equal(record.sourceCommit, expectedCommit);
assert.equal(record.filename, names[0]);
assert.equal(record.sha256, hash(bytes));
assert.equal(record.size, bytes.length);
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
const local = createRequire(path.join(root, 'package.json'));
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
return { ...record, archive };
}
async function legacyArchive(temp, env) {
const directory = path.join(temp, 'legacy');
await fs.mkdir(directory);
const result = await run('npm', ['pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache')], { cwd: temp, env, timeout: 300000 });
const [record] = JSON.parse(result.stdout);
assert.equal(record.name, '@whyour/qinglong');
assert.equal(record.version, '2.23.0');
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
const archive = path.join(directory, record.filename);
const integrity = 'sha512-' + createHash('sha512').update(await fs.readFile(archive)).digest('base64');
assert.equal(integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed');
return { archive, integrity, version: record.version };
}
async function verifyPanel(input, output) {
assert.equal(process.platform, 'linux', 'Service tests require Linux');
assert.equal(process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1');
const stat = await fs.stat(input);
if (stat.isDirectory()) {
const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'));
assert.equal(names.length, 1, 'Expected one exact npm archive');
input = path.join(input, names[0]);
}
const expected = await read(path.join(path.dirname(input), 'package-receipt.json'));
assert.equal(path.basename(input), expected.filename);
assert.equal(hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt');
assert.equal((await fs.stat(input)).size, expected.size);
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
const nodeSha = hash(await fs.readFile(process.execPath));
const pm2 = process.env.QL_PANEL_TEST_PM2;
assert.ok(pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable');
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')));
const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data');
await fs.mkdir(home);
await fs.mkdir(data);
const port = await unusedPort();
let grpcPort = await unusedPort();
while (grpcPort === port) grpcPort = await unusedPort();
const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'),
QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false',
BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort),
BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1',
NODE_PATH: '', NODE_OPTIONS: '',
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(path.delimiter),
};
for (const key of ['QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL']) delete env[key];
let root, fixtureServer, token;
const api = async (route, method = 'GET', body, anonymous = false) => {
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
});
const result = await response.json();
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
return result.data;
};
const healthy = async () => {
const deadline = Date.now() + 120000;
while (Date.now() < deadline) {
try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {}
await delay(300);
}
throw new Error('Installed npm backend did not become healthy');
};
const job = async (id, status) => {
const deadline = Date.now() + 240000;
while (Date.now() < deadline) {
try {
const value = await api('system/update-status');
assert.equal(value.id, id);
if (value.status === status) return value;
if (value.status === 'failed' && status !== 'failed') throw new Error(value.error);
} catch (error) {
if (!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(error.message)) throw error;
}
await delay(250);
}
throw new Error(`Installed npm update never reached ${status}`);
};
try {
const install = (archive) => run('npm', ['install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive], { cwd: temp, env, timeout: 600000 });
const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000 });
const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
console.log(`Installing actual npm archive with production lifecycle scripts on ${process.version}`);
await install(input);
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
root = path.join(globalRoot, '@whyour/qinglong');
env.QL_DIR = root;
// Use a subprocess: cached candidate modules must not mask missing files
// after old-package installation and the real npm overwrite below.
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
console.log('Starting the freshly installed npm package');
await start();
const fresh = await healthy();
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
assert.equal(fresh.services.grpc, true);
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
await stop();
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
env.QL_DATA_DIR = data;
console.log('Installing the integrity-pinned published legacy npm package');
const legacy = await legacyArchive(temp, env);
await install(legacy.archive);
assert.equal((await read(path.join(root, 'package.json'))).version, legacy.version);
await assert.rejects(fs.access(path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs')), { code: 'ENOENT' });
const legacyBackendSha = hash(await fs.readFile(path.join(root, 'static/build/app.js')));
await start();
await healthy();
const credentials = { username: 'npm_package_verification', password: `Test_${randomUUID()}!` };
await api('user/init', 'PUT', credentials, true);
token = (await api('user/login', 'POST', credentials, true)).token;
const nonce = randomUUID();
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
const script = path.join(data, 'scripts/npm-package-check.js');
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
const cron = await api('crons', 'POST', { name: 'npm package retained task', command: `node ${script}`, schedule: '0 0 1 1 *' });
const userConfig = path.join(data, 'config/config.sh');
await fs.appendFile(userConfig, `\n# retained npm package setting ${nonce}\n`);
const userConfigSha = hash(await fs.readFile(userConfig));
const preservedData = async () => {
assert.equal((await api('user')).username, credentials.username);
assert.ok((await api('envs')).some((x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce));
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`);
};
await preservedData();
console.log('Stopping legacy service, backing up its .env and overwriting with the new npm archive');
await stop();
// npm replaces its entire package directory. Back up a custom root .env
// explicitly, as required by the documented package-manager upgrade flow.
await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`);
const savedEnv = await fs.readFile(path.join(root, '.env'));
await install(input);
await fs.writeFile(path.join(root, '.env'), savedEnv);
const configSha = hash(savedEnv);
const local = createRequire(path.join(root, 'package.json'));
const artifacts = local('./shell/upgrade.cjs');
const build = await artifacts.verifyUpgrade(root, path.join(root, 'static'));
assert.equal(build.sourceCommit, expected.sourceCommit);
assert.notEqual(hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha);
assert.equal(hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256);
const fixture = await platformFixture(root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit);
let badChecksum = true, requests = 0;
fixtureServer = http.createServer((request, response) => {
requests++;
if (request.url === '/latest.json') {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${fixtureServer.address().port}/bundle.tar.gz` } } }));
} else if (request.url === '/bundle.tar.gz') {
response.setHeader('Content-Length', fixture.size);
sync.createReadStream(fixture.archive).pipe(response);
} else { response.statusCode = 404; response.end(); }
});
await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve));
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${fixtureServer.address().port}/latest.json`;
console.log('Starting the overwritten npm package against legacy user data');
await start();
const original = await healthy();
assert.equal(original.build.sourceCommit, build.sourceCommit);
assert.equal(original.services.grpc, true);
token = (await api('user/login', 'POST', credentials, true)).token;
const preserved = async () => {
await preservedData();
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
};
await preserved();
console.log('Testing a rejected download through the installed panel API');
const rejected = await api('system/update', 'PUT');
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
await preserved();
badChecksum = false;
console.log('Testing installed panel download, prestart and explicit application');
const accepted = await api('system/update', 'PUT');
const ready = await job(accepted.id, 'ready');
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
const pointer = await read(path.join(root, '.tmp/upgrade-ready-develop.json'));
const receipt = await read(path.join(root, '.tmp', pointer.directory, 'prestart.json'));
assert.ok(receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart');
const applying = await api('update/system', 'PUT');
await job(applying.id, 'completed');
const afterOnline = await healthy();
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
await fs.access(path.join(root, 'node_modules/.pnpm'));
assert.match(await fs.realpath(path.join(root, 'node_modules/sqlite3')), /[/\\]node_modules[/\\]\.pnpm[/\\]/);
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
await preserved();
console.log('Testing offline installer and Bash reload from the installed npm package');
const requestsBeforeOffline = requests;
await run(process.execPath, [path.join(root, 'shell/install-bundle.cjs'), '--root', root, '--file', fixture.archive, '--download-only'], { cwd: temp, env, timeout: 240000 });
assert.equal(requests, requestsBeforeOffline, 'Offline preparation contacted the index');
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
const afterOffline = await healthy();
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
await fs.access(path.join(root, 'node_modules/.pnpm'));
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
assert.equal((await api('system/update-status')).status, 'completed');
await preserved();
await api('crons/run', 'PUT', [cron.id]);
let taskExecuted = false;
for (let i = 0; i < 100; i++) {
const log = String(await api(`crons/${cron.id}/log`));
if (log.includes(nonce) && /完成|执行结束|finished|completed/i.test(log)) { taskExecuted = true; break; }
await delay(300);
}
assert.ok(taskExecuted, 'Retained task did not execute after npm package upgrades');
const result = {
node: process.version, platform: fixture.target,
archiveSha256: hash(await fs.readFile(input)), sourceCommit: build.sourceCommit,
installedProductionDependencies: true, restoredLockVerified: true,
legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity,
legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true,
customRootEnvironmentRestoredFromExplicitBackup: true,
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
migratedFromNpmToOfficialPnpmLayout: true,
existingNodePreserved: true,
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
prestartBeforeReady: true, onlineActualRestart: true,
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
accountEnvironmentConfigurationAndScriptsPreserved: true, retainedTaskExecuted: true,
initialBuild: original.build, afterOnline: afterOnline.build, afterOffline: afterOffline.build,
};
if (output) {
await fs.mkdir(output, { recursive: true });
await fs.writeFile(path.join(output, `verification-${process.version}.json`), JSON.stringify(result, null, 2));
}
console.log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
if (root) {
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
const text = await fs.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8').catch(() => '');
if (text) console.error(text.slice(-6000));
}
}
throw error;
} finally {
if (fixtureServer) {
fixtureServer.closeAllConnections();
await new Promise((resolve) => fixtureServer.close(resolve));
}
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
await fs.rm(temp, { recursive: true, force: true });
}
}
module.exports = { checkPackedFiles, packPanel, verifyPanel };
if (require.main === module) {
const [action, input, output] = process.argv.slice(2);
const operation = action === 'pack' && input
? packPanel(path.resolve(input))
: action === 'verify' && input
? verifyPanel(path.resolve(input), output && path.resolve(output))
: Promise.reject(new Error('Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]'));
operation.catch((error) => { console.error(error); process.exitCode = 1; });
}
+32
View File
@@ -0,0 +1,32 @@
const fs = require('node:fs');
const path = require('node:path');
const { createHash, randomUUID } = require('node:crypto');
function restorePackageLock(root = path.resolve(__dirname, '..')) {
const copy = path.join(root, 'shell/upgrade-lock.yaml');
const destination = path.join(root, 'pnpm-lock.yaml');
if (!fs.existsSync(copy)) {
// Source checkouts already have the canonical lock; no built artifacts yet.
if (fs.existsSync(destination)) return;
throw new Error('Missing npm package upgrade lockfile');
}
const bytes = fs.readFileSync(copy);
const manifest = JSON.parse(
fs.readFileSync(path.join(root, 'static/build-info.json'), 'utf8'),
);
if (
manifest.dirty !== false ||
manifest.lockfileSha256 !== createHash('sha256').update(bytes).digest('hex')
)
throw new Error('npm package lockfile does not match its build manifest');
const temporary = path.join(root, `.upgrade-lock-${randomUUID()}`);
try {
fs.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o644 });
fs.renameSync(temporary, destination);
} finally {
fs.rmSync(temporary, { force: true });
}
}
module.exports = { restorePackageLock };
if (require.main === module) restorePackageLock();
+152
View File
@@ -0,0 +1,152 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { createHash } = require('node:crypto');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execute = promisify(execFile);
const { checkPackedFiles } = require('../../scripts/verify-panel-package.cjs');
const { restorePackageLock } = require('../../shell/install-package.cjs');
async function packageFixture(t) {
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-contract-')));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
const root = path.join(temp, 'source'), output = path.join(temp, 'archives');
await fs.mkdir(root);
await fs.mkdir(output);
const copy = async (name) => {
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
await fs.copyFile(path.resolve(name), path.join(root, name));
};
for (const name of ['.gitignore', '.npmignore', 'docker/verify-build.cjs', 'docker/build-manifest.cjs', '.env.example', 'ecosystem.config.js', 'version.yaml', 'README.md', 'LICENSE', 'scripts/prepare-npm-package.cjs', 'back/protos/api.proto']) await copy(name);
for (const directory of ['shell', 'sample', 'cli/src/internal/maintenance']) {
await fs.cp(directory, path.join(root, directory), {
recursive: true,
filter: (source) => !/[/\\](?:__pycache__|node_modules)(?:[/\\]|$)|[/\\]preload[/\\](?:env\.|notify\.|__ql_notify__\.|lang_env\.sh)/.test(source),
});
}
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
// The structural test stays offline; full production installation and service
// upgrades run against the unmodified package in verify-panel-package.cjs.
metadata.dependencies = {};
metadata.devDependencies = {};
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(metadata));
const lock = 'lockfileVersion: 6.0\n';
await fs.writeFile(path.join(root, 'pnpm-lock.yaml'), lock);
const files = {};
for (const name of ['build/app.js', 'build/token.js', 'build/bootstrap/upgradeProbe.js', 'dist/index.html', 'build/app.js.map', 'dist/app.js.gz']) {
const bytes = name.endsWith('.html') ? '<html>pack fixture</html>' : '{}';
await fs.mkdir(path.dirname(path.join(root, 'static', name)), { recursive: true });
await fs.writeFile(path.join(root, 'static', name), bytes);
files[name] = createHash('sha256').update(bytes).digest('hex');
}
for (const name of ['.env', 'output/private.json', 'data/config/auth.json', 'shell/preload/env.js', 'shell/preload/__ql_notify__.js', 'shell/preload/__pycache__/env.pyc', 'cli/dist/npm/ql.js']) {
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
await fs.writeFile(path.join(root, name), 'DO_NOT_PUBLISH');
}
const env = { ...process.env, HOME: temp, NODE_ENV: 'production' };
await execute('git', ['init', '-q'], { cwd: root, env });
await execute('git', ['add', '-A'], { cwd: root, env });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture'], { cwd: root, env });
await fs.writeFile(path.join(root, 'static/build-info.json'), JSON.stringify({
version: 1, dirty: false, sourceCommit: (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(), files,
lockfileSha256: createHash('sha256').update(lock).digest('hex'),
}));
const pack = () => execute('npm', ['pack', '--json', '--cache', path.join(temp, 'cache'), '--pack-destination', output], { cwd: root, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
return { root, output, temp, env, lock, files, pack };
}
test('real npm pack excludes local state and its installed lifecycle restores verified runtime files', async (t) => {
const { root, output, temp, env, lock, files, pack } = await packageFixture(t);
const packed = await pack();
const [archive] = JSON.parse(packed.stdout);
checkPackedFiles(archive.files);
const names = new Set(archive.files.map((file) => file.path));
assert.ok(names.has('shell/upgrade-lock.yaml'));
for (const name of Object.keys(files)) assert.ok(names.has('static/' + name));
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
const prefix = path.join(temp, 'installed');
await execute('npm', ['install', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), '--prefix', prefix, path.join(output, archive.filename)], { cwd: temp, env, timeout: 60000 });
const installed = path.join(prefix, 'node_modules/@whyour/qinglong');
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
assert.equal(typeof require(path.join(installed, 'shell/upgrade.cjs')).stageUpgrade, 'function');
assert.ok((await fs.stat(path.join(installed, 'shell/update.sh'))).mode & 0o111);
for (const name of ['ql', 'task', 'qinglong']) await fs.access(path.join(prefix, 'node_modules/.bin', name));
await fs.writeFile(path.join(installed, 'shell/upgrade-lock.yaml'), 'tampered');
assert.throws(() => restorePackageLock(installed), /does not match/);
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-lock-')));
});
test('postinstall still supports minimal Docker dependency stages before shell files are copied', async (t) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-builder-'));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
await fs.writeFile(path.join(temp, 'package.json'), JSON.stringify({ scripts: { postinstall: metadata.scripts.postinstall } }));
await fs.writeFile(path.join(temp, 'pnpm-lock.yaml'), 'source lock');
await execute('npm', ['run', 'postinstall', '--cache', path.join(temp, 'cache')], { cwd: temp, timeout: 30000 });
});
test('a missing npm runtime lockfile fails instead of claiming an installed package is ready', async (t) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-lock-missing-'));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
assert.throws(() => restorePackageLock(temp), /Missing npm package upgrade lockfile/);
});
test('npm publication consumes the exact archive whose isolated installation passed', () => {
const yaml = require('js-yaml');
const sync = require('node:fs');
const workflow = yaml.load(sync.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'));
const verify = workflow.jobs['verify-npm-package'];
assert.deepEqual(verify.needs, ['build-static', 'build-npm-migration-bundle']);
assert.ok(verify.steps.some((step) => step.with?.name === 'npm-migration-bundle-${{ github.sha }}'));
const migration = workflow.jobs['build-npm-migration-bundle'];
assert.equal(migration.needs, 'build-debian');
assert.ok(migration.steps.some((step) => /Dockerfile.upgrade-bundle/.test(step.run || '')));
assert.ok(!migration.steps.some((step) => /publish-upgrade-bundles/.test(step.run || '')));
assert.deepEqual(verify.strategy.matrix.node, ['20.18.1', '24']);
assert.match(verify.container, /node:.*bookworm/);
const tools = verify.steps.find((step) => step.name === 'Install isolated service test tools');
assert.match(tools.run, /npm install --global --prefix \/usr\/local.*pm2@6\.0\.14/);
const service = verify.steps.find((step) => step.name === 'Install and upgrade the exact npm archive');
assert.equal(service.env.QL_PANEL_TEST_PM2, '/usr/local/bin/pm2');
assert.ok(workflow.jobs.publish.needs.includes('verify-npm-package'));
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.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
});
for (const scenario of ['dirty-source', 'stale-commit', 'dirty-build', 'changed-lock', 'changed-output', 'missing-output', 'extra-output']) {
test(`native npm pack rejects ${scenario} before producing an archive`, async (t) => {
const { root, output, pack } = await packageFixture(t);
const manifestFile = path.join(root, 'static/build-info.json');
const manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
if (scenario === 'dirty-source') await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# uncommitted change\n');
if (scenario === 'stale-commit') {
await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# committed change after build\n');
await execute('git', ['add', '-A'], { cwd: root });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new source'], { cwd: root });
}
if (scenario === 'dirty-build') { manifest.dirty = true; await fs.writeFile(manifestFile, JSON.stringify(manifest)); }
if (scenario === 'changed-lock') {
await fs.appendFile(path.join(root, 'pnpm-lock.yaml'), '# changed lock\n');
await execute('git', ['add', '-A'], { cwd: root });
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new lock'], { cwd: root });
manifest.sourceCommit = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim();
await fs.writeFile(manifestFile, JSON.stringify(manifest));
}
if (scenario === 'changed-output') await fs.appendFile(path.join(root, 'static/build/app.js'), 'changed');
if (scenario === 'missing-output') await fs.rm(path.join(root, 'static/build/app.js'));
if (scenario === 'extra-output') await fs.writeFile(path.join(root, 'static/build/leftover.js'), 'old');
await assert.rejects(pack(), (error) => {
assert.match(error.stderr, /clean source|do not match|checksum mismatch|file set mismatch/i);
return true;
});
assert.deepEqual(await fs.readdir(output), []);
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
});
}
+15 -2
View File
@@ -36,14 +36,23 @@ for (const [scenario, expectedStatus, published] of [
fs.mkdirSync(bin);
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }),
JSON.stringify({ name: '@fixture/unrelated-source', version: '0.0.0' }),
);
fs.mkdirSync(path.join(root, 'package'));
fs.mkdirSync(path.join(root, 'npm-panel'));
fs.writeFileSync(
path.join(root, 'package/package.json'),
JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }),
);
const packed = spawnSync('tar', ['-czf', 'npm-panel/verified.tgz', 'package'], { cwd: root });
assert.equal(packed.status, 0, String(packed.stderr));
fs.writeFileSync(
path.join(bin, 'npm'),
`#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); }
if (process.argv[2] === 'publish') { fs.writeFileSync('published', JSON.stringify(process.argv.slice(3))); process.exit(0); }
if (process.argv[2] !== 'view') process.exit(99);
if (process.argv[3] !== '@whyour/qinglong@2.22.0') process.exit(98);
switch (process.env.QL_PUBLISH_SCENARIO) {
case 'existing': console.log(JSON.stringify('2.22.0')); break;
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
@@ -66,5 +75,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
});
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
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',
]);
});
}