diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 5f76928a..aafc4ff2 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: "20" + node-version-file: ".nvmrc" cache: "pnpm" cache-dependency-path: pnpm-lock.yaml diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 26d4c6a1..00000000 --- a/.npmignore +++ /dev/null @@ -1,28 +0,0 @@ -/.tmp/ -/.github/ -/.vscode/ -/.history/ -/back/**/*.ts -/back/**/*.json -/data/ -/src/ -/.editorconfig -/.gitignore -/.prettierignore -/.prettierrc -/.umirc.ts -/nodemon.json -/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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index 58355334..fc844b6e 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -70,8 +70,12 @@ ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。 +`package.json#files` 是 npm 包内容的唯一项目规则,不再维护根 `.npmignore`:npm 在根包使用 `files` 时会停用该目录的 `.npmignore` 和 `.gitignore` 规则。打包测试在没有 `.npmignore` 的独立源码目录中注入私有配置、密钥占位文件、缓存及生成的预加载配置,执行真实 `npm pack` 和安装,检查运行文件完整、私有文件及其内容不进入归档。此规则只用于 npm 包,完整平台 tar.gz 仍由平台包构建器处理。 + 干净源码完成 `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,也不推进开发或正式更新索引。 +Umi 4.4.4 的构建依赖仍使用 Node 24 已移除的 `http_parser`。前端源码构建使用 `.nvmrc` 中的 Node 22,CI 从该文件读取版本,Vercel 前后端项目均配置为 Node 22.x。`package.json` 不声明 `engines.node`,避免 Vercel 用开放的最低运行版本范围覆盖项目的构建版本;`pnpm build:front` 直接执行构建,不下载或切换 Node。面板运行时仍支持 Node 20.18.1 及以上,平台包的清单与安装器独立校验最低 Node 要求,npm 实装验收继续覆盖 Node 20/24。 + 服务集成测试只能在专用容器中运行:设置 `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 覆盖升级前,应先停止服务,执行新包安装,再启动服务。正常 npm 安装的 `postinstall` 会从当前路径对应的 npm 旧包临时目录中自动保留 `.env`,逐字节恢复为权限 `0600` 的普通文件,不需要手工备份和恢复;新目录中已有的 `.env` 不会被覆盖。只读取包名匹配的旧包,不扫描其他安装或备份目录;异常配置(如符号链接)会使安装失败,由 npm 回退旧包,避免静默丢失配置。此机制需要启用 npm 安装脚本,不能使用 `--ignore-scripts`。持久数据仍必须放在包目录以外的 `QL_DATA_DIR`。验收直接检查真实旧 npm 包覆盖安装后配置自动保留,并校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。 diff --git a/package.json b/package.json index 7cd57bcf..8f25287a 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,6 @@ "bugs": { "url": "https://github.com/whyour/qinglong/issues" }, - "engines": { - "node": ">=20.18.1" - }, "files": [ ".env.example", "back/protos/api.proto", diff --git a/test/back/npm-package.test.cjs b/test/back/npm-package.test.cjs index 26b1a7dd..e6023506 100644 --- a/test/back/npm-package.test.cjs +++ b/test/back/npm-package.test.cjs @@ -21,7 +21,7 @@ async function packageFixture(t) { 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 name of ['.gitignore', '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, @@ -43,10 +43,11 @@ async function packageFixture(t) { 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']) { + for (const name of ['.env', '.env.local', '.env.production', '.aws/credentials', '.ssh/id_rsa', '.vscode/settings.json', '.github/workflows/private.yml', '.tmp/private.json', '.DS_Store', 'output/private.json', 'data/config/auth.json', 'back/private.json', 'back/private.ts', 'sample/tool.ts', 'sample/private.json', 'sample/__pycache__/notify.pyc', 'shell/preload/env.js', 'shell/preload/env.py', 'shell/preload/notify.js', 'shell/preload/__ql_notify__.js', 'shell/preload/lang_env.sh', 'shell/preload/task-notify.json', 'shell/preload/__pycache__/env.pyc', 'cli/dist/npm/ql.js', 'cli/src/internal/private.cjs']) { await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true }); await fs.writeFile(path.join(root, name), 'DO_NOT_PUBLISH'); } + await fs.writeFile(path.join(root, '.npmrc'), '# DO_NOT_PUBLISH\n'); 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 }); @@ -61,6 +62,8 @@ async function packageFixture(t) { 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); + // The whitelist must work by itself, even for artifacts ignored by Git. + await assert.rejects(fs.access(path.join(root, '.npmignore')), { code: 'ENOENT' }); const packed = await pack(); const [archive] = JSON.parse(packed.stdout); checkPackedFiles(archive.files); @@ -71,11 +74,16 @@ test('real npm pack excludes local state and its installed lifecycle restores ve 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'); + for (const { path: name } of archive.files) + assert.equal((await fs.readFile(path.join(installed, name))).includes(Buffer.from('DO_NOT_PUBLISH')), false, `Private fixture content in npm package: ${name}`); await assert.rejects(fs.access(path.join(installed, '.env')), { code: 'ENOENT' }); 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)); + for (const [name, file] of Object.entries(JSON.parse(await fs.readFile(path.join(installed, 'package.json'), 'utf8')).bin)) { + await fs.access(path.join(prefix, 'node_modules/.bin', name)); + assert.ok((await fs.stat(path.join(installed, file))).mode & 0o111, file); + } 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);