PowerShell 发布脚本为什么容易失控,以及如何通过切换工作目录减少路径噪声
我曾经把 PowerShell 发布脚本写成这样:
$rootDirectory = $PSScriptRoot
$frontendDirectory = Join-Path $rootDirectory "src/Web"
$backendDirectory = Join-Path $rootDirectory "src/Server"
$publishDirectory = Join-Path $rootDirectory "dist/host"
npm --prefix $frontendDirectory ci
npm --prefix $frontendDirectory run build
dotnet publish \
(Join-Path $backendDirectory "WebHost.csproj") \
-c Release \
-o $publishDirectory
Copy-Item \
(Join-Path $frontendDirectory "dist/*") \
(Join-Path $publishDirectory "frontend") \
-Recurse先不讨论这段代码里的反斜杠不是 PowerShell 续行符。更明显的问题是,真正的发布过程已经很难看出来了。脚本做的事情原本很简单:构建前端、发布后端、复制前端产物。每条命令却都在重新解释目录结构。
Join-Path 本身很正常,PowerShell 的语法也确实偏长。更大的问题是,脚本把“无论当前位于哪里都能执行”当成了每条命令的责任。
Shell 脚本为什么看起来更短
常见的 Shell 发布脚本会先进入项目目录:
cd src/web
npm ci
npm run build
cd ../server
dotnet publish -c Release -o ../../dist/host后面的命令都借用了当前工作目录,所以读者看到的是动作。PowerShell 脚本经常反过来:始终停在仓库根目录,给 npm 传 --prefix,给 dotnet publish 传项目文件,给每个 Copy-Item 拼接源路径和目标路径。
PowerShell 的 Cmdlet 名称、参数名和对象管道本来就比 cp、rm 长。如果再把完整路径重复到每一行,脚本当然显得笨重。语法只放大了问题,路径设计才是主要原因。
当前工作目录本来就是上下文
PowerShell 的 Location是命令在没有显式路径时使用的默认位置。进入前端目录后:
Set-Location "src/Web"
npm ci
npm run build
Copy-Item "dist/*" $frontendPublishDirectory -Recursenpm 会读取当前目录中的 package.json,Copy-Item 的源路径也一眼可见。此时继续写 npm --prefix $frontendDirectory 没有增加多少可靠性,只是让目录信息重复出现。
同样,进入后端项目目录后,通常可以直接执行:
Set-Location $backendDirectory
dotnet publish -c Release -o $publishDirectory如果目录中只有一个可发布项目,就不必再把 .csproj 路径传给 dotnet publish。项目路径只有在目录中有多个候选项目,或者脚本有意从其他位置调用它时才有价值。
一份容易读懂的发布脚本
下面的示例保留一个仓库锚点和一个发布目录。工作目录按构建阶段切换:
param(
[switch]$BuildFrontend
)
$ErrorActionPreference = "Stop"
$rootDirectory = $PSScriptRoot
$publishDirectory = Join-Path $rootDirectory "dist/host"
Push-Location $rootDirectory
try
{
Remove-Item $publishDirectory -Recurse -Force -ErrorAction Ignore
Set-Location "src/Server"
dotnet publish -c Release -o $publishDirectory -t:Rebuild
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed: $LASTEXITCODE" }
Set-Location "$rootDirectory/src/Web"
if ($BuildFrontend)
{
npm ci
if ($LASTEXITCODE -ne 0) { throw "npm ci failed: $LASTEXITCODE" }
npm run build
if ($LASTEXITCODE -ne 0) { throw "npm run build failed: $LASTEXITCODE" }
}
New-Item "$publishDirectory/frontend" -ItemType Directory -Force | Out-Null
Copy-Item "dist/*" "$publishDirectory/frontend" -Recurse -Force
}
finally
{
Pop-Location
}脚本仍然使用了绝对路径,但只用在确实跨阶段的对象上:仓库根目录和最终发布目录。npm ci、npm run build、dotnet publish 这些命令保留了平时手工执行时的样子。
PowerShell 的 Push-Location会先保存调用脚本前的位置,再进入仓库根目录。无论发布成功还是抛出异常,finally 中的 Pop-Location 都会把终端送回原来的目录。直接运行 .ps1 时,位置变化可能留在当前 PowerShell 会话里;不恢复位置会让脚本对调用者产生额外影响。
按阶段切换,不要来回跳
切换工作目录也能被滥用。下面这种写法同样难读:
Set-Location $frontendDirectory
npm ci
Set-Location $rootDirectory
Set-Location $frontendDirectory
npm run build
Set-Location $rootDirectory
Set-Location $backendDirectory
dotnet publish
Set-Location $rootDirectory目录应该跟随发布阶段,而不是跟随单条命令。进入前端目录后,把安装、构建和处理前端产物放在一起;随后进入后端目录,完成后端发布。Docker 构建需要以制品目录作为上下文时,再切换一次。
脚本读起来应该接近实际操作顺序:
清理发布目录
进入后端目录并发布
进入前端目录并复制产物
进入制品目录并构建镜像
恢复调用者原来的目录目录切换成了阶段边界,而不是散落在命令参数里的背景噪声。
哪些路径应该继续明确写出
仍有一些路径应当明确锚定在 $PSScriptRoot:
- 会被多个阶段共同使用的发布目录;
- 即将递归删除或覆盖的目录;
- 脚本运行过程中需要跨项目访问的文件;
- 不允许随当前目录变化的 Docker 构建上下文。
尤其是 Remove-Item -Recurse。执行前应该能够从变量定义直接看出目标位于哪个仓库、哪个制品目录,不能让一串 ..\.. 决定删除范围。
相反,当前项目自己的输入文件可以保持相对路径:
Set-Location $frontendDirectory
Copy-Item "dist/*" $frontendPublishDirectory -Recurse“源文件属于当前阶段,目标目录属于整个发布过程”,这条界线通常很清楚。
Set-Location 与 Push-Location 怎样选择
顶层、线性执行的发布脚本适合用 Set-Location 表达阶段变化,再在脚本外层用一组 Push-Location 和 Pop-Location 保存调用者位置。
可复用函数则应该自己恢复位置:
function Invoke-FrontendBuild
{
param([string]$ProjectDirectory)
Push-Location $ProjectDirectory
try
{
npm ci
if ($LASTEXITCODE -ne 0) { throw "npm ci failed: $LASTEXITCODE" }
npm run build
if ($LASTEXITCODE -ne 0) { throw "npm run build failed: $LASTEXITCODE" }
}
finally
{
Pop-Location
}
}函数内部如果只调用 Set-Location 而不恢复,调用者必须知道它修改了全局位置状态。这样的函数很容易在脚本扩展后制造偶发错误。
ErrorActionPreference 管不了所有程序
PowerShell 的错误处理说明中,Remove-Item、Copy-Item 等 Cmdlet 会进入 PowerShell 的错误处理系统,$ErrorActionPreference = "Stop" 可以让多数错误终止脚本。
npm、dotnet、docker 和 git 是原生程序。它们通过退出码报告失败,PowerShell 自动变量文档中的 $LASTEXITCODE保存最近一个原生程序的退出码。Windows PowerShell 5.1 和较早的 PowerShell 7 默认不会因为非零退出码自动进入 catch,因此要在调用后立即检查它:
dotnet publish -c Release -o $publishDirectory
if ($LASTEXITCODE -ne 0)
{
exit $LASTEXITCODE
}PowerShell 7.4 及更高版本可以使用:
$ErrorActionPreference = "Stop"
$PSNativeCommandUseErrorActionPreference = $true遗留构建机仍在运行 Windows PowerShell 5.1 时,不要照搬这个变量后就以为错误处理已经生效。显式检查退出码虽然多一行,但行为清楚,也容易移植。
参数不是越多越专业
为了让脚本“通用”,很容易把每个目录都做成参数:
param(
[string]$RootDirectory,
[string]$FrontendDirectory,
[string]$BackendProject,
[string]$PublishDirectory,
[string]$Dockerfile,
[string]$ImageName
)如果这些路径在仓库里从不变化,它们就不是调用者的选择,而是仓库结构。把它们暴露成参数,只会把一份发布脚本变成一条很长的启动命令。
真正会变化的值才适合做参数,例如是否重建前端、镜像标签或发布角色。固定目录由 $PSScriptRoot 推导,读脚本的人可以在开头看到完整约定。
不必把 PowerShell 写成 Shell
PowerShell 中也有 cd、cp、rm 这些别名,但提交到仓库的脚本使用 Set-Location、Copy-Item 和 Remove-Item 更明确。减少噪声的办法不是把 Cmdlet 全部缩成别名,而是减少重复信息。
Join-Path 也应该保留。它适合生成共享目录,能让 PowerShell Provider 决定路径分隔符。问题只出在每一行都嵌套几个 Join-Path,以至于读者先数括号,最后才看到脚本想执行什么。
判断一份发布脚本是否需要整理,可以先看命令主体。如果把路径参数遮住以后,已经认不出构建顺序,路径就写得太多了。把工作目录切换放在阶段开头,通常比继续抽变量、加函数更有效。
这套错误处理和目录恢复方式也用于Angular 与 ASP.NET Core 单镜像发布和多角色 ASP.NET Core 配置发布。后两篇分别处理前后端制品组装与角色配置选择。