fix(build): Windows 树杀与错误码分阶段包裹;清残覆盖 manifest;反斜杠守卫与 stat 收窄

质量复审五修(Plan 4 Task 5):
- Fix 1 超时杀整棵进程树:win32 走 taskkill /PID /T /F(child.kill 只杀
  pnpm.cmd 壳层,astro/vite 孤儿会继续写 dist 并占句柄);POSIX detached
  进程组 + kill(-pgid, SIGKILL)。暂存 dist 预清 rm 包成 THEME_BUILD_FAILED
  (孤儿句柄 EPERM 不再裸抛误标 SNAPSHOT_FAILED)。
- Fix 2 五处裸抛分阶段裹码:媒体校验读回 → MEDIA_COPY_FAILED;sitemap/
  robots 写、dist 拷出、目录哈希 → MANIFEST_WRITE_FAILED;service 对
  building 期非 PipelineError 裸错误标 PIPELINE_INTERNAL(码位新增)。
- Fix 3 失败清残覆盖 route-manifest.json;build_artifacts 已登记行注明
  审计豁免(与快照行同语义)。
- Fix 4 媒体 public_path 反斜杠拒绝(Windows 路径解析会绕过 '/'-split
  的 '..' 段检测)。
- Fix 5 stat 缺席语义缩窄到 ENOENT,其余错误原样上抛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 11:30:05 +08:00
parent 1ed3ac062a
commit 41829e2665
3 changed files with 150 additions and 30 deletions
+33 -11
View File
@@ -59,6 +59,7 @@ export type BuildErrorCode =
| 'NOT_PUBLISHED' | 'NOT_PUBLISHED'
| 'NO_SITE_CONFIG' | 'NO_SITE_CONFIG'
| 'SNAPSHOT_FAILED' | 'SNAPSHOT_FAILED'
| 'PIPELINE_INTERNAL'
| 'REPORT_WRITE_FAILED' | 'REPORT_WRITE_FAILED'
| 'ARTIFACT_REGISTER_FAILED' | 'ARTIFACT_REGISTER_FAILED'
@@ -363,7 +364,11 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
const reportPath = join(reportDir, 'build.json') const reportPath = join(reportDir, 'build.json')
input.onStatus?.('queued', jobId) input.onStatus?.('queued', jobId)
// 失败收口:job 行保留(failed 终态 + error 面 + input/ 目录清场)后原样上抛——镜像 import 纪律 // 失败收口:job 行保留(failed 终态 + error 面 + 产物清场)后原样上抛——镜像 import 纪律。
// 阶段标记(审查复审 Fix 2):catch 里对"非 BuildError/PipelineError 的裸错误"按阶段
// 给码——building 期裸错误是管线内部程序性破裂(PIPELINE_INTERNAL),不误标
// SNAPSHOT_FAILED;更早阶段(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。
let phase: 'snapshotting' | 'building' | 'validating' | 'reporting' = 'snapshotting'
try { try {
// 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进) // 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进)
await db await db
@@ -416,6 +421,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
// stderr 标记进 error_summary——收口见下方 catch)。 // stderr 标记进 error_summary——收口见下方 catch)。
await db.update(buildJobs).set({ status: 'building' }).where(eq(buildJobs.id, jobId)) await db.update(buildJobs).set({ status: 'building' }).where(eq(buildJobs.id, jobId))
input.onStatus?.('building', jobId) input.onStatus?.('building', jobId)
phase = 'building'
const built = await runBuildPipeline(db, { const built = await runBuildPipeline(db, {
jobId: jobId!, jobId: jobId!,
siteId: site.id, siteId: site.id,
@@ -426,6 +432,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
// 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整) // 阶段 7:validating(**空门禁**——Task 6 填真实门禁;状态机占位完整)
await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId)) await db.update(buildJobs).set({ status: 'validating' }).where(eq(buildJobs.id, jobId))
input.onStatus?.('validating', jobId) input.onStatus?.('validating', jobId)
phase = 'validating'
// 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在 // 阶段 8:报告先行落盘(temp+rename 原子替换),终态落行在后——行内即文件在
const report: BuildReport = { const report: BuildReport = {
@@ -474,6 +481,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
durationMs: performance.now() - startedAt, durationMs: performance.now() - startedAt,
} }
const reportText = `${JSON.stringify(report, null, 2)}\n` const reportText = `${JSON.stringify(report, null, 2)}\n`
phase = 'reporting'
try { try {
await mkdir(reportDir, { recursive: true }) await mkdir(reportDir, { recursive: true })
await writeAtomically(reportPath, reportText) await writeAtomically(reportPath, reportText)
@@ -520,22 +528,36 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
durationMs: performance.now() - startedAt, durationMs: performance.now() - startedAt,
} }
} catch (err) { } catch (err) {
// 阶段敏感的错误码映射:BuildError 保自身码(解析/报告/登记);PipelineError 带 // 阶段敏感的错误码映射(审查复审 Fix 2):BuildError 保自身码(解析/报告/登记);
// 管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并入 error_summary); // PipelineError 带管线阶段码(spawn/媒体/manifest——stderr 标记在 message 内一并
// 其余(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。 // 入 error_summary);其余裸错误按阶段给码——building 期是 PIPELINE_INTERNAL
const code = err instanceof BuildError ? err.code : err instanceof PipelineError ? err.code : 'SNAPSHOT_FAILED' // (管线内部程序性破裂),更早阶段(快照事务/物化)沿用 SNAPSHOT_FAILED 总括码。
const code = err instanceof BuildError
? err.code
: err instanceof PipelineError
? err.code
: phase === 'building'
? 'PIPELINE_INTERNAL'
: 'SNAPSHOT_FAILED'
const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err) const summary = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
await db await db
.update(buildJobs) .update(buildJobs)
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` }) .set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
.where(eq(buildJobs.id, jobId)) .where(eq(buildJobs.id, jobId))
// 失败清场(审查 Fix 3 + Task 5 扩面):半途落盘的 input/ 与 dist/(job 级产物目录 // 失败清场(审查 Fix 3 + Task 5 扩面 + 复审 Fix 3):半途落盘的 input/、dist/(job 级
// + 主题包暂存 dist)移除——failed 终态无产物语义,不留"行说 failed 而目录半存" // 产物目录 + 主题包暂存 dist)与 route-manifest.json 移除——failed 终态无产物语义,
// 的歧义现场。快照行若已插入则由不可变触发器保留为审计记录(manifest_path 悬空 // 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为
// 可接受:job 已 failed,无下游消费方)。清场失败不掩盖原始错误(残留目录由操作者处置)。 // 审计记录;build_artifacts 行若已登记(如 report 落盘后、终态更新前的窗口)同样
for (const dir of [inputDir, join(artifactsRoot, 'jobs', jobId, 'dist'), themeDistDir()]) { // 保留为审计记录——storage_path 悬空可接受:job 已 failed,无下游消费方。清场失败
// 不掩盖原始错误(残留由操作者处置)。
for (const target of [
inputDir,
join(artifactsRoot, 'jobs', jobId, 'dist'),
join(artifactsRoot, 'jobs', jobId, 'route-manifest.json'),
themeDistDir(),
]) {
try { try {
await rm(dir, { recursive: true, force: true }) await rm(target, { recursive: true, force: true })
} catch { } catch {
// best-effort:保留原错误上抛 // best-effort:保留原错误上抛
} }
+114 -18
View File
@@ -19,8 +19,9 @@
// @astrojs/telemetry 在构建期真调 global fetch POST(实测撞无网 stub);离线构建 // @astrojs/telemetry 在构建期真调 global fetch POST(实测撞无网 stub);离线构建
// 纪律下遥测必须关。NODE_OPTIONS 原样透传(spread 继承 + 本管线不自加标志的合并 // 纪律下遥测必须关。NODE_OPTIONS 原样透传(spread 继承 + 本管线不自加标志的合并
// 语义):测试可注入 `--import <stub>` 预载钩子证明构建进程树不联网。 // 语义):测试可注入 `--import <stub>` 预载钩子证明构建进程树不联网。
import { spawn } from 'node:child_process' import { spawn, type ChildProcess } from 'node:child_process'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import type { Stats } from 'node:fs'
import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path' import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@@ -122,6 +123,30 @@ function spawnEnv(inputDir: string): NodeJS.ProcessEnv {
} }
} }
/**
* 超时杀**整棵进程树**(审查复审 Fix 1):Windows 下 child.kill 只杀 pnpm.cmd 壳层,
* astro/vite 子进程会存活继续写 dist(孤儿进程占句柄还会让后续 rm 撞 EPERM)——
* taskkill /T /F 才是树杀;POSIX 以 detached 进程组长起树、kill(-pgid) 组杀
* (组长已被回收时回退杀 pid 单点)。
*/
function killProcessTree(child: ChildProcess): void {
const pid = child.pid
if (pid === undefined) {
child.kill('SIGKILL')
return
}
if (process.platform === 'win32') {
// taskkill.exe(System32,PATH 内 .exe 无需 shell);/T 递归子树 /F 强杀
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} else {
try {
process.kill(-pid, 'SIGKILL')
} catch {
child.kill('SIGKILL')
}
}
}
/** astro 构建 spawn:cwd=仓库根(pnpm workspace 解析),退出非零即败(stderr 摘要入 message) */ /** astro 构建 spawn:cwd=仓库根(pnpm workspace 解析),退出非零即败(stderr 摘要入 message) */
function spawnAstroBuild(inputDir: string): Promise<void> { function spawnAstroBuild(inputDir: string): Promise<void> {
return new Promise<void>((resolvePromise, rejectPromise) => { return new Promise<void>((resolvePromise, rejectPromise) => {
@@ -131,15 +156,23 @@ function spawnAstroBuild(inputDir: string): Promise<void> {
const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], { const child = spawn('pnpm', ['--filter', THEME_PACKAGE, 'exec', 'astro', 'build'], {
cwd: REPO_ROOT, cwd: REPO_ROOT,
shell: true, // Windows:pnpm 是 pnpm.cmd,须经 shell 解析(参数均为无空格字面量) shell: true, // Windows:pnpm 是 pnpm.cmd,须经 shell 解析(参数均为无空格字面量)
// POSIX:detached 让子树自成进程组(超时可 kill(-pgid) 树杀);Windows 不设——
// detached 在 win32 会开新控制台窗(树杀走 taskkill /T,不需要)
detached: process.platform !== 'win32',
env: spawnEnv(inputDir), env: spawnEnv(inputDir),
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
const timer = setTimeout(() => { const timer = setTimeout(() => {
child.kill('SIGKILL') killProcessTree(child)
if (!settled) { if (!settled) {
settled = true settled = true
rejectPromise( rejectPromise(
new PipelineError('THEME_BUILD_FAILED', `astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——SIGKILL 终结`), new PipelineError(
'THEME_BUILD_FAILED',
`astro 构建超时(>${SPAWN_TIMEOUT_MS}ms)——进程树终结(${
process.platform === 'win32' ? 'taskkill /T /F' : 'kill(-pgid, SIGKILL)'
})`,
),
) )
} }
}, SPAWN_TIMEOUT_MS) }, SPAWN_TIMEOUT_MS)
@@ -201,6 +234,19 @@ export function deriveTargetFile(routePath: string): string {
return rel.endsWith('.html') ? rel : `${rel}/index.html` return rel.endsWith('.html') ? rel : `${rel}/index.html`
} }
/**
* stat 缺席语义缩窄到 ENOENT(→ null;审查复审 Fix 5):其余错误(EPERM/IO 破裂)
* 不是"产物缺席",原样上抛——静默吞掉会把权限/磁盘故障误判成"构建产物缺失"。
*/
async function statOrNull(p: string): Promise<Stats | null> {
try {
return await stat(p)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
throw err
}
}
/** temp+rename 原子写(与 build.service 同款纪律——不留半成品) */ /** temp+rename 原子写(与 build.service 同款纪律——不留半成品) */
async function writeAtomically(path: string, content: string): Promise<void> { async function writeAtomically(path: string, content: string): Promise<void> {
const tmpPath = `${path}.tmp` const tmpPath = `${path}.tmp`
@@ -216,8 +262,14 @@ async function writeAtomically(path: string, content: string): Promise<void> {
/** 媒体复制:copyPath→dist/public_path;**每条**落盘后 sha256 全量校验(非抽检)失配即败 */ /** 媒体复制:copyPath→dist/public_path;**每条**落盘后 sha256 全量校验(非抽检)失配即败 */
async function copyMedia(distDir: string, assets: AssetEntry[]): Promise<number> { async function copyMedia(distDir: string, assets: AssetEntry[]): Promise<number> {
for (const a of assets) { for (const a of assets) {
// public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写) // public_path 必须是根相对安全路径(media-writer 上游已按此登记——此处守越界写);
if (!a.publicPath.startsWith('/') || a.publicPath.split('/').includes('..')) { // 反斜杠单列拒绝(审查复审 Fix 4):Windows 路径解析把 '\' 当分隔符,混入会绕过
// 上面的 '/'-split '..' 段检测(如 '/safe\..\..\x')——落盘前拦死。
if (
!a.publicPath.startsWith('/') ||
a.publicPath.split('/').includes('..') ||
a.publicPath.includes('\\')
) {
throw new PipelineError( throw new PipelineError(
'MEDIA_COPY_FAILED', 'MEDIA_COPY_FAILED',
`媒体 public_path 非根相对安全路径(${a.publicPath})——拒绝越界落盘`, `媒体 public_path 非根相对安全路径(${a.publicPath})——拒绝越界落盘`,
@@ -234,7 +286,17 @@ async function copyMedia(distDir: string, assets: AssetEntry[]): Promise<number>
`媒体复制失败(${a.publicPath} ← ${a.copyPath}):${err instanceof Error ? err.message : String(err)}`, `媒体复制失败(${a.publicPath} ← ${a.copyPath}):${err instanceof Error ? err.message : String(err)}`,
) )
} }
const copied = createHash('sha256').update(await readFile(dst)).digest('hex') // 校验读回也不裸抛(审查复审 Fix 2):复制成功但读回失败同样是媒体落盘阶段失败
let copiedBuf: Buffer
try {
copiedBuf = await readFile(dst)
} catch (err) {
throw new PipelineError(
'MEDIA_COPY_FAILED',
`媒体落盘回读失败(${a.publicPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
const copied = createHash('sha256').update(copiedBuf).digest('hex')
if (copied !== a.sha256) { if (copied !== a.sha256) {
throw new PipelineError( throw new PipelineError(
'MEDIA_COPY_FAILED', 'MEDIA_COPY_FAILED',
@@ -372,21 +434,30 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn
throw new PipelineError('INPUT_INVALID', 'render-manifest.json sourceHashes.route 缺席——哈希链断链') throw new PipelineError('INPUT_INVALID', 'render-manifest.json sourceHashes.route 缺席——哈希链断链')
} }
// ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险) // ① spawn(主题包 dist 暂存先行清空——跨 build 残留归零,astro 自身清 outDir 之外双保险)。
// 清空失败按构建阶段失败收口(审查复审 Fix 1):最常见诱因是超时孤儿进程仍持文件
// 句柄(EPERM)——这不是快照阶段错误,裸抛会被 service 误标 SNAPSHOT_FAILED。
const scratchDist = themeDistDir() const scratchDist = themeDistDir()
await rm(scratchDist, { recursive: true, force: true }) try {
await rm(scratchDist, { recursive: true, force: true })
} catch (err) {
throw new PipelineError(
'THEME_BUILD_FAILED',
`清空主题暂存 dist 失败(${scratchDist}):${err instanceof Error ? err.message : String(err)}——疑似孤儿构建进程占用`,
)
}
await spawnAstroBuild(input.inputDir) await spawnAstroBuild(input.inputDir)
// ② dist 定位与完整性:每条 html 行 target_file(派生映射)在暂存 dist 存在且是文件 // ② dist 定位与完整性:每条 html 行 target_file(派生映射)在暂存 dist 存在且是文件
// (主题侧折平集成已对齐映射——此处失配即主题/管线映射破裂,禁静默放行) // (主题侧折平集成已对齐映射——此处失配即主题/管线映射破裂,禁静默放行)
const scratchStat = await stat(scratchDist).catch(() => null) const scratchStat = await statOrNull(scratchDist)
if (scratchStat === null || !scratchStat.isDirectory()) { if (scratchStat === null || !scratchStat.isDirectory()) {
throw new PipelineError('THEME_BUILD_FAILED', `主题构建产物目录缺席:${scratchDist}`) throw new PipelineError('THEME_BUILD_FAILED', `主题构建产物目录缺席:${scratchDist}`)
} }
for (const r of routes) { for (const r of routes) {
if (r.routeKind !== 'html') continue if (r.routeKind !== 'html') continue
const rel = deriveTargetFile(r.routePath) const rel = deriveTargetFile(r.routePath)
const st = await stat(join(scratchDist, rel)).catch(() => null) const st = await statOrNull(join(scratchDist, rel))
if (st === null || !st.isFile()) { if (st === null || !st.isFile()) {
throw new PipelineError( throw new PipelineError(
'THEME_BUILD_FAILED', 'THEME_BUILD_FAILED',
@@ -395,16 +466,32 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn
} }
} }
// ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件) // ③ 媒体复制(sha256 全量校验)④ sitemap/robots(写入暂存 dist——它们是站点服务面文件);
// 落盘不裸抛(审查复审 Fix 2):此阶段 IO 失败是 manifest/服务面文件写入失败
const mediaCopied = await copyMedia(scratchDist, assets) const mediaCopied = await copyMedia(scratchDist, assets)
const sitemap = buildSitemap(origin, routes) const sitemap = buildSitemap(origin, routes)
await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml) try {
await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin)) await writeAtomically(join(scratchDist, 'sitemap.xml'), sitemap.xml)
await writeAtomically(join(scratchDist, 'robots.txt'), buildRobots(origin))
} catch (err) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`sitemap/robots 落盘失败(${scratchDist}):${err instanceof Error ? err.message : String(err)}`,
)
}
// ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律) // ⑤ 暂存拷出到 job 级权威产物目录,暂存清空(见模块头双位置纪律);拷出不裸抛
// (审查复审 Fix 2)——dist 产物落位失败按 manifest 写入阶段收口
const distPath = join(input.artifactsRoot, 'jobs', input.jobId, 'dist') const distPath = join(input.artifactsRoot, 'jobs', input.jobId, 'dist')
await rm(distPath, { recursive: true, force: true }) try {
await copyTree(scratchDist, distPath) await rm(distPath, { recursive: true, force: true })
await copyTree(scratchDist, distPath)
} catch (err) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`dist 产物拷出失败(${scratchDist} → ${distPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
await rm(scratchDist, { recursive: true, force: true }) await rm(scratchDist, { recursive: true, force: true })
// ⑥ route-manifest(§12.1:物化哈希链复用 + 行序 = routes.json 物化序) // ⑥ route-manifest(§12.1:物化哈希链复用 + 行序 = routes.json 物化序)
@@ -429,8 +516,17 @@ export async function runBuildPipeline(db: PipelineDb, input: RunBuildPipelineIn
) )
} }
// ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path)) // ⑦ build_artifacts 登记(dist/manifest 两行;UNIQUE(build_job_id,kind,storage_path));
const dirHash = await hashDirectory(distPath) // 目录哈希计算不裸抛(审查复审 Fix 2)——计算失败按 manifest 登记阶段收口
let dirHash: { sha256: string; byteSize: number; fileCount: number }
try {
dirHash = await hashDirectory(distPath)
} catch (err) {
throw new PipelineError(
'MANIFEST_WRITE_FAILED',
`dist 目录哈希计算失败(${distPath}):${err instanceof Error ? err.message : String(err)}`,
)
}
const manifestSha256 = createHash('sha256').update(manifestText, 'utf8').digest('hex') const manifestSha256 = createHash('sha256').update(manifestText, 'utf8').digest('hex')
try { try {
await db.insert(buildArtifacts).values([ await db.insert(buildArtifacts).values([
+3 -1
View File
@@ -369,8 +369,10 @@ describe('构建管线全链(mini 夹具 DB 物化输入)', () => {
expect(job[0].error_summary).toContain('Render Snapshot 文件缺失或不可读') expect(job[0].error_summary).toContain('Render Snapshot 文件缺失或不可读')
expect(job[0].error_summary).toContain('content.json') expect(job[0].error_summary).toContain('content.json')
// dist 清残:产物目录与主题暂存 dist 均无残留;input/ 同样清场(failed 无产物语义) // dist 清残:产物目录与主题暂存 dist 均无残留;input/ 与 route-manifest.json
// 同样清场(failed 无产物语义——复审 Fix 3:清残面覆盖 manifest 文件)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'dist'))).toBe(false) expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'dist'))).toBe(false)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'route-manifest.json'))).toBe(false)
expect(existsSync(themeDistDir())).toBe(false) expect(existsSync(themeDistDir())).toBe(false)
expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'input'))).toBe(false) expect(existsSync(join(artifactsRoot, 'jobs', failingJobId, 'input'))).toBe(false)
// 半途失败无 build_artifacts 行 // 半途失败无 build_artifacts 行