PowerShell 发布脚本为什么容易失控,以及如何通过切换工作目录减少路径噪声

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
PowerShell发布脚本DevOps

我曾经把 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 名称、参数名和对象管道本来就比 cprm 长。如果再把完整路径重复到每一行,脚本当然显得笨重。语法只放大了问题,路径设计才是主要原因。

当前工作目录本来就是上下文

PowerShell 的 Location是命令在没有显式路径时使用的默认位置。进入前端目录后:

Set-Location "src/Web"

npm ci
npm run build
Copy-Item "dist/*" $frontendPublishDirectory -Recurse

npm 会读取当前目录中的 package.jsonCopy-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 cinpm run builddotnet 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-LocationPop-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-ItemCopy-Item 等 Cmdlet 会进入 PowerShell 的错误处理系统,$ErrorActionPreference = "Stop" 可以让多数错误终止脚本。

npmdotnetdockergit 是原生程序。它们通过退出码报告失败,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 中也有 cdcprm 这些别名,但提交到仓库的脚本使用 Set-LocationCopy-ItemRemove-Item 更明确。减少噪声的办法不是把 Cmdlet 全部缩成别名,而是减少重复信息。

Join-Path 也应该保留。它适合生成共享目录,能让 PowerShell Provider 决定路径分隔符。问题只出在每一行都嵌套几个 Join-Path,以至于读者先数括号,最后才看到脚本想执行什么。

判断一份发布脚本是否需要整理,可以先看命令主体。如果把路径参数遮住以后,已经认不出构建顺序,路径就写得太多了。把工作目录切换放在阶段开头,通常比继续抽变量、加函数更有效。

这套错误处理和目录恢复方式也用于Angular 与 ASP.NET Core 单镜像发布多角色 ASP.NET Core 配置发布。后两篇分别处理前后端制品组装与角色配置选择。