From 3f02b04dff1a2fe6cd3e5a844870b409a8fec6f9 Mon Sep 17 00:00:00 2001 From: whyour Date: Mon, 19 Oct 2026 13:08:09 +0800 Subject: [PATCH] fix: ship verified npm panel packages and test production migrations --- .github/workflows/build-docker-image.yml | 111 +++++- .gitignore | 1 + .npmignore | 16 +- docs/development/platform-upgrade-bundles.md | 14 + package.json | 43 ++- scripts/prepare-npm-package.cjs | 20 + scripts/verify-panel-package.cjs | 369 +++++++++++++++++++ shell/install-package.cjs | 32 ++ test/back/npm-package.test.cjs | 152 ++++++++ test/back/npm-publication.test.cjs | 17 +- 10 files changed, 747 insertions(+), 28 deletions(-) create mode 100644 scripts/prepare-npm-package.cjs create mode 100644 scripts/verify-panel-package.cjs create mode 100644 shell/install-package.cjs create mode 100644 test/back/npm-package.test.cjs diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index f8c4b197..5f76928a 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -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 diff --git a/.gitignore b/.gitignore index b1095392..64196117 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ __pycache__ /shell/preload/*-notify.json /shell/preload/__ql_notify__.* /shell/preload/lang_env.sh +/shell/upgrade-lock.yaml .deepseek/ .claude/ diff --git a/.npmignore b/.npmignore index 230f2ff4..26d4c6a1 100644 --- a/.npmignore +++ b/.npmignore @@ -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 \ No newline at end of file +/.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 diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 454cba57..6adf7d6a 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -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。 diff --git a/package.json b/package.json index bb03612f..987de9d9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/prepare-npm-package.cjs b/scripts/prepare-npm-package.cjs new file mode 100644 index 00000000..1d4875a2 --- /dev/null +++ b/scripts/prepare-npm-package.cjs @@ -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 }); +} diff --git a/scripts/verify-panel-package.cjs b/scripts/verify-panel-package.cjs new file mode 100644 index 00000000..bf7dbd97 --- /dev/null +++ b/scripts/verify-panel-package.cjs @@ -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()), / { + 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()), / ''); + 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; }); +} diff --git a/shell/install-package.cjs b/shell/install-package.cjs new file mode 100644 index 00000000..32fc5460 --- /dev/null +++ b/shell/install-package.cjs @@ -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(); diff --git a/test/back/npm-package.test.cjs b/test/back/npm-package.test.cjs new file mode 100644 index 00000000..ac4ef36c --- /dev/null +++ b/test/back/npm-package.test.cjs @@ -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') ? 'pack fixture' : '{}'; + 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' }); + }); +} diff --git a/test/back/npm-publication.test.cjs b/test/back/npm-publication.test.cjs index 9a816354..b61a10a6 100644 --- a/test/back/npm-publication.test.cjs +++ b/test/back/npm-publication.test.cjs @@ -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', + ]); }); }