固定构建工具链并禁止依赖安装脚本
This commit is contained in:
parent
5fdb5e4fa1
commit
410ce751ba
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,6 +9,8 @@ frontend/dist-release-*/
|
||||
build/*
|
||||
!build/build-release.ps1
|
||||
!build/README.md
|
||||
!build/toolchain.mjs
|
||||
!build/toolchain.test.mjs
|
||||
codex-*.patch
|
||||
desktop/frontend/dist/
|
||||
desktop/*.exe
|
||||
|
||||
@ -41,6 +41,7 @@ func TestRepositoryIgnoreBoundaries(t *testing.T) {
|
||||
"frontend/src/views/Login/Login.vue", "frontend/src/views/Login/Login.test.js",
|
||||
"deploy/install-linux.sh", "deploy/install-windows.ps1", "deploy/tests/install-linux-test.py",
|
||||
"desktop/main.go", "build/README.md", "build/build-release.ps1",
|
||||
"build/toolchain.mjs", "build/toolchain.test.mjs", "frontend/.npmrc",
|
||||
"release/README.md", "artifacts/README.md", "tests/README.md", "scripts/README.md",
|
||||
"tests/example_test.go", "scripts/example.go", ".env.example", "nested/.env.sample",
|
||||
}
|
||||
|
||||
@ -56,7 +56,22 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -OutputDirect
|
||||
|
||||
脚本仅创建新的发布目录。发布目录或对应归档已存在时,在构建前拒绝执行,不覆盖旧包。前端直接构建到本次发布目录中的 `web-build/`,不清空或覆盖已有 `frontend/dist/`。失败产生的目录保留供排查,再次构建请使用新的输出目录。
|
||||
|
||||
`manifest.json` 记录源码提交、已跟踪文件是否未提交、实际 Go 工具链、目标平台和文件及归档的 SHA256。`delivery_scope` 固定为 `web`、`server`,兼容字段 `desktop_requested` 固定为 `false`。`source_dirty=true` 的包仅用于开发,正式发布必须从已复审提交构建。未跟踪本地产物不计入源码脏状态,也不会自动打包。
|
||||
`manifest.json` 记录源码提交、源码是否未提交、实际 Go 工具链、目标平台和文件及归档的 SHA256。`delivery_scope` 固定为 `web`、`server`,兼容字段 `desktop_requested` 固定为 `false`。`source_dirty=true` 的包仅用于开发,正式发布必须从已复审提交构建。未忽略的未跟踪文件也计入源码脏状态;已忽略的本地产物不计入,也不会自动打包。
|
||||
|
||||
## 固定工具链与安装策略
|
||||
|
||||
`toolchain.mjs` 是正式发布入口调用的构建检查器,`toolchain.test.mjs` 是纳入前端测试命令的正式回归,不是临时验收脚本。
|
||||
|
||||
1. Go 精确版本以 `backend/go.mod` 为唯一来源;实际工具链不符则失败。`go.sum`、`go.mod` 固定 Go 依赖和数据库驱动,构建及归档使用 `-mod=readonly`;禁用外部 Go 工作区及 `GOFLAGS` 覆盖。
|
||||
2. Node/npm 精确版本由 `frontend/package.json` 的 `engines` 声明,`devEngines`、`packageManager` 和锁文件必须一致。直接依赖禁止范围版本,锁文件负责传递依赖。
|
||||
3. `frontend/.npmrc` 明确配置 `ignore-scripts=true`、`engine-strict=true`、`save-exact=true`。不使用脚本白名单或全量放行开关;当前 npm 的未审查警告不能证明脚本被禁止。
|
||||
4. 发布脚本在创建输出目录前校验版本、执行 `go mod verify`,并自动执行 `npm ci --ignore-scripts --engine-strict --include=dev --include=optional --no-audit --no-fund`。这会按锁文件重建前端依赖,不复用未知的旧安装,也不删除数据库、证据或既有发布包。
|
||||
5. esbuild 使用锁文件中对应构建机的平台可选包,不运行其安装脚本,不允许 `ESBUILD_BINARY_PATH` 外部覆盖。缺少平台包或二进制不可用必须失败,不允许另行下载绕过锁文件。
|
||||
6. Vite 构建仍显式执行,但禁用隐式前后置脚本。最终再校验版本和输入 SHA256;构建中改变版本文件不生成发布清单。
|
||||
|
||||
`build_environment` 记录 Node/npm/Go/PowerShell、直接前端依赖、安装策略、构建机系统名称/版本/架构,以及五份依赖和策略文件的 SHA256;不记录用户名、主机名或本地绝对路径。构建机目前允许 Windows/Linux amd64,实际已验证系统和未验证边界见版本矩阵;Windows 交叉编译 Linux 包不等于 Linux 原生构建或运行验收。
|
||||
|
||||
从 `npm test` 等父进程启动检查器时,npm 可能将用户级 `allow-scripts` 导出到环境变量,而项目级安装拒绝这种传递。本入口仅从子进程环境移除该白名单变量,不改用户配置;`--ignore-scripts` 仍强制生效。正式测试覆盖嵌套调用和主动尝试放行脚本的环境设置,不能通过开启安装脚本解决此兼容问题。
|
||||
|
||||
## 本机路径保护
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ function Invoke-GoBuild {
|
||||
$env:GOOS = $OperatingSystem
|
||||
$env:GOARCH = $Architecture
|
||||
$env:CGO_ENABLED = "0"
|
||||
& go build -trimpath -o $Destination $Package
|
||||
& go build -trimpath -mod=readonly -o $Destination $Package
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建 $OperatingSystem/$Architecture 的 $Package 失败。"
|
||||
}
|
||||
@ -136,6 +136,7 @@ function Invoke-ReleaseArchive {
|
||||
$arguments = @(
|
||||
"run",
|
||||
"-trimpath",
|
||||
"-mod=readonly",
|
||||
"./cmd/scert-release",
|
||||
"--source", $Source,
|
||||
"--output", $Destination,
|
||||
@ -174,7 +175,7 @@ function Test-ReleasePaths {
|
||||
[string[]]$ForbiddenPaths
|
||||
)
|
||||
|
||||
$arguments = @("run", "-trimpath", "./cmd/scert-release", "--verify-only", "--source", $Source)
|
||||
$arguments = @("run", "-trimpath", "-mod=readonly", "./cmd/scert-release", "--verify-only", "--source", $Source)
|
||||
foreach ($binary in $Binaries) {
|
||||
$arguments += @("--binary", $binary)
|
||||
}
|
||||
@ -215,6 +216,9 @@ $npmCommand = if ($env:OS -eq "Windows_NT") { "npm.cmd" } else { "npm" }
|
||||
if (-not (Get-Command $npmCommand -ErrorAction SilentlyContinue)) {
|
||||
throw "未找到 npm 工具链,请先安装 Node.js 并加入 PATH。"
|
||||
}
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
throw "未找到 Node.js 工具链,请先安装 Node.js 并加入 PATH。"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $frontend "package.json") -PathType Leaf)) {
|
||||
throw "前端目录中没有找到 package.json:$frontend"
|
||||
}
|
||||
@ -258,21 +262,29 @@ $sourceCommit = (& git -C $backend rev-parse HEAD)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "无法取得源码提交,不能生成可追溯发布包。"
|
||||
}
|
||||
$sourceChanges = @(& git -C $backend status --porcelain --untracked-files=no)
|
||||
$sourceChanges = @(& git -C $backend status --porcelain --untracked-files=normal)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "无法检查源码工作区状态。"
|
||||
}
|
||||
$sourceDirty = $sourceChanges.Count -gt 0
|
||||
if ($sourceDirty) {
|
||||
Write-Warning "存在未提交的已跟踪文件改动,清单将标记 source_dirty=true,不能作为正式发布包。"
|
||||
Write-Warning "存在未提交的源码改动,清单将标记 source_dirty=true,不能作为正式发布包。"
|
||||
}
|
||||
|
||||
Write-Host "正在校验构建版本并按锁文件安装前端依赖……"
|
||||
$toolchainJSON = & node (Join-Path $scriptDirectory "toolchain.mjs") --backend $backend --frontend $frontend --install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建版本或依赖安装检查失败,尚未创建发布目录。"
|
||||
}
|
||||
$toolchain = ($toolchainJSON -join "`n") | ConvertFrom-Json
|
||||
$toolchain | Add-Member -NotePropertyName powershell -NotePropertyValue $PSVersionTable.PSVersion.ToString()
|
||||
New-Item -ItemType Directory -Path $releaseDirectory | Out-Null
|
||||
$frontendDistribution = Join-Path $releaseDirectory "web-build"
|
||||
|
||||
Write-Host "正在构建 Scert Web 前端……"
|
||||
Push-Location $frontend
|
||||
try {
|
||||
& $npmCommand run build -- --outDir $frontendDistribution --emptyOutDir false
|
||||
& $npmCommand --ignore-scripts run build -- --outDir $frontendDistribution --emptyOutDir false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建 Scert Web 前端失败。"
|
||||
}
|
||||
@ -287,6 +299,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $frontendDistribution "index.html")
|
||||
$oldGoOS = $env:GOOS
|
||||
$oldGoArch = $env:GOARCH
|
||||
$oldCGOEnabled = $env:CGO_ENABLED
|
||||
$oldGoWork = $env:GOWORK
|
||||
$env:GOWORK = "off"
|
||||
$manifestArtifacts = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
Push-Location $backend
|
||||
@ -385,6 +399,16 @@ finally {
|
||||
$env:GOOS = $oldGoOS
|
||||
$env:GOARCH = $oldGoArch
|
||||
$env:CGO_ENABLED = $oldCGOEnabled
|
||||
$env:GOWORK = $oldGoWork
|
||||
}
|
||||
|
||||
$verifiedToolchainJSON = & node (Join-Path $scriptDirectory "toolchain.mjs") --backend $backend --frontend $frontend
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "构建后的工具链复核失败,不生成发布清单。"
|
||||
}
|
||||
$verifiedToolchain = ($verifiedToolchainJSON -join "`n") | ConvertFrom-Json
|
||||
if (($toolchain.input_sha256 | ConvertTo-Json -Compress) -ne ($verifiedToolchain.input_sha256 | ConvertTo-Json -Compress)) {
|
||||
throw "构建期间版本文件发生变化,不生成发布清单。"
|
||||
}
|
||||
|
||||
$manifest = [ordered]@{
|
||||
@ -394,6 +418,7 @@ $manifest = [ordered]@{
|
||||
source_commit = $sourceCommit.Trim()
|
||||
source_dirty = $sourceDirty
|
||||
go_toolchain = $goVersion.Trim()
|
||||
build_environment = $toolchain
|
||||
trimpath = $true
|
||||
local_path_check = "passed"
|
||||
targets = @($Target)
|
||||
|
||||
179
build/toolchain.mjs
Normal file
179
build/toolchain.mjs
Normal file
@ -0,0 +1,179 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { arch, platform, release, version } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
const exactVersion = /^\d+\.\d+\.\d+$/;
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function requireCondition(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFrontend(pkg, lock, npmrc, actual) {
|
||||
for (const name of ['node', 'npm']) {
|
||||
const expected = pkg.engines?.[name];
|
||||
requireCondition(exactVersion.test(expected ?? ''), `${name} 必须固定精确版本。`);
|
||||
requireCondition(actual[name] === expected, `${name} 版本不匹配,需要 ${expected}。`);
|
||||
const engine = pkg.devEngines?.[name === 'node' ? 'runtime' : 'packageManager'];
|
||||
requireCondition(
|
||||
engine?.name === name && engine.version === expected && engine.onFail === 'error',
|
||||
`${name} 的开发工具链拒绝策略不一致。`,
|
||||
);
|
||||
requireCondition(lock.packages?.['']?.engines?.[name] === expected, `${name} 锁文件版本声明不一致。`);
|
||||
}
|
||||
requireCondition(pkg.packageManager === `npm@${pkg.engines.npm}`, '包管理器声明不一致。');
|
||||
requireCondition(lock.lockfileVersion === 3, '前端必须使用第三版锁文件。');
|
||||
requireCondition(pkg.name === lock.name && pkg.version === lock.version, '前端锁文件项目标识不一致。');
|
||||
|
||||
for (const section of ['dependencies', 'devDependencies']) {
|
||||
const declared = pkg[section] ?? {};
|
||||
const locked = lock.packages[''][section] ?? {};
|
||||
requireCondition(
|
||||
JSON.stringify(Object.keys(declared).sort()) === JSON.stringify(Object.keys(locked).sort()),
|
||||
`前端 ${section} 与锁文件不一致。`,
|
||||
);
|
||||
for (const [name, expected] of Object.entries(declared)) {
|
||||
requireCondition(exactVersion.test(expected), `依赖 ${name} 必须固定精确版本。`);
|
||||
requireCondition(
|
||||
locked[name] === expected && lock.packages[`node_modules/${name}`]?.version === expected,
|
||||
`依赖 ${name} 与锁文件不一致。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = new Map();
|
||||
for (const line of npmrc.split(/\r?\n/)) {
|
||||
const value = line.trim();
|
||||
if (!value || value.startsWith('#') || value.startsWith(';')) continue;
|
||||
const match = /^([a-z-]+)=(.*)$/.exec(value);
|
||||
requireCondition(match && !settings.has(match[1]), '前端 npm 配置包含无效项或重复项。');
|
||||
settings.set(match[1], match[2]);
|
||||
}
|
||||
for (const name of ['engine-strict', 'ignore-scripts', 'save-exact']) {
|
||||
requireCondition(settings.get(name) === 'true', `前端 npm 配置必须启用 ${name}。`);
|
||||
}
|
||||
requireCondition(settings.size === 3, '前端 npm 配置存在未审查的额外设置。');
|
||||
requireCondition(!pkg.allowScripts && !pkg.dangerouslyAllowAllScripts, '禁止额外放行依赖安装脚本。');
|
||||
}
|
||||
|
||||
export function validateGoVersion(goMod, actual) {
|
||||
const expected = /^go (\d+\.\d+\.\d+)\s*$/m.exec(goMod)?.[1];
|
||||
requireCondition(expected, '后端模块必须声明精确 Go 版本。');
|
||||
requireCondition(actual === `go${expected}`, `实际 Go 工具链不匹配,需要 go${expected}。`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
export function validateEnvironment(env, host) {
|
||||
requireCondition(['win32', 'linux'].includes(host.platform) && host.arch === 'x64', '构建机仅支持 Windows/Linux amd64。');
|
||||
requireCondition(!env.ESBUILD_BINARY_PATH, '禁止使用锁文件之外的 esbuild 可执行文件。');
|
||||
requireCondition(!env.GOWORK || env.GOWORK === 'off', '正式构建禁止使用外部 Go 工作区。');
|
||||
requireCondition(!env.GOFLAGS, '正式构建禁止通过 GOFLAGS 覆盖依赖或编译策略。');
|
||||
}
|
||||
|
||||
export function npmEnvironment(env = process.env) {
|
||||
// npm run 会把用户级白名单导出为环境变量;子项目安装却拒绝该变量。
|
||||
// 仅移除这项不适用于项目安装的继承策略,安装命令仍强制禁止全部脚本。
|
||||
return Object.fromEntries(Object.entries(env).filter(([name]) => name.toLowerCase() !== 'npm_config_allow_scripts'));
|
||||
}
|
||||
|
||||
function execute(command, args, cwd, env = process.env) {
|
||||
const useShell = process.platform === 'win32' && command === npmCommand;
|
||||
if (useShell) {
|
||||
requireCondition(args.every((arg) => /^[a-zA-Z0-9=_./-]+$/.test(arg)), 'npm 参数含有不允许的命令解释器字符。');
|
||||
}
|
||||
const options = {
|
||||
cwd,
|
||||
env: command === npmCommand ? npmEnvironment(env) : env,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
// Windows 的 npm.cmd 需要命令解释器;参数仅来自本文件的固定值。
|
||||
shell: useShell,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
};
|
||||
const result = useShell
|
||||
? spawnSync(`${command} ${args.join(' ')}`, options)
|
||||
: spawnSync(command, args, options);
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(`${command} 执行失败。\n${result.stderr ?? ''}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function inputHashes(backend, frontend) {
|
||||
const files = {
|
||||
'backend/go.mod': join(backend, 'go.mod'),
|
||||
'backend/go.sum': join(backend, 'go.sum'),
|
||||
'frontend/package.json': join(frontend, 'package.json'),
|
||||
'frontend/package-lock.json': join(frontend, 'package-lock.json'),
|
||||
'frontend/.npmrc': join(frontend, '.npmrc'),
|
||||
};
|
||||
return Object.fromEntries(Object.entries(files).map(([name, path]) => [
|
||||
name,
|
||||
createHash('sha256').update(readFileSync(path)).digest('hex'),
|
||||
]));
|
||||
}
|
||||
|
||||
export function installDependencies(frontend) {
|
||||
// 不信任使用者的 npm 全局/用户设置,命令行参数显式覆盖;保留构建所需的可选平台包。
|
||||
const result = execute(npmCommand, [
|
||||
'ci', '--ignore-scripts', '--engine-strict', '--include=dev', '--include=optional',
|
||||
'--no-audit', '--no-fund',
|
||||
], frontend);
|
||||
process.stderr.write(result.stdout);
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
export function inspectToolchain(backend, frontend, install = false) {
|
||||
const host = { platform: platform(), arch: arch(), release: release(), version: version() };
|
||||
validateEnvironment(process.env, host);
|
||||
const pkg = JSON.parse(readFileSync(join(frontend, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(readFileSync(join(frontend, 'package-lock.json'), 'utf8'));
|
||||
const npmrc = readFileSync(join(frontend, '.npmrc'), 'utf8');
|
||||
const actual = { node: process.versions.node, npm: execute(npmCommand, ['--version'], frontend).stdout.trim() };
|
||||
validateFrontend(pkg, lock, npmrc, actual);
|
||||
const goEnv = { ...process.env, GOWORK: 'off' };
|
||||
const goVersion = execute('go', ['env', 'GOVERSION'], backend, goEnv).stdout.trim();
|
||||
validateGoVersion(readFileSync(join(backend, 'go.mod'), 'utf8'), goVersion);
|
||||
const hashes = inputHashes(backend, frontend);
|
||||
execute('go', ['mod', 'verify'], backend, goEnv);
|
||||
if (install) installDependencies(frontend);
|
||||
requireCondition(JSON.stringify(hashes) === JSON.stringify(inputHashes(backend, frontend)), '依赖安装改变了锁文件或版本策略,拒绝构建。');
|
||||
return {
|
||||
node: actual.node,
|
||||
npm: actual.npm,
|
||||
go: goVersion,
|
||||
host,
|
||||
frontend_dependencies: { ...pkg.dependencies, ...pkg.devDependencies },
|
||||
dependency_scripts: 'disabled',
|
||||
dependency_install: install ? 'npm-ci' : 'not-run',
|
||||
input_sha256: hashes,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
backend: { type: 'string' },
|
||||
frontend: { type: 'string' },
|
||||
install: { type: 'boolean', default: false },
|
||||
},
|
||||
});
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const report = inspectToolchain(
|
||||
resolve(values.backend ?? join(root, 'backend')),
|
||||
resolve(values.frontend ?? join(root, 'frontend')),
|
||||
values.install,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
} catch (error) {
|
||||
console.error(`构建工具链检查失败:${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
164
build/toolchain.test.mjs
Normal file
164
build/toolchain.test.mjs
Normal file
@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import { installDependencies, npmEnvironment, validateEnvironment, validateFrontend, validateGoVersion } from './toolchain.mjs';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
|
||||
function fixture() {
|
||||
const frontend = join(root, 'frontend');
|
||||
const pkg = JSON.parse(readFileSync(join(frontend, 'package.json'), 'utf8'));
|
||||
return {
|
||||
pkg,
|
||||
lock: JSON.parse(readFileSync(join(frontend, 'package-lock.json'), 'utf8')),
|
||||
npmrc: readFileSync(join(frontend, '.npmrc'), 'utf8'),
|
||||
actual: { ...pkg.engines },
|
||||
};
|
||||
}
|
||||
|
||||
function validate(value) {
|
||||
validateFrontend(value.pkg, value.lock, value.npmrc, value.actual);
|
||||
}
|
||||
|
||||
test('仓库版本声明、锁文件和安装策略一致', () => {
|
||||
validate(fixture());
|
||||
});
|
||||
|
||||
for (const [name, mutate] of Object.entries({
|
||||
'拒绝错误 Node 版本': (f) => { f.actual.node = '24.0.0'; },
|
||||
'拒绝错误 npm 版本': (f) => { f.actual.npm = '10.0.0'; },
|
||||
'拒绝工具链范围版本': (f) => { f.pkg.engines.node = '^24.19.0'; },
|
||||
'拒绝开发工具链警告降级': (f) => { f.pkg.devEngines.runtime.onFail = 'warn'; },
|
||||
'拒绝包管理器声明漂移': (f) => { f.pkg.packageManager = 'npm@10.0.0'; },
|
||||
'拒绝旧版锁文件': (f) => { f.lock.lockfileVersion = 2; },
|
||||
'拒绝锁文件工具链漂移': (f) => { f.lock.packages[''].engines.node = '24.0.0'; },
|
||||
'拒绝依赖范围版本': (f) => { f.pkg.dependencies.vue = '^3.5.41'; },
|
||||
'拒绝依赖锁定漂移': (f) => { f.lock.packages['node_modules/vue'].version = '3.5.0'; },
|
||||
'拒绝锁文件多余直接依赖': (f) => { f.lock.packages[''].dependencies.extra = '1.0.0'; },
|
||||
'拒绝前端标识漂移': (f) => { f.lock.name = 'other-project'; },
|
||||
'拒绝放行安装脚本': (f) => { f.npmrc = f.npmrc.replace('ignore-scripts=true', 'ignore-scripts=false'); },
|
||||
'拒绝重复安装策略': (f) => { f.npmrc += '\nignore-scripts=false\n'; },
|
||||
'拒绝关闭版本限制': (f) => { f.npmrc = f.npmrc.replace('engine-strict=true', 'engine-strict=false'); },
|
||||
'拒绝额外 npm 配置': (f) => { f.npmrc += '\nforce=true\n'; },
|
||||
'拒绝脚本白名单绕行': (f) => { f.pkg.allowScripts = { esbuild: true }; },
|
||||
'拒绝全量脚本放行': (f) => { f.pkg.dangerouslyAllowAllScripts = true; },
|
||||
})) {
|
||||
test(name, () => {
|
||||
const value = fixture();
|
||||
mutate(value);
|
||||
assert.throws(() => validate(value));
|
||||
});
|
||||
}
|
||||
|
||||
test('Go 工具链必须精确匹配模块基线', () => {
|
||||
assert.equal(validateGoVersion('module test\n\ngo 1.26.6\n', 'go1.26.6'), '1.26.6');
|
||||
for (const version of ['go1.26.2', 'go1.26.7', 'devel go1.27', '']) {
|
||||
assert.throws(() => validateGoVersion('go 1.26.6\n', version));
|
||||
}
|
||||
assert.throws(() => validateGoVersion('go 1.26\n', 'go1.26.6'));
|
||||
});
|
||||
|
||||
test('拒绝外部构建覆盖和未支持的构建机', () => {
|
||||
const host = { platform: 'win32', arch: 'x64' };
|
||||
validateEnvironment({}, host);
|
||||
validateEnvironment({ GOWORK: 'off' }, { ...host, platform: 'linux' });
|
||||
for (const env of [{ ESBUILD_BINARY_PATH: 'external' }, { GOWORK: 'go.work' }, { GOFLAGS: '-mod=mod' }]) {
|
||||
assert.throws(() => validateEnvironment(env, host));
|
||||
}
|
||||
assert.throws(() => validateEnvironment({}, { ...host, platform: 'darwin' }));
|
||||
assert.throws(() => validateEnvironment({}, { ...host, arch: 'arm64' }));
|
||||
});
|
||||
|
||||
test('发布入口在创建目录前检查并安装依赖,构建后复核版本文件', () => {
|
||||
const script = readFileSync(join(root, 'build', 'build-release.ps1'), 'utf8');
|
||||
const install = script.indexOf('--frontend $frontend --install');
|
||||
assert.ok(install > 0 && install < script.indexOf('New-Item -ItemType Directory -Path $releaseDirectory'));
|
||||
assert.match(script, /--ignore-scripts run build/);
|
||||
assert.match(script, /go build -trimpath -mod=readonly/);
|
||||
assert.match(script, /build_environment = \$toolchain/);
|
||||
assert.match(script, /\$verifiedToolchain\.input_sha256/);
|
||||
});
|
||||
|
||||
test('工具链命令实际拒绝外部二进制覆盖', () => {
|
||||
const result = spawnSync(process.execPath, [join(root, 'build', 'toolchain.mjs'), '--install'], {
|
||||
cwd: root, encoding: 'utf8', windowsHide: true,
|
||||
env: { ...process.env, ESBUILD_BINARY_PATH: 'unreviewed-binary' },
|
||||
});
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /禁止使用锁文件之外的 esbuild/);
|
||||
assert.equal(result.stdout, '');
|
||||
});
|
||||
|
||||
test('嵌套 npm 安装不继承项目安装禁止的用户脚本白名单', () => {
|
||||
const original = { npm_config_allow_scripts: 'fixture', NPM_CONFIG_ALLOW_SCRIPTS: 'fixture', PATH: 'retained' };
|
||||
assert.deepEqual(npmEnvironment(original), { PATH: 'retained' });
|
||||
assert.equal(original.npm_config_allow_scripts, 'fixture');
|
||||
});
|
||||
|
||||
test('发布脚本实际在工具链拒绝后不创建产物', { skip: process.platform !== 'win32' }, (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'scert-release-preflight-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const output = join(directory, 'release');
|
||||
const result = spawnSync('powershell.exe', [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'build', 'build-release.ps1'),
|
||||
'-OutputDirectory', output,
|
||||
], {
|
||||
cwd: root, encoding: 'utf8', windowsHide: true,
|
||||
env: { ...process.env, ESBUILD_BINARY_PATH: 'unreviewed-binary' },
|
||||
});
|
||||
assert.equal(result.status, 1, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stderr, /esbuild/);
|
||||
assert.ok(!existsSync(output));
|
||||
});
|
||||
|
||||
test('真实离线安装禁止根项目及依赖脚本,命令行策略覆盖环境绕行', { timeout: 120000 }, (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'scert-install-policy-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const dependency = join(directory, 'dependency');
|
||||
mkdirSync(dependency);
|
||||
const script = 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'yes\')"';
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({
|
||||
name: 'scert-policy-fixture', version: '1.0.0', scripts: { postinstall: script },
|
||||
}));
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({
|
||||
name: 'scert-policy-test', version: '1.0.0', private: true,
|
||||
dependencies: { 'scert-policy-fixture': 'file:./dependency' },
|
||||
scripts: { postinstall: script },
|
||||
}));
|
||||
writeFileSync(join(directory, '.npmrc'), 'ignore-scripts=true\n');
|
||||
const run = (args) => {
|
||||
const options = { cwd: directory, encoding: 'utf8', windowsHide: true, env: npmEnvironment() };
|
||||
const result = process.platform === 'win32'
|
||||
? spawnSync(`${npmCommand} ${args.join(' ')}`, { ...options, shell: true })
|
||||
: spawnSync(npmCommand, args, options);
|
||||
assert.equal(result.status, 0, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`);
|
||||
};
|
||||
run(['install', '--package-lock-only', '--ignore-scripts', '--offline', '--no-audit', '--no-fund']);
|
||||
const lock = readFileSync(join(directory, 'package-lock.json'));
|
||||
const envKeys = ['npm_config_ignore_scripts', 'npm_config_dangerously_allow_all_scripts', 'npm_config_offline'];
|
||||
const previous = envKeys.map((key) => process.env[key]);
|
||||
try {
|
||||
process.env.npm_config_ignore_scripts = 'false';
|
||||
process.env.npm_config_dangerously_allow_all_scripts = 'true';
|
||||
process.env.npm_config_offline = 'true';
|
||||
installDependencies(directory);
|
||||
} finally {
|
||||
envKeys.forEach((key, i) => {
|
||||
if (previous[i] === undefined) delete process.env[key];
|
||||
else process.env[key] = previous[i];
|
||||
});
|
||||
}
|
||||
assert.ok(existsSync(join(directory, 'node_modules', 'scert-policy-fixture', 'package.json')));
|
||||
assert.ok(!existsSync(join(directory, 'lifecycle-ran')));
|
||||
assert.ok(!existsSync(join(dependency, 'lifecycle-ran')));
|
||||
assert.deepEqual(readFileSync(join(directory, 'package-lock.json')), lock);
|
||||
// 正向对照证明夹具确实会执行脚本,避免用未触发的脚本证明禁止策略。
|
||||
run(['ci', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--offline', '--no-audit', '--no-fund']);
|
||||
assert.ok(existsSync(join(directory, 'lifecycle-ran')));
|
||||
assert.ok(existsSync(join(dependency, 'lifecycle-ran')));
|
||||
});
|
||||
@ -38,7 +38,7 @@
|
||||
| P0-01 | [x] | 明确产品范围 | 正式发布仅含 Web/服务端,桌面源码保留;CLI 已收敛且实际 Windows 产物验证通过 | 等待最终复审;Windows/Linux 实机安装仍归对应验收项 | 双平台归档及 SHA256、独立克隆、命令树及实际退出码 |
|
||||
| P0-02 | [x] | 建立完整任务清单 | 44 项逐项规格、原始编号名称、字段、六阶段、引用和汇总状态核对通过 | 等待最终复审,随实现同步状态和证据 | 四份规格、统一验收约定及 `fab98d1` 独立克隆 |
|
||||
| P0-03 | [x] | 整理源码、构建产物和部署产物 | 全仓库及子目录规则、目录说明、正式回归和独立克隆构建校验通过 | 等待最终复审,后续提交继续执行边界检查 | 正式 Go 回归、完整索引、双平台 33 项清单及解包校验 |
|
||||
| P0-04 | [~] | 统一开发和构建版本 | 模块实际使用 Go `1.26.6`,不存在此前误报的版本阻塞;历史文档仍需统一 | 统一模块、构建说明、锁文件和验收机版本记录 | 版本矩阵、构建日志 |
|
||||
| P0-04 | [~] | 统一开发和构建版本 | 精确工具链及直接依赖、禁止安装脚本、发布前后校验和构建机清单已实现,25 项专用回归及完整自动化通过 | 独立克隆复测;Linux 原生构建与双平台运行环境仍需记录 | 版本矩阵、175 项前端测试、后端完整回归、开发态双平台构建 |
|
||||
| P0-05 | [!] | 确认数据库正式支持矩阵 | 三种数据库均有代码,但 README、文档和实机证据口径不完全一致 | 明确 SQLite、MySQL、PostgreSQL 的正式级别、版本和迁移回滚要求 | 三数据库迁移、备份恢复和连接测试 |
|
||||
| P0-06 | [~] | 建立统一领域模型 | 已有域名、证书、任务、服务器等实体,项目和部署关联仍需审查 | 补齐资源关系图、外键/归属和任务关联 | 模型文档、迁移检查、接口测试 |
|
||||
| P0-07 | [~] | 增加项目级权限和资源隔离 | 用户组和角色已有实现,项目级资源边界未闭环 | 为证书、DNS、服务器、任务和审计补齐项目归属及越权测试 | 权限矩阵、接口越权测试 |
|
||||
|
||||
@ -48,14 +48,14 @@
|
||||
|
||||
- 任务编号:P0-04;任务名称:统一开发和构建版本;优先级:P0。
|
||||
- 当前状态:`[~] 进行中`。
|
||||
- 六阶段:需求确认 `[x]`;代码实现 `[~]`;自动化测试 `[~]`;隔离环境测试 `[~]`;生产部署测试 `[ ]`;最终验收 `[ ]`。
|
||||
- 问题说明:已有模块和锁文件版本,但文档、安装脚本批准策略、操作系统与构建机记录需统一。
|
||||
- 涉及文件:`backend/go.mod`、`backend/go.sum`、`frontend/package.json`、`frontend/package-lock.json`、`build/build-release.ps1`、《构建版本与数据库支持矩阵.md》。
|
||||
- 六阶段:需求确认 `[x]`;代码实现 `[x]`;自动化测试 `[x]`;隔离环境测试 `[~]`;生产部署测试 `[ ]`;最终验收 `[ ]`。
|
||||
- 问题说明:已有模块和锁文件版本;本批补齐精确前端工具链、禁用安装脚本、发布前后校验和构建机记录,仍需独立克隆与运行环境证据。
|
||||
- 涉及文件:`backend/go.mod`、`backend/go.sum`、`frontend/package.json`、`frontend/package-lock.json`、`frontend/.npmrc`、`build/build-release.ps1`、`build/toolchain.mjs`、`build/toolchain.test.mjs`、《构建版本与数据库支持矩阵.md》。
|
||||
- 依赖任务:P0-01、P0-03。
|
||||
- 整改内容:记录并固定 Go、Node、npm、Vue、驱动和依赖版本、系统版本、编译目标;检查锁文件安装及构建环境差异。模块内实际 Go 版本优先,不按根目录工具版本降级模块。
|
||||
- 验证命令:V02、V03、V04;模块内 `go version`、`go env GOVERSION GOTOOLCHAIN`;`node --version`、`npm.cmd --version`。
|
||||
- 实机验收步骤:全新构建环境按固定版本安装依赖;验证锁文件不漂移;在双平台验收机记录系统版本和运行包版本,比较 manifest。
|
||||
- 验收证据:已有 Go 1.26.6 与前端锁文件基线、独立克隆构建;安装脚本策略和完整系统矩阵尚缺。
|
||||
- 验收证据:Go 1.26.6、Node 24.19.0、npm 11.17.0 精确检查;前端 175 项通过,其中 25 项工具链回归;真实离线安装正反对照证明脚本策略生效;后端全量测试、vet、模块校验通过,开发态双平台构建通过,独立克隆结果待补。
|
||||
- 复审结果:未完成,不以一次构建通过代表可重复发布。
|
||||
|
||||
## P0-05 确认数据库正式支持矩阵
|
||||
|
||||
@ -8,9 +8,9 @@
|
||||
|---|---|---|---|
|
||||
| Go | `backend/go.mod`、`backend/go.sum` | 模块内 `go1.26.6 windows/amd64` | 在模块内记录实际版本,不使用仓库根目录的版本推断 |
|
||||
| 工具链选择 | `go env GOTOOLCHAIN` | `auto` | 自动选择仍必须与模块要求一致,不得降低版本绕过失败 |
|
||||
| Node.js | 本机 `node --version` | `v24.19.0` | 当前验证基线;后续升级需重跑测试和构建 |
|
||||
| npm | 本机 `npm.cmd --version` | `11.17.0` | 使用锁文件安装;Windows 使用 `npm.cmd` |
|
||||
| Vue | `frontend/package-lock.json` | `3.5.41` | 发布以锁文件为准,不以 package.json 的范围猜测实际版本 |
|
||||
| Node.js | `frontend/package.json` 的 `engines`、`devEngines`,锁文件 | `v24.19.0` | 精确匹配,升级需同步声明并重跑测试和构建 |
|
||||
| npm | 同上,另有 `packageManager` | `11.17.0` | 精确匹配,使用锁文件安装;Windows 使用 `npm.cmd` |
|
||||
| Vue | `frontend/package.json`、`frontend/package-lock.json` | `3.5.41` | 直接依赖固定精确版本,声明及锁文件必须一致 |
|
||||
| Vite | 同上 | `6.4.3` | 同上 |
|
||||
| Vue 构建插件 | 同上 | `5.2.4` | 同上 |
|
||||
| SQLite Go 驱动 | `backend/go.mod` | `modernc.org/sqlite v1.37.0` | 驱动版本不等于数据库引擎版本 |
|
||||
@ -35,7 +35,20 @@ npm.cmd test
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
本轮未重新执行 `npm.cmd ci`;已执行现有依赖下的前端测试、构建和生产依赖审计。发布前仍需在独立干净环境按锁文件安装,记录操作系统实际版本和发布清单。
|
||||
历史基线只验证过现有依赖;随后 P0-03 已在 `fab98d1` 独立克隆重新执行 `npm ci`、150 项前端测试和双平台候选构建。该次安装仍有脚本批准策略警告,不能证明安装脚本被禁止。P0-04 改为明确禁用依赖安装脚本,执行证据持续记录在《Scert整改任务执行清单.md》,不能将旧安装结果当作新策略验证。
|
||||
|
||||
### 构建机与安装策略
|
||||
|
||||
| 项目 | 固定基线或约束 | 验收边界 |
|
||||
|---|---|---|
|
||||
| 当前 Windows 构建机 | Windows 11 Home China,系统 `10.0.26200`,amd64;Windows PowerShell 5.1 | 记录实际值,不把系统显示名称误写成内核版本;系统更新后需重新验收 |
|
||||
| Linux 原生构建机 | 脚本允许 Linux amd64,但尚无本批指定发行版、内核、PowerShell 版本的原生构建证据 | 不宣称 Linux 原生可重复构建已通过;Linux 候选包由 Windows 交叉编译 |
|
||||
| 目标平台 | `windows/amd64`、`linux/amd64`,`CGO_ENABLED=0`、`-trimpath`、`-mod=readonly` | 构建机版本与生产运行系统分别记录 |
|
||||
| 安装脚本 | `.npmrc` 固定 `ignore-scripts=true`,正式入口再显式传参 | npm 未审查警告不是安全保证,禁止白名单和全量放行 |
|
||||
| 可选平台包 | `npm ci` 显式包含开发和可选依赖 | esbuild 不执行安装脚本,使用锁文件中的平台包;外部二进制覆盖被拒绝 |
|
||||
| 发布追溯 | `manifest.json` 的 `build_environment` | 包含实际工具链、系统、直接前端依赖、安装策略和五份输入文件 SHA256;Go 驱动及依赖由模块文件哈希追溯 |
|
||||
|
||||
正式发布入口自动校验工具链、核验 Go 模块缓存、按锁文件干净安装,并在构建结束后复核输入文件。环境不符时尽早失败;构建中版本文件漂移则不生成发布清单。`toolchain.mjs` 和配套测试属于正式构建代码,不作为替代真实部署的辅助验收脚本。具体流程见 `build/README.md`。
|
||||
|
||||
## 二、数据库支持矩阵
|
||||
|
||||
|
||||
3
frontend/.npmrc
Normal file
3
frontend/.npmrc
Normal file
@ -0,0 +1,3 @@
|
||||
engine-strict=true
|
||||
ignore-scripts=true
|
||||
save-exact=true
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -8,11 +8,15 @@
|
||||
"name": "scert-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0"
|
||||
"vue": "3.5.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"vite": "^6.0.0"
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"vite": "6.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.19.0",
|
||||
"npm": "11.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
||||
@ -3,17 +3,34 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@11.17.0",
|
||||
"engines": {
|
||||
"node": "24.19.0",
|
||||
"npm": "11.17.0"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "24.19.0",
|
||||
"onFail": "error"
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "npm",
|
||||
"version": "11.17.0",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"test": "node --test ../build/toolchain.test.mjs src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/Dashboard/SettingsModal.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0"
|
||||
"vue": "3.5.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"vite": "^6.0.0"
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"vite": "6.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user