Windows 上的 GitLab Runner 出現「前面明明失敗,工作卻繼續跑」或「同一行在本機成功、CI 卻被 PowerShell 誤解」時,根因往往不是建置工具,而是執行 Shell、YAML 邊界與結束碼契約沒有被說清楚。可靠的修法不是增加重試,而是先辨識 Runner 實際使用 powershell 還是 pwsh,再讓每個關鍵步驟具有可觀察、可中止、可在沙箱重現的失敗條件。
本文鎖定 Windows shell executor 的命令語意與 fail-fast 設計。它不重複一般 CI/CD 治理文章的審批、回復與流程角色,也不處理 DLL 原子部署;搜尋意圖是回答「Windows Runner 為何用錯 Shell 語法、如何讓原生命令失敗立即中止」。範例只操作暫存資料夾,不連線正式環境、不清空網站、不執行發布。
直接答案:先確認 Shell,再定義失敗契約
直接答案:Windows Runner 的 script 不是抽象命令列;它會由設定的 Shell 產生成 PowerShell 腳本。先在 log 印出版本與命令解析結果,再把 cmd 專用語法改成 PowerShell cmdlet 或明確交給 cmd.exe /c,並在每個原生程式後檢查 $LASTEXITCODE。
GitLab 官方文件把 powershell 與 pwsh 列為不同執行環境:前者是 Windows PowerShell Desktop,後者是 PowerShell Core。Runner 會先產生腳本檔,再由相對應的可執行檔執行。這表示 .gitlab-ci.yml 中的文字不會因為跑在 Windows 就自動採用 cmd.exe 規則;同一個 copy 字樣在 PowerShell 可能解析為 Copy-Item 別名,而 /Y 是 cmd 的參數,不是 Copy-Item 的合法旗標。
失敗契約也要分層理解。PowerShell cmdlet 的錯誤受 $ErrorActionPreference 與 -ErrorAction 影響;原生程式通常透過程序結束碼表達成敗,需觀察 $LASTEXITCODE。此外,GitLab 的 Runner 產生器可能在它產生的命令邊界加入檢查,但這不代表你放進同一個 .ps1、函式、字串或多行區塊內的所有原生呼叫都會自動 fail-fast。文章的防線因此放在自有腳本內,而不是猜測 Runner 版本會替你補完控制流。
最小診斷資訊應包含 $PSVersionTable.PSEdition、$PSVersionTable.PSVersion、Get-Command copy 的 CommandType,以及實際建置程式的解析路徑。不要輸出完整環境變數;CI 變數可能含受保護設定或內部位址。只印出允許清單中的非敏感工具版本與命令類型,已足以判斷語意。
cmd 與 PowerShell 混用為何產生誤導錯誤
直接答案:命令名稱相同不代表語法相同;PowerShell 會先依 Alias、Function、Cmdlet、Application 等解析規則決定目標,所以 cmd 的 /Y、&& 或內建命令不能直接假設可用。錯誤若未中止,後續步驟會覆蓋真正的第一個故障。
copy 是經典案例。在 cmd.exe 中,copy /Y source destination 由 cmd 內建命令處理;在 Windows PowerShell 中,copy 通常是 Copy-Item 的別名。後者使用 -Force,並不接受 /Y。穩健作法有兩種:使用原生 PowerShell 語法 Copy-Item -LiteralPath $source -Destination $destination -Force,或在必須保留舊批次語法時明確寫成 cmd.exe /d /c copy /Y ...。前者較容易使用結構化錯誤處理;後者則必須立刻檢查 $LASTEXITCODE。
類似問題也會發生在 del、dir、set 與管線行為。別名讓互動操作方便,卻讓 CI 腳本的語言邊界變模糊。建議在排程腳本使用完整 cmdlet 名稱、命名參數與 -LiteralPath,避免萬用字元意外擴張。對外部工具則使用呼叫運算子 &、引號包住可執行檔與引數,並在下一行驗證結束碼。
真正昂貴的問題不是第一個命令失敗,而是它繼續執行。例如 restore 失敗後仍進入 build,最後只看到缺少參考;複製失敗後仍檢查錯誤目錄,最後誤判為產物不存在;清理完成後 publish 失敗卻仍啟動服務,風險更高。診斷時要找 log 中第一個 parser、命令解析或非零結束碼,而不是只讀最後一段紅字。
GitLab 的 script 文件也提醒:多個命令若合併在一個命令字串中,可能只回報最後一個命令的成功或失敗。這與 PowerShell 自身的錯誤模型是兩個不同層次。安全設計應同時做到:YAML 中將重要動作拆成獨立項目,自有 PowerShell 腳本內也對原生工具顯式檢查。只做其中一層,仍可能在另一層失去訊號。
建立可審查的 Fail-Fast PowerShell 模式
直接答案:把 PowerShell cmdlet 錯誤與原生程序結束碼分開處理,將破壞性動作放到所有驗證之後,並讓每個 gate 在失敗時以非零碼離開;如此 CI 狀態才代表真正完成,而不是只代表腳本走到最後。
對 cmdlet,可在腳本開頭設定 $ErrorActionPreference = 'Stop',並對預期可能失敗的區段使用 try/catch。這會把 non-terminating error 提升成可攔截的終止錯誤,但不能把它當作所有原生程式的通用結束碼處理器。對 nuget.exe、msbuild.exe 或其他原生工具,緊接著保存 $LASTEXITCODE;若不是零,就輸出不含秘密的步驟名稱並 exit 該碼。
可重用函式應接收「步驟名稱、程式路徑、引數陣列」,而不是把完整命令拼成字串再 Invoke-Expression。字串求值增加 quoting 與注入風險,也讓審查者難以辨識參數邊界。下列為整合片段,不是完整 fixture:必須先備妥 input 與 candidate 目錄和來源檔。此模式限定 Windows PowerShell 5.1;PowerShell 7 的原生命令偏好設定須另行確認。Write-Error 明確使用 -ErrorAction Continue,避免 Stop 偏好先拋出例外、使後面的 exit $code 無法到達:
$ErrorActionPreference = 'Stop'
function Invoke-NativeChecked {
param(
[Parameter(Mandatory)] [string] $Step,
[Parameter(Mandatory)] [string] $FilePath,
[Parameter()] [string[]] $ArgumentList = @()
)
& $FilePath @ArgumentList
$code = $LASTEXITCODE
if ($code -ne 0) {
Write-Error "Step failed: $Step; exit code: $code" -ErrorAction Continue
exit $code
}
}
Copy-Item -LiteralPath '.\input\artifact.txt' `
-Destination '.\candidate\artifact.txt' -Force
Invoke-NativeChecked -Step 'sandbox verification' `
-FilePath 'cmd.exe' -ArgumentList @('/d', '/c', 'exit', '0')
還要調整步驟順序。先建置到全新的 candidate 目錄,完成檔案存在、雜湊、組態與啟動前檢查,再進入任何服務切換。不要先刪除現有目錄來「確保乾淨」;清理不是驗證,且會把可回復狀態變成停機風險。本文的沙箱刻意不提供 IIS、服務重啟或正式發布命令,因為那些不屬於安全的讀者重現範圍。
YAML、特殊字元與產生腳本邊界
直接答案:.gitlab-ci.yml 先經 YAML 解析,再由 Runner 產生 Shell 腳本,最後才由 PowerShell 解析;任何引號、冒號、反引號或變數插值都可能在不同層被重新解讀,因此應縮短 inline 指令、避免動態程式碼字串,並先用 CI Lint 檢查 YAML。
官方文件指出,含冒號等特殊字元的 script 項目可能需要將整個命令以引號包住,也列出多種需特別留意的符號。這不是格式美觀問題:YAML 若把文字解讀成 mapping,Runner 收到的就不是原本的命令;Runner 若將變數寫入產生腳本,Shell 還會再次解析引號與插值。
可靠策略是把複雜邏輯放進版本控制中的 .ps1,YAML 只保留少量、固定的呼叫與參數。腳本可單獨 lint、在沙箱執行、做 code review,也能明確設定 strictness。不要把 commit message、分支標題或使用者輸入直接拼成可執行 PowerShell;若只需顯示,應當作資料處理並避免輸出敏感內容。
範例 YAML 可採下列形狀,子腳本明確由 Windows PowerShell 5.1 的獨立程序執行。每份子腳本必須設定 cmdlet 的終止錯誤策略、逐一檢查原生命令,成功時 exit 0、失敗時 exit 非零碼;外層只檢查程序碼無法彌補子腳本忽略失敗。直接用 & .\ci\build.ps1 後讀 $LASTEXITCODE 可能讀到前一次值,並非通用成功判斷。每個 gate 是獨立 script item,rules 的用途也比相互矛盾的舊式分支過濾更容易集中檢查:
windows-build:
tags: [windows]
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- '$PSVersionTable.PSVersion.ToString()'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\restore.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\build.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
- '& powershell.exe -NoProfile -NonInteractive -File .\ci\verify-candidate.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
這個形狀仍需用目標 GitLab 版本的 CI Lint 驗證,並在受信任的專用 Runner 上執行。Shell executor 隔離有限,官方文件明確提醒只應讓可信任的專案與使用者使用。把 pipeline 語法修正,不等於解決執行主機權限、跨專案檔案可見性或機密設定治理。
測試與重現:在暫存目錄驗證三個關鍵行為
直接答案:安全重現應證明三件事:PowerShell 如何解析 copy、原生程式非零碼若未檢查會留下繼續執行空間,以及加入 gate 後能以非零碼停止。這些測試只需暫存檔,不需要 GitLab 伺服器或部署權限。
執行情況:讀者可重現步驟,排程環境未執行。 腳本執行核准尚未完成,因此下列修訂 fixture 僅經靜態校閱,不宣稱已實測,也未繞過任何核准。前置條件為 Windows PowerShell 5.1、cmd.exe、可寫入的暫存目錄及已依組織規則取得的腳本執行許可;測試不涵蓋特定 GitLab Runner 版本的產生腳本。請將以下完整程式存為 verify-windows-ci.ps1,從另一個 PowerShell 程序呼叫;不要貼入正在使用的互動工作階段,因為末尾會 exit。
- 在一次性暫存資料夾建立
source.txt,執行Get-Command copy,確認目前 PowerShell 將copy解析為 Alias;再測試 cmd 旗標是否被 PowerShell cmdlet 拒絕。 - 使用
Copy-Item -LiteralPath ... -Force與cmd.exe /d /c copy /Y ...各建立一份副本,對後者立即檢查$LASTEXITCODE;兩種明確語言邊界都應成功。 - 執行
cmd.exe /d /c exit 7,保存$LASTEXITCODE,先在未顯式exit的控制腳本建立 continuation marker,再在 fail-fast 版本中以相同非零碼離開;前者證明風險,後者證明 gate。 - 以
powershell.exe -NoProfile -NonInteractive -File .\verify-windows-ci.ps1執行完整 fixture;任何檔案不存在、預期碼不符或命令解析不符都應使腳本失敗。
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -ne 5 -or
$PSVersionTable.PSVersion.Minor -ne 1) { throw 'Requires Windows PowerShell 5.1' }
$root = Join-Path $env:TEMP ('windows-ci-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $root | Out-Null
$source = Join-Path $root 'source.txt'
$nativeCopy = Join-Path $root 'native-copy.txt'
$cmdCopy = Join-Path $root 'cmd-copy.txt'
Set-Content -LiteralPath $source -Value 'sandbox-only' -Encoding ASCII
$alias = Get-Command copy
if ($alias.CommandType -ne 'Alias' -or $alias.Definition -ne 'Copy-Item') {
throw 'Unexpected copy resolution'
}
$rejected = $false
try { Copy-Item /Y $source $nativeCopy } catch { $rejected = $true }
if (-not $rejected) { throw 'Expected cmd syntax rejection' }
Copy-Item -LiteralPath $source -Destination $nativeCopy -Force
Push-Location -LiteralPath $root
try {
& cmd.exe /d /c copy /Y source.txt cmd-copy.txt | Out-Null
$copyCode = $LASTEXITCODE
} finally { Pop-Location }
if ($copyCode -ne 0) { throw 'cmd copy failed' }
$sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
foreach ($file in @($nativeCopy, $cmdCopy)) {
if (-not (Test-Path -LiteralPath $file)) { throw 'Copy missing' }
if ((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -ne $sourceHash) {
throw 'Copy content mismatch'
}
}
& cmd.exe /d /c exit 7
$captured = $LASTEXITCODE
if ($captured -ne 7) { throw 'Native status mismatch' }
$continued = Join-Path $root 'continued.txt'
Set-Content -LiteralPath $continued -Value 'reached' -Encoding ASCII
if (-not (Test-Path -LiteralPath $continued)) { throw 'Control marker missing' }
$gateFile = Join-Path $root 'gate.ps1'
$gateMarker = Join-Path $root 'post-gate.txt'
@'
param([string] $Marker)
$ErrorActionPreference = 'Stop'
& cmd.exe /d /c exit 7
$code = $LASTEXITCODE
if ($code -ne 0) { exit $code }
Set-Content -LiteralPath $Marker -Value 'must-not-be-reached' -Encoding ASCII
exit 0
'@ | Set-Content -LiteralPath $gateFile -Encoding ASCII
$engine = Join-Path $PSHOME 'powershell.exe'
& $engine -NoProfile -NonInteractive -File $gateFile -Marker $gateMarker
$gateCode = $LASTEXITCODE
if ($gateCode -ne 7) { throw 'Gate did not return 7' }
if (Test-Path -LiteralPath $gateMarker) { throw 'Post-gate marker exists' }
Write-Output 'PASS alias=Copy-Item copies=equal native=7 control=present gate=7 postGate=absent'
exit 0
每次執行使用新的 GUID 暫存目錄,不先刪除或覆寫既有測試資料;測試後保留該目錄供檢視。以下是預期輸出,並非實測紀錄;總 fixture 應回傳 0,受測 gate 子程序應回傳 7。任何 assertion 失敗應讓總 fixture 非零結束;核准阻擋也不是測試通過。
PASS alias=Copy-Item copies=equal native=7 control=present gate=7 postGate=absent
通過條件不能只寫「看起來正常」。第一項應觀察 Alias 與 Copy-Item 對應;第二項應觀察兩個目的檔存在且內容相同;第三項應觀察控制腳本確實能到達 marker,而 gate 腳本的程序結束碼精確為 7;第四項應觀察總測試在所有 assertion 成立時才回傳預定狀態。若在 pwsh 重跑,需另外記錄版本,不能把 Windows PowerShell 5.1 的結果直接套用。
快取、建置與部署的分離決策
直接答案:Shell 正確性是必要條件,但快取命中、工具鏈一致與部署安全是不同 gate。先讓 restore/build 使用同一工具鏈並產生候選目錄,再驗證候選;快取只能加速,不能成為正確性的前置依賴。
NuGet 或其他套件快取若只設定 pull 卻沒有任何流程 push,就不會建立可重用內容。反過來,快取命中也不表示內容完整。管線應能在空快取下正確 restore;命中只影響時間,不改變結果。若偵測到半殘目錄,安全策略是讓套件管理工具在隔離快取重建,而不是直接把未知檔案帶入候選產物。
工具鏈也要對齊。不要讓 restore 由一版工具自動探索,build 卻硬指向另一版。版本應在 job 開頭以允許清單輸出,並在專案或 Runner 映像中固定。Legacy Web Application Project 與 SDK-style 專案具有不同發布需求;不能因為 dotnet publish 對新專案可用,就假設舊式 Web 專案也有相同 targets。
建置與部署必須拆開:build job 產生不可變候選與驗證報告,deploy job 只接受已驗證產物。分支或規則條件應在兩者間一致,並透過 pipeline graph 檢查依賴,而不是分散在互相矛盾的條件。即使本文不執行部署,這個邊界仍能阻止「建置未完成卻進入清理」的控制流。
診斷清單與採用判準
遇到 Windows Runner 失敗時,先從最早的語法與命令解析訊號開始,不要直接重跑。重跑可能讓偶發狀態消失,卻不會修正 Shell 契約。
- 確認 executor 與
config.toml選擇的是powershell或pwsh。 - 在允許清單中記錄 PowerShell edition、版本與關鍵工具版本。
- 以
Get-Command判斷同名命令究竟是 Alias、Cmdlet 或 Application。 - cmd 語法要明確交給
cmd.exe /c;PowerShell 邏輯使用完整 cmdlet 與命名參數。 - PowerShell cmdlet 失敗使用
Stop與try/catch;原生工具失敗檢查$LASTEXITCODE。 - 將 YAML 的重要步驟拆成獨立 script items,複雜邏輯移入可審查
.ps1。 - 先建置及驗證 candidate,再考慮任何服務切換;不要先清空現有目錄。
- 在空快取下驗證 restore/build 正確性,快取只當成加速層。
- 用 CI Lint 檢查 YAML,並在受信任、隔離的 Runner 沙箱驗證。
- log 僅輸出必要診斷,不列出完整環境變數、權杖或內部路徑。
這套做法把「Windows CI 很玄」拆成可測契約:Shell 決定語法,YAML 決定命令邊界,PowerShell 決定錯誤流,原生程式以結束碼回報結果,部署 gate 則決定失敗能否跨越。當每一層都有明確輸入與通過條件,最後一個錯誤就不再掩蓋第一個原因,Pipeline 的綠燈也才真正代表候選產物完成驗證。
官方查核依據
GitLab 的 Shell 選擇、YAML script 邊界與 shell executor 隔離分別支援上述環境、命令分項與安全限制。Microsoft 的 ErrorActionPreference 說明 Stop 與單一命令覆寫行為;LASTEXITCODE 區分直接呼叫 ps1 與 powershell.exe -File 的狀態。