PowerShell 技能连载 - GitOps 工作流自动化

适用于 PowerShell 7.0 及以上版本

GitOps 是一种以 Git 仓库作为”唯一事实来源”的运维方法论,所有基础设施和应用配置都以代码形式存放在 Git 中,通过声明式描述来管理和交付系统。虽然 Kubernetes 生态中的 Argo CD 和 Flux 已经成为 GitOps 的代名词,但在传统的 Windows 环境和混合云场景中,PowerShell 同样可以成为实现 GitOps 工作流的利器。

本文将演示如何用 PowerShell 构建一套轻量级 GitOps 自动化流水线。从 Git 仓库状态监控、配置漂移检测,到多环境发布管道,这些脚本可以直接集成到 Windows 计划任务或 Azure DevOps Pipeline 中,让团队在不引入复杂工具链的前提下享受 GitOps 的好处。

核心思路非常简单:将所有配置文件纳入 Git 管理,用 PowerShell 定期检查仓库状态,对比期望状态与实际状态,发现漂移时自动修复或发出告警,最终实现基础设施即代码(IaC)的闭环管理。

Git 仓库状态检测与自动化提交

第一个场景是最基础也是最关键的环节——监控 Git 仓库的状态变化并自动提交推送。在 GitOps 模式下,任何配置文件的修改都应该尽快同步到远程仓库,确保 Git 始终反映最新的期望状态。

下面的脚本封装了 Git 操作的核心逻辑,支持自定义文件模式匹配、自动暂存、提交(附带变更摘要)和推送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
function Invoke-GitOpsSync {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$RepoPath,

[string[]]$FilePatterns = @('*.json', '*.yaml', '*.yml', '*.ps1'),

[string]$RemoteName = 'origin',
[string]$BranchName = 'main',
[switch]$DryRun
)

Push-Location $RepoPath
try {
# 确认是有效的 Git 仓库
$null = git rev-parse --git-dir 2>$null
if ($LASTEXITCODE -ne 0) {
throw "路径 '$RepoPath' 不是有效的 Git 仓库"
}

# 拉取远程最新变更,避免冲突
git fetch $RemoteName 2>&1 | ForEach-Object { Write-Verbose $_ }
$localHash = (git rev-parse HEAD).Trim()
$remoteHash = (git rev-parse "$RemoteName/$BranchName").Trim()

if ($localHash -ne $remoteHash) {
Write-Host "[拉取] 检测到远程有新提交,正在合并..." -ForegroundColor Cyan
git pull $RemoteName $BranchName 2>&1 | ForEach-Object { Write-Host $_ }
}

# 检查工作区中匹配模式的文件变更
$changedFiles = @()
foreach ($pattern in $FilePatterns) {
$changedFiles += git diff --name-only --relative -- $pattern 2>$null
$changedFiles += git ls-files --others --exclude-standard -- $pattern 2>$null
}
$changedFiles = $changedFiles | Select-Object -Unique | Where-Object { $_ }

if ($changedFiles.Count -eq 0) {
Write-Host "[状态] 工作区无变更,与远程仓库同步。" -ForegroundColor Green
return
}

Write-Host "[变更] 检测到 $($changedFiles.Count) 个文件变更:" -ForegroundColor Yellow
$changedFiles | ForEach-Object { Write-Host " - $_" }

if ($DryRun) {
Write-Host "[试运行] 未执行实际提交。使用 -DryRun:`$false 提交变更。" -ForegroundColor Magenta
return
}

# 暂存、提交、推送
foreach ($pattern in $FilePatterns) {
git add $pattern 2>&1 | ForEach-Object { Write-Verbose $_ }
}

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$summary = "auto-sync: $($changedFiles.Count) file(s) updated at $timestamp"
git commit -m $summary 2>&1 | ForEach-Object { Write-Host $_ }
git push $RemoteName $BranchName 2>&1 | ForEach-Object { Write-Host $_ }

Write-Host "[完成] 变更已提交并推送到 $RemoteName/$BranchName" -ForegroundColor Green
}
finally {
Pop-Location
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
PS> Invoke-GitOpsSync -RepoPath D:\gitops-config -Verbose

[拉取] 检测到远程有新提交,正在合并...
Already up to date.
[变更] 检测到 3 个文件变更:
- config/appsettings.json
- deploy/infra.yaml
- scripts/deploy.ps1
[main c7a2f1d] auto-sync: 3 file(s) updated at 2026-04-02 09:15:33
3 files changed, 27 insertions(+), 5 deletions(-)
[完成] 变更已提交并推送到 origin/main

配置漂移检测与自动修复

GitOps 的核心理念是”期望状态”与”实际状态”的一致性。在生产环境中,人为修改、脚本错误或系统更新都可能导致配置漂移。下面的脚本会对比 Git 仓库中的声明式配置与实际运行环境的差异,发现漂移后自动拉取最新配置并同步。

这个方案特别适合管理 IIS 站点配置、Windows 服务参数、环境变量等场景,通过将期望配置存储在 JSON 文件中,PowerShell 负责定期对比并纠正偏差:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
function Test-ConfigDrift {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$DesiredStatePath,

[Parameter(Mandatory)]
[string]$EnvironmentName,

[switch]$AutoRemediate,
[switch]$ReportOnly
)

# 加载期望状态配置
$desiredConfig = Get-Content -Path $DesiredStatePath -Raw | ConvertFrom-Json
$driftReport = [System.Collections.Generic.List[PSObject]]::new()
$remediationLog = [System.Collections.Generic.List[string]]::new()

foreach ($service in $desiredConfig.services) {
$actual = Get-Service -Name $service.name -ErrorAction SilentlyContinue

if (-not $actual) {
$drift = [PSCustomObject]@{
Resource = $service.name
Type = 'Service'
Expected = $service.status
Actual = 'NotFound'
Drift = $true
Severity = 'Critical'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}
$driftReport.Add($drift)
$remediationLog.Add("[严重] 服务 '$($service.name)' 不存在于 $EnvironmentName 环境")
continue
}

$expectedStatus = $service.status
$actualStatus = $actual.Status.ToString()

if ($expectedStatus -ne $actualStatus) {
$drift = [PSCustomObject]@{
Resource = $service.name
Type = 'Service'
Expected = $expectedStatus
Actual = $actualStatus
Drift = $true
Severity = 'Warning'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
}
$driftReport.Add($drift)
$remediationLog.Add("[漂移] 服务 '$($service.name)' 期望 $expectedStatus,实际 $actualStatus")

if ($AutoRemediate -and -not $ReportOnly) {
try {
Set-Service -Name $service.name -Status $expectedStatus -ErrorAction Stop
$remediationLog.Add("[修复] 已将服务 '$($service.name)' 状态恢复为 $expectedStatus")
}
catch {
$remediationLog.Add("[失败] 修复服务 '$($service.name)' 失败: $($_.Exception.Message)")
}
}
}
}

# 检查文件路径的漂移
foreach ($file in $desiredConfig.files) {
$fileExists = Test-Path -Path $file.path
if ($file.shouldExist -and -not $fileExists) {
$driftReport.Add([PSCustomObject]@{
Resource = $file.path
Type = 'File'
Expected = 'Exists'
Actual = 'Missing'
Drift = $true
Severity = 'Warning'
Environment = $EnvironmentName
Timestamp = Get-Date -Format 'o'
})
$remediationLog.Add("[漂移] 文件 '$($file.path)' 期望存在但未找到")

if ($AutoRemediate -and $file.sourceUrl -and -not $ReportOnly) {
try {
Invoke-WebRequest -Uri $file.sourceUrl -OutFile $file.path -ErrorAction Stop
$remediationLog.Add("[修复] 已从 $($file.sourceUrl) 下载到 $($file.path)")
}
catch {
$remediationLog.Add("[失败] 下载文件失败: $($_.Exception.Message)")
}
}
}
}

# 输出报告
if ($driftReport.Count -eq 0) {
Write-Host "[通过] $EnvironmentName 环境配置与期望状态一致,未检测到漂移。" -ForegroundColor Green
}
else {
Write-Host "[漂移] $EnvironmentName 环境检测到 $($driftReport.Count) 项配置漂移:" -ForegroundColor Yellow
$driftReport | Format-Table -Property Resource, Type, Expected, Actual, Severity -AutoSize
}

if ($remediationLog.Count -gt 0) {
Write-Host "`n--- 修复日志 ---" -ForegroundColor Cyan
$remediationLog | ForEach-Object { Write-Host " $_" }
}

return $driftReport
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PS> Test-ConfigDrift -DesiredStatePath D:\gitops-config\desired-state.json -EnvironmentName staging -AutoRemediate

[漂移] staging 环境检测到 2 项配置漂移:

Resource Type Expected Actual Severity
-------- ---- -------- ------ --------
W3SVC Service Running Stopped Warning
D:\app\connection.json File Exists Missing Warning

--- 修复日志 ---
[漂移] 服务 'W3SVC' 期望 Running,实际 Stopped
[修复] 已将服务 'W3SVC' 状态恢复为 Running
[漂移] 文件 'D:\app\connection.json' 期望存在但未找到
[修复] 已从 https://config-server.local/staging/connection.json 下载到 D:\app\connection.json

多环境发布管道

在完整的 GitOps 工作流中,配置需要经过 dev、staging、prod 三个环境的逐步验证和发布。每个环境有自己的变量替换规则和审批策略。下面的脚本实现了一个轻量级的多环境发布管道,通过模板渲染将通用配置转化为环境特定的部署清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
function Invoke-GitOpsPipeline {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ConfigRepoPath,

[ValidateSet('dev', 'staging', 'prod')]
[string]$TargetEnvironment = 'dev',

[string]$TemplatesDir = 'templates',
[string]$OutputDir = 'rendered',
[switch]$SkipValidation,
[switch]$WhatIf
)

$envConfigPath = Join-Path $ConfigRepoPath "environments/$TargetEnvironment.json"
if (-not (Test-Path $envConfigPath)) {
throw "找不到环境配置文件: $envConfigPath"
}

# 加载环境变量映射
$envVars = Get-Content -Path $envConfigPath -Raw | ConvertFrom-Json
Write-Host "`n========== GitOps 发布管道 ==========" -ForegroundColor Cyan
Write-Host " 环境: $TargetEnvironment"
Write-Host " 仓库: $ConfigRepoPath"
Write-Host " 时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "======================================`n"

$templatesPath = Join-Path $ConfigRepoPath $TemplatesDir
$renderedPath = Join-Path $ConfigRepoPath "$OutputDir/$TargetEnvironment"
$null = New-Item -ItemType Directory -Path $renderedPath -Force

# 渲染所有模板文件
$templateFiles = Get-ChildItem -Path $templatesPath -Filter '*.tpl' -Recurse
$renderResults = @()

foreach ($tpl in $templateFiles) {
$content = Get-Content -Path $tpl.FullName -Raw
$relativePath = $tpl.FullName.Substring($templatesPath.Length).TrimStart('\', '/')

# 使用环境变量替换模板占位符 {{VAR_NAME}}
$envVars.PSObject.Properties | ForEach-Object {
$placeholder = "{{$($_.Name)}}"
$content = $content -replace [regex]::Escape($placeholder), $_.Value
}

$outputFile = Join-Path $renderedPath ($tpl.BaseName -replace '\.tpl$', '.yaml')
$relativeOutput = "$OutputDir/$TargetEnvironment/" + ($tpl.BaseName -replace '\.tpl$', '.yaml')

if ($WhatIf) {
Write-Host "[预览] $relativeOutput (未写入)" -ForegroundColor Magenta
$renderResults += [PSCustomObject]@{ File = $relativeOutput; Status = 'Preview' }
}
else {
$content | Set-Content -Path $outputFile -NoNewline
Write-Host "[渲染] $relativeOutput" -ForegroundColor Green
$renderResults += [PSCustomObject]@{ File = $relativeOutput; Status = 'Rendered' }
}
}

# 验证渲染结果
if (-not $SkipValidation -and -not $WhatIf) {
Write-Host "`n--- 验证渲染结果 ---" -ForegroundColor Cyan
$yamlFiles = Get-ChildItem -Path $renderedPath -Filter '*.yaml'

foreach ($yaml in $yamlFiles) {
$yamlContent = Get-Content -Path $yaml.FullName -Raw
$unresolved = [regex]::Matches($yamlContent, '\{\{[A-Z_]+\}\}')

if ($unresolved.Count -gt 0) {
$varNames = $unresolved | ForEach-Object { $_.Value } | Select-Object -Unique
Write-Host "[警告] $($yaml.Name) 包含未替换的变量: $($varNames -join ', ')" -ForegroundColor Yellow
}
else {
Write-Host "[通过] $($yaml.Name) 所有变量已正确替换" -ForegroundColor Green
}
}
}

# prod 环境的额外确认
if ($TargetEnvironment -eq 'prod' -and -not $WhatIf) {
Write-Host "`n[生产] 即将发布到生产环境!" -ForegroundColor Red
$confirm = Read-Host "请输入 'CONFIRM' 确认发布到生产环境"
if ($confirm -ne 'CONFIRM') {
Write-Host "[取消] 发布已中止。" -ForegroundColor Yellow
return
}
}

Write-Host "`n========== 发布完成 ==========" -ForegroundColor Cyan
$renderResults | Format-Table -AutoSize

return $renderResults
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
PS> Invoke-GitOpsPipeline -ConfigRepoPath D:\gitops-config -TargetEnvironment staging

========== GitOps 发布管道 ==========
环境: staging
仓库: D:\gitops-config
时间: 2026-04-02 10:30:00
======================================

[渲染] rendered/staging/app-deploy.yaml
[渲染] rendered/staging/infra-config.yaml
[渲染] rendered/staging/monitoring.yaml

--- 验证渲染结果 ---
[通过] app-deploy.yaml 所有变量已正确替换
[通过] infra-config.yaml 所有变量已正确替换
[通过] monitoring.yaml 所有变量已正确替换

========== 发布完成 ==========

File Status
---- ------
rendered/staging/app-deploy.yaml Rendered
rendered/staging/infra-config.yaml Rendered
rendered/staging/monitoring.yaml Rendered

注意事项

  1. Git 凭据管理:自动化推送依赖 Git 认证,推荐使用 SSH 密钥或 Windows Credential Manager 存储凭据,避免在脚本中硬编码密码。在 CI/CD 管道中可使用 PAT(Personal Access Token)配合环境变量注入。

  2. 并发冲突处理:多人同时修改同一仓库时可能出现合并冲突。建议配置文件按服务或模块拆分到不同子目录,减少冲突概率。脚本中已包含 git pull 逻辑,遇到冲突时应中断流程并通知人工介入。

  3. 漂移检测的定时执行:将 Test-ConfigDrift 注册为 Windows 计划任务或放入 Azure Functions 定时触发器,建议生产环境每 5 分钟检测一次,staging 环境每 15 分钟检测一次。可通过 Register-ScheduledTask 命令快速配置。

  4. 模板占位符规范:多环境管道中使用 {{VAR_NAME}} 格式的占位符,变量名统一使用大写字母和下划线。确保每个环境的 JSON 配置文件包含所有模板中引用的变量,否则渲染结果会残留未替换的占位符。

  5. 生产环境保护:发布到 prod 环境时,脚本内置了交互式确认步骤。在无人值守的 CI/CD 管道中,应通过外部审批门禁(如 Azure DevOps 的 Environment approvals)替代交互式确认,同时启用 Slack 或 Teams 通知。

  6. 日志与审计:所有 GitOps 操作都应输出结构化日志,建议通过 ConvertTo-Json 将漂移报告和发布记录序列化后写入中央日志系统。这样可以在 Grafana 或 Kibana 中建立配置漂移的仪表板,追踪漂移频率和修复耗时。

PowerShell 技能连载 - Azure DevOps 自动化

适用于 PowerShell 7.0 及以上版本

在现代软件交付体系中,Azure DevOps 已经成为许多团队的核心协作平台。它集成了代码仓库、CI/CD 流水线、工作项追踪和制品管理等功能,为端到端的 DevOps 实践提供了完整支撑。然而随着团队规模扩大和项目数量增多,仅依靠 Web 界面进行日常管理变得低效——批量创建项目、统一配置流水线、定期生成进度报告等场景迫切需要自动化手段。

PowerShell 凭借对 REST API 的原生支持和强大的对象处理能力,是自动化管理 Azure DevOps 的理想工具。通过脚本调用 Azure DevOps REST API,我们可以将重复性的管理操作编排成可重复执行的工作流,例如一键同步多个项目的仓库配置、自动触发全量回归测试流水线、定时生成冲刺健康度报告。

本文将从实际运维场景出发,介绍如何使用 PowerShell 实现 Azure DevOps 的三大类自动化操作:项目与仓库的批量管理、CI/CD 流水线的触发与监控、工作项的查询与报告生成。每个场景都提供可直接运行的完整脚本和执行结果演示。

Azure DevOps API 连接与项目管理

所有 Azure DevOps 自动化的基础是与 REST API 建立可靠的连接。以下代码封装了一个通用的 API 调用函数,并在此基础上实现项目的批量查询和仓库操作。我们将 Personal Access Token(PAT)存储在环境变量中,通过 Base64 编码构造认证头,确保凭据不会以明文形式出现在脚本中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
function Invoke-AzDoRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Organization,

[string]$Project,

[Parameter(Mandatory)]
[string]$ApiPath,

[Parameter(Mandatory)]
[securestring]$PatToken,

[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
[string]$Method = 'GET',

[object]$Body,

[string]$ApiVersion = '7.1'
)

# 将 PAT 转换为 Basic Auth 格式
$plainPat = (New-Object PSCredential('user', $PatToken)).GetNetworkCredential().Password
$base64Auth = [Convert]::ToBase64String(
[System.Text.Encoding]::ASCII.GetBytes(":$plainPat")
)

$headers = @{
Authorization = "Basic $base64Auth"
'Content-Type' = 'application/json'
}

# 构建请求 URI
$uri = if ($Project) {
"https://dev.azure.com/$Organization/$Project/_apis$ApiPath"
} else {
"https://dev.azure.com/$Organization/_apis$ApiPath"
}

# 追加 api-version 参数
$separator = if ($uri -match '\?') { '&' } else { '?' }
$uri = "$uri$separator`api-version=$ApiVersion"

$params = @{
Uri = $uri
Headers = $headers
Method = $Method
}

if ($Body) {
$params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
}

try {
Invoke-RestMethod @params
} catch {
$statusCode = $_.Exception.Response.StatusCode.value__
$errorReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream())
$errorBody = $errorReader.ReadToEnd()
Write-Error "API 请求失败 [HTTP $statusCode]: $errorBody"
}
}

# 从环境变量获取 PAT
$pat = ConvertTo-SecureString $env:AZDO_PAT -AsPlainText -Force

# 查询组织下所有项目
$allProjects = Invoke-AzDoRequest `
-Organization 'mycompany' `
-ApiPath '/projects' `
-PatToken $pat

Write-Host "组织内的项目列表:"
Write-Host ("-" * 60)

foreach ($proj in $allProjects.value) {
$lastUpdate = [datetime]::Parse($proj.lastUpdateTime).ToString('yyyy-MM-dd')
Write-Host " 名称: $($proj.name)"
Write-Host " 状态: $($proj.state) | 上次更新: $lastUpdate"
Write-Host " ID: $($proj.id)"
Write-Host ""
}

# 查询指定项目的所有仓库
$repos = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/git/repositories' `
-PatToken $pat

Write-Host "PlatformService 项目的仓库:"
foreach ($repo in $repos.value) {
$branch = $repo.defaultBranch -replace 'refs/heads/', ''
Write-Host " [$($repo.name)] 默认分支: $branch, 大小: $([math]::Round($repo.size / 1MB, 1)) MB"
}

上述代码定义了 Invoke-AzDoRequest 函数,支持组织级和项目级的 API 调用。函数自动根据 URI 是否已包含查询参数来拼接 api-version,避免重复的 ? 符号。错误处理部分捕获 HTTP 异常并解析响应体,便于排查 API 调用失败的原因。通过该函数可以方便地查询项目列表和仓库信息,为后续的批量管理操作奠定基础。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
组织内的项目列表:
------------------------------------------------------------
名称: PlatformService
状态: wellFormed | 上次更新: 2026-01-28
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890

名称: DataPipeline
状态: wellFormed | 上次更新: 2026-01-15
ID: b2c3d4e5-f6a7-8901-bcde-f12345678901

名称: MobileApp
状态: wellFormed | 上次更新: 2025-12-20
ID: c3d4e5f6-a7b8-9012-cdef-123456789012

PlatformService 项目的仓库:
[PlatformService.Api] 默认分支: main, 大小: 24.3 MB
[PlatformService.Web] 默认分支: main, 大小: 18.7 MB
[PlatformService.Infra] 默认分支: develop, 大小: 3.2 MB

流水线管理:触发构建与下载制品

CI/CD 流水线是 Azure DevOps 中最核心的自动化能力之一。在日常运维中,我们经常需要通过脚本触发流水线(例如数据迁移完成后触发部署)、查询构建状态(集成到监控看板),以及下载构建制品(用于自动化测试或灰度发布)。以下代码演示了这三个常见操作的完整实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# --- 1. 触发流水线构建 ---

$runPayload = @{
resources = @{
repositories = @{
self = @{
refName = 'refs/heads/main'
}
}
}
templateParameters = @{
environment = 'staging'
enableSmokeTest = 'true'
tagVersion = '2.4.1-rc.3'
}
}

$newRun = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/pipelines/42/runs' `
-Method POST `
-PatToken $pat `
-Body $runPayload

Write-Host "已触发流水线运行:"
Write-Host " 运行 ID: $($newRun.id)"
Write-Host " 流水线: $($newRun.pipeline.name)"
Write-Host " 状态: $($newRun.state)"
Write-Host ""

# --- 2. 轮询构建状态直到完成 ---

$runId = $newRun.id
$maxWaitMinutes = 30
$startTime = Get-Date

while (((Get-Date) - $startTime).TotalMinutes -lt $maxWaitMinutes) {
Start-Sleep -Seconds 20

$runStatus = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/pipelines/42/runs/$runId" `
-PatToken $pat

$elapsed = ((Get-Date) - $startTime).ToString('mm\:ss')
Write-Host "[$elapsed] 运行 #$runId 状态: $($runStatus.state)"

if ($runStatus.state -in 'completed', 'cancelling', 'cancelled') {
Write-Host ""
Write-Host "流水线运行结束:"
Write-Host " 最终结果: $($runStatus.result)"
Write-Host " 完成时间: $($runStatus.finishedAt)"
break
}
}

# --- 3. 下载构建制品 ---

if ($runStatus.result -eq 'succeeded') {
$artifacts = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/build/builds/$($newRun.id)/artifacts" `
-PatToken $pat

$downloadDir = Join-Path $HOME "Downloads/AzDo-Artifacts/$($newRun.id)"
New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null

foreach ($artifact in $artifacts.value) {
$downloadUrl = $artifact.resource.downloadUrl
$fileName = "$($artifact.name).zip"
$savePath = Join-Path $downloadDir $fileName

Write-Host "正在下载制品: $($artifact.name) -> $savePath"
Invoke-WebRequest -Uri $downloadUrl -Headers @{
Authorization = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$plainPat")))"
} -OutFile $savePath
}

Write-Host ""
Write-Host "所有制品已下载至: $downloadDir"
Get-ChildItem $downloadDir | ForEach-Object {
Write-Host " $($_.Name) ($([math]::Round($_.Length / 1KB, 1)) KB)"
}
}

这段脚本按照实际运维流程编排了三个步骤:首先触发流水线并传入模板参数(目标环境、是否执行冒烟测试、版本标签),然后以 20 秒间隔轮询运行状态直到完成,最后仅在构建成功时下载所有制品到本地。轮询部分使用时间差而非固定次数,避免长时间运行的流水线被提前终止。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
已触发流水线运行:
运行 ID: 1847
流水线: PlatformService-CI
状态: inProgress

[00:20] 运行 #1847 状态: inProgress
[00:40] 运行 #1847 状态: inProgress
[01:00] 运行 #1847 状态: inProgress
[01:20] 运行 #1847 状态: inProgress
[01:40] 运行 #1847 状态: completed

流水线运行结束:
最终结果: succeeded
完成时间: 2026-02-03T08:23:45.123Z
正在下载制品: drop -> /Users/wubo/Downloads/AzDo-Artifacts/1847/drop.zip
正在下载制品: testResults -> /Users/wubo/Downloads/AzDo-Artifacts/1847/testResults.zip

所有制品已下载至: /Users/wubo/Downloads/AzDo-Artifacts/1847
drop.zip (12456.3 KB)
testResults.zip (3287.5 KB)

工作项查询与冲刺报告生成

Azure DevOps Boards 的工作项数据是团队进度和质量的直接反映。定期从 Boards 中提取数据并生成报告,可以帮助团队及时发现阻塞、评估交付速率、辅助 Sprint 回顾。以下代码使用 WIQL(Work Item Query Language)查询当前冲刺的工作项,并生成一份结构化的文本报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# --- 1. 查询当前冲刺的活跃工作项 ---

$wiqlQuery = @{
query = @"
SELECT [System.Id], [System.WorkItemType], [System.Title],
[System.State], [System.AssignedTo],
[Microsoft.VSTS.Scheduling.StoryPoints],
[Microsoft.VSTS.Common.Priority]
FROM WorkItems
WHERE [System.IterationPath] = @currentIteration()
AND [System.State] NOT IN ('Closed', 'Removed')
AND [System.WorkItemType] IN ('User Story', 'Bug', 'Task')
ORDER BY [Microsoft.VSTS.Common.Priority] ASC
"@
}

$queryResult = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath '/wit/wiql' `
-Method POST `
-PatToken $pat `
-Body $wiqlQuery

Write-Host "查询到 $($queryResult.workItems.Count) 个活跃工作项"
Write-Host ""

# --- 2. 批量获取工作项详情 ---

$allIds = $queryResult.workItems | Select-Object -ExpandProperty id
$workItems = @()

# API 限制单次最多查询 200 个工作项
$idBatches = $allIds | Group-Object -Property { [math]::Floor([array]::IndexOf($allIds, $_) / 200) }

foreach ($batch in $idBatches) {
$idList = ($batch.Group | ForEach-Object { [int]$_ }) -join ','
$details = Invoke-AzDoRequest `
-Organization 'mycompany' `
-Project 'PlatformService' `
-ApiPath "/wit/workitems?ids=$idList&`$expand=fields" `
-PatToken $pat
$workItems += $details.value
}

# --- 3. 生成冲刺报告 ---

Write-Host "=" * 70
Write-Host " PlatformService - Sprint 进度报告"
Write-Host " 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "=" * 70
Write-Host ""

# 按类型分组统计
$byType = $workItems | Group-Object { $_.fields.'System.WorkItemType' }

Write-Host "[按类型统计]"
foreach ($group in $byType) {
$totalPoints = ($group.Group | ForEach-Object {
$_.fields.'Microsoft.VSTS.Scheduling.StoryPoints'
} | Where-Object { $_ } | Measure-Object -Sum).Sum

$pointsStr = if ($totalPoints) { " | 故事点: $totalPoints" } else { '' }
Write-Host " $($group.Name): $($group.Count) 个$pointsStr"
}
Write-Host ""

# 按状态分组统计
$byState = $workItems | Group-Object { $_.fields.'System.State' }

Write-Host "[按状态统计]"
foreach ($group in $byState) {
$pct = [math]::Round($group.Count / $workItems.Count * 100, 1)
$bar = '#' * [math]::Floor($pct / 5)
Write-Host " $($group.Name): $($group.Count) 个 ($pct%) $bar"
}
Write-Host ""

# 按负责人分组统计
$byAssignee = $workItems | Group-Object {
$assigned = $_.fields.'System.AssignedTo'
if ($assigned) { $assigned.displayName } else { '未分配' }
} | Sort-Object Count -Descending

Write-Host "[按负责人统计]"
foreach ($group in $byAssignee) {
Write-Host " $($group.Name): $($group.Count) 个"
foreach ($item in $group.Group) {
$title = $_.fields.'System.Title'
Write-Host " - #$($item.id) $($item.fields.'System.Title')"
}
}
Write-Host ""

# 阻塞项警告
$blockedItems = $workItems | Where-Object {
$_.fields.'System.State' -eq 'Blocked' -or
$_.fields.'System.Tags' -match 'blocked'
}

if ($blockedItems) {
Write-Host "[警告] 存在 $($blockedItems.Count) 个阻塞项:"
foreach ($item in $blockedItems) {
Write-Host " !! #$($item.id) $($item.fields.'System.Title')"
}
}

Write-Host ""
Write-Host "=" * 70
Write-Host " 报告结束"
Write-Host "=" * 70

这段代码使用 WIQL 查询当前冲刺中所有活跃的工作项,然后通过批量接口获取详细字段。报告生成部分按工作项类型、状态、负责人三个维度进行分组统计,并高亮显示被阻塞的工作项。这种脚本非常适合在 Sprint Standup 会议前自动运行,或通过 CI/CD 定时任务发送到团队频道。

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
查询到 24 个活跃工作项

======================================================================
PlatformService - Sprint 进度报告
生成时间: 2026-02-03 08:30:15
======================================================================

[按类型统计]
User Story: 8 个 | 故事点: 21
Bug: 6
Task: 10

[按状态统计]
Active: 12 个 (50%) ##############
New: 7 个 (29.2%) ######
Resolved: 3 个 (12.5%) ###
Blocked: 2 个 (8.3%) ##

[按负责人统计]
张三: 8
- #1856 用户注册接口性能优化
- #1861 修复订单列表分页异常
李四: 7
- #1858 实现批量导出功能
- #1863 消息推送服务重构
王五: 5
- #1859 日志采集模块升级
未分配: 4
- #1865 接入新版支付网关

[警告] 存在 2 个阻塞项:
!! #1860 第三方认证服务证书过期
!! #1867 等待上游团队提供接口文档

======================================================================
报告结束
======================================================================

注意事项

  • PAT 权限范围:创建 PAT 时需根据脚本操作范围选择最小权限集。管理项目需要 Project (Read & Write),操作流水线需要 Build (Read & Execute),查询工作项需要 Work Items (Read)。避免使用全权限 PAT,降低凭据泄露后的影响范围。
  • API 版本兼容性:示例中使用 api-version=7.1。Azure DevOps REST API 有 v5.x、v6.x、v7.x 多个主版本,部分接口在不同版本间行为有差异(如流水线 Runs API 在 7.0 后引入了 templateParameters 字段)。生产脚本务必锁定版本号并做兼容性测试。
  • 分页与批量限制:查询类接口(如项目列表、工作项查询)默认有分页限制,通常单次返回 100-1000 条。需要检查响应中的 continuationToken 字段并循环获取完整数据。工作项批量查询单次上限为 200 个 ID,大批量场景需要自行分批。
  • 并发与速率控制:Azure DevOps 对同一组织的 API 调用有速率限制(个人用户约每分钟 600 次)。批量操作时建议使用 Start-Sleep 添加间隔,或使用 PowerShell 的 ForEach-Object -Parallel 配合计数器实现受控并发。
  • 错误重试策略:网络抖动和临时限流会导致偶发的 5xx 或 429 响应。建议封装通用的重试逻辑,对 429 状态码读取 Retry-After 响应头确定等待时间,对 5xx 状态码采用指数退避策略,最多重试 3 次。
  • 敏感信息脱敏:冲刺报告可能包含员工姓名、工作项内容等信息。如果报告需要发送到外部渠道(如邮件、Slack),注意对 System.AssignedTo 等字段做脱敏处理,或将报告输出到 Azure DevOps 内部的 Wiki 页面,保持访问权限的一致性。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本

现代 DevOps 实践中,基础设施即代码(IaC)和自动化测试已经成为标准流程,而 CI/CD 流水线正是将这些实践落地的核心工具。无论是代码提交触发的自动测试,还是合并后自动部署到生产环境,流水线都在其中扮演着承上启下的角色。PowerShell 凭借其强大的系统管理能力和丰富的模块生态,成为了各大 CI/CD 平台中编写构建、测试和部署逻辑的理想选择。

主流 CI/CD 平台(如 GitHub Actions、Azure DevOps、Jenkins)都原生支持运行 PowerShell 脚本。这意味着团队可以用同一门语言编写本地运维脚本和流水线逻辑,减少技术栈切换带来的认知负担。同时,Pester 测试框架可以与流水线深度集成,实现代码质量门控——只有当所有测试用例通过时才允许部署继续推进。

本文将从 GitHub Actions 集成、Azure DevOps 流水线配置和通用流水线工具三个角度,展示如何用 PowerShell 构建健壮的 CI/CD 流水线。

GitHub Actions 中的 PowerShell

GitHub Actions 是目前最流行的 CI/CD 平台之一,它在 Windows、Linux 和 macOS runner 上都原生支持 PowerShell(pwsh)。以下代码展示了如何编写一个完整的 GitHub Actions workflow,包含 Pester 测试、代码质量检查和构建产物发布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# 文件路径: .github/workflows/ci.yml
# 以下为 workflow 配置的 PowerShell 等价描述
# 实际 workflow 文件为 YAML 格式

# === 本地模拟 CI 流水线的 PowerShell 脚本 ===

# 1. 安装测试依赖
Write-Host '=== 步骤 1: 安装依赖 ===' -ForegroundColor Cyan
Install-Module -Name Pester -MinimumVersion 5.5 -Force -Scope CurrentUser
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser

# 2. 运行 PSScriptAnalyzer 代码质量检查
Write-Host '`n=== 步骤 2: 代码质量分析 ===' -ForegroundColor Cyan
$analysisResults = Invoke-ScriptAnalyzer -Path '.\Scripts' -Severity Warning,Error -Recurse
if ($analysisResults) {
$analysisResults | Format-Table -Property RuleName, Severity, ScriptName, Line, Message -Wrap
$errorCount = ($analysisResults | Where-Object Severity -eq 'Error').Count
if ($errorCount -gt 0) {
Write-Host "发现 $errorCount 个错误,流水线终止" -ForegroundColor Red
exit 1
}
} else {
Write-Host '代码质量检查通过,无警告或错误' -ForegroundColor Green
}

# 3. 运行 Pester 测试
Write-Host '`n=== 步骤 3: 运行 Pester 测试 ===' -ForegroundColor Cyan
$pesterConfig = @{
Run = @{
Path = '.\Tests'
PassThru = $true
}
TestResult = @{
Enabled = $true
OutputPath = 'TestResults.xml'
OutputFormat = 'JUnitXml'
}
CodeCoverage = @{
Enabled = $true
Path = '.\Scripts\*.ps1'
OutputPath = 'Coverage.xml'
}
}
$result = Invoke-Pester -Configuration $pesterConfig

# 4. 输出测试摘要
Write-Host "`n测试结果摘要:" -ForegroundColor Yellow
Write-Host " 总计: $($result.TotalCount) 个测试"
Write-Host " 通过: $($result.PassedCount)" -ForegroundColor Green
Write-Host " 失败: $($result.FailedCount)" -ForegroundColor Red
Write-Host " 跳过: $($result.SkippedCount)" -ForegroundColor Gray
Write-Host " 耗时: $($result.Duration.TotalSeconds) 秒"

if ($result.FailedCount -gt 0) {
Write-Host '`n测试未全部通过,流水线终止' -ForegroundColor Red
exit 1
}

# 5. 打包构建产物
Write-Host '`n=== 步骤 4: 打包构建产物 ===' -ForegroundColor Cyan
$artifactName = "ops-scripts-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
$artifactPath = "dist\$artifactName"
New-Item -Path $artifactPath -ItemType Directory -Force | Out-Null
Copy-Item -Path '.\Scripts\*' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Modules' -Destination $artifactPath -Recurse
Copy-Item -Path '.\Config\env.template.ps1' -Destination $artifactPath
Compress-Archive -Path $artifactPath -DestinationPath "dist\$artifactName.zip"
Write-Host "构建产物已打包: dist\$artifactName.zip" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
=== 步骤 1: 安装依赖 ===
正在安装 Pester 5.5.x...
正在安装 PSScriptAnalyzer...

=== 步骤 2: 代码质量分析 ===
代码质量检查通过,无警告或错误

=== 步骤 3: 运行 Pester 测试 ===
[+] D:\OpsScripts\Tests\Get-DiskHealth.Tests.ps1 120ms (3 tests)
[+] D:\OpsScripts\Tests\Deploy-App.Tests.ps1 85ms (5 tests)
[+] D:\OpsScripts\Tests\Export-Report.Tests.ps1 42ms (2 tests)

测试结果摘要:
总计: 10 个测试
通过: 10
失败: 0
跳过: 0
耗时: 0.35 秒

=== 步骤 4: 打包构建产物 ===
构建产物已打包: dist\ops-scripts-20260115-083000.zip

Azure DevOps 流水线

Azure DevOps 提供了经典的构建/发布管道和 YAML 管道两种模式,两者都深度集成 PowerShell 执行环境。以下代码展示了如何在 Azure DevOps 中实现多阶段流水线,包括构建验证、 staging 环境部署、审批门控和生产环境发布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# Azure DevOps Pipeline Agent 中运行的部署脚本
# 文件路径: scripts/Deploy-Application.ps1

param(
[Parameter(Mandatory)]
[ValidateSet('Build', 'Staging', 'Production')]
[string]$Environment,

[string]$ArtifactPath,

[string]$TargetServer
)

# 1. 根据环境加载对应配置
$envConfig = @{
Build = @{
Servers = @('localhost')
Validate = $false
Backup = $false
Notify = $false
}
Staging = @{
Servers = @('staging-web-01', 'staging-web-02')
Validate = $true
Backup = $true
Notify = $false
}
Production = @{
Servers = @('prod-web-01', 'prod-web-02', 'prod-web-03')
Validate = $true
Backup = $true
Notify = $true
}
}
$config = $envConfig[$Environment]

Write-Host "部署环境: $Environment"
Write-Host "目标服务器: $($config.Servers -join ', ')"

# 2. 部署前健康检查
if ($config.Validate) {
Write-Host '`n执行部署前健康检查...' -ForegroundColor Cyan
foreach ($server in $config.Servers) {
$session = New-PSSession -ComputerName $server -ErrorAction Stop
$result = Invoke-Command -Session $session -ScriptBlock {
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
[PSCustomObject]@{
Server = $env:COMPUTERNAME
FreeDiskGB = [math]::Round($disk.FreeSpace / 1GB, 1)
CpuUsage = if ($cpu) { $cpu } else { 0 }
ServicesUp = (Get-Service -Name 'W3SVC','WinRM' |
Where-Object Status -eq 'Running').Count
}
}
$result | Format-Table -AutoSize
if ($result.FreeDiskGB -lt 5) {
throw "服务器 $($result.Server) 磁盘空间不足 ($($result.FreeDiskGB) GB)"
}
Remove-PSSession $session
}
Write-Host '健康检查通过' -ForegroundColor Green
}

# 3. 备份当前版本
if ($config.Backup) {
Write-Host "`n备份当前版本..." -ForegroundColor Cyan
$backupTag = "pre-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($tag)
$appPath = 'D:\WebApp'
$backupPath = "D:\Backups\$tag"
Copy-Item -Path $appPath -Destination $backupPath -Recurse -Force
Write-Host " $env:COMPUTERNAME: 已备份至 $backupPath"
} -ArgumentList $backupTag
}
Write-Host '备份完成' -ForegroundColor Green
}

# 4. 执行部署
Write-Host "`n开始部署..." -ForegroundColor Cyan
foreach ($server in $config.Servers) {
Invoke-Command -ComputerName $server -ScriptBlock {
param($src)
$appPath = 'D:\WebApp'
# 停止应用
Stop-WebSite -Name 'Default Web Site' -ErrorAction SilentlyContinue
# 复制文件
Copy-Item -Path "$src\*" -Destination $appPath -Recurse -Force
# 启动应用
Start-WebSite -Name 'Default Web Site'
Write-Host " $env:COMPUTERNAME: 部署完成,站点已启动"
} -ArgumentList $ArtifactPath
}

# 5. 部署后验证与通知
if ($config.Notify) {
Write-Host "`n发送部署通知..." -ForegroundColor Cyan
$notifyBody = @{
text = "[部署完成] $Environment 环境已于 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') 完成部署"
} | ConvertTo-Json -Compress
Invoke-RestMethod -Uri $env:WEBHOOK_URL -Method Post `
-Body $notifyBody -ContentType 'application/json'
Write-Host '通知已发送' -ForegroundColor Green
}

Write-Host "`n部署流程全部完成" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
部署环境: Staging
目标服务器: staging-web-01, staging-web-02

执行部署前健康检查...
Server FreeDiskGB CpuUsage ServicesUp
------ ---------- -------- -----------
STAGING-WEB-01 45.2 12 2
STAGING-WEB-02 38.7 8 2
健康检查通过

备份当前版本...
STAGING-WEB-01: 已备份至 D:\Backups\pre-deploy-20260115-090000
STAGING-WEB-02: 已备份至 D:\Backups\pre-deploy-20260115-090000
备份完成

开始部署...
STAGING-WEB-01: 部署完成,站点已启动
STAGING-WEB-02: 部署完成,站点已启动

部署流程全部完成

通用流水线工具

无论使用哪个 CI/CD 平台,一些通用的流水线工具都是不可或缺的。以下代码封装了代码质量检查、语义版本计算和变更日志自动生成三个常用功能,可以作为独立脚本被任何流水线调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# 文件路径: scripts/Pipeline-Utils.ps1
# 通用流水线工具函数集

# --- 函数 1: 代码质量门控检查 ---
function Invoke-CodeQualityGate {
[CmdletBinding()]
param(
[string]$Path = '.\Scripts',
[int]$MaxWarnings = 10,
[int]$MaxErrors = 0
)
$results = Invoke-ScriptAnalyzer -Path $Path -Recurse -Severity Information,Warning,Error
$summary = $results | Group-Object Severity | ForEach-Object {
@{ Severity = $_.Name; Count = $_.Count }
}
$errorCount = ($results | Where-Object Severity -eq 'Error').Count
$warningCount = ($results | Where-Object Severity -eq 'Warning').Count

Write-Host "代码质量报告:" -ForegroundColor Yellow
Write-Host " 错误: $errorCount (阈值: $MaxErrors)"
Write-Host " 警告: $warningCount (阈值: $MaxWarnings)"

$passed = ($errorCount -le $MaxErrors) -and ($warningCount -le $MaxWarnings)
if ($passed) {
Write-Host ' 结果: 通过' -ForegroundColor Green
} else {
Write-Host ' 结果: 未通过' -ForegroundColor Red
}
return $passed
}

# --- 函数 2: 语义版本计算 ---
function Get-NextSemanticVersion {
[CmdletBinding()]
param(
[string]$TagPrefix = 'v',
[string]$DefaultVersion = '0.1.0'
)
$latestTag = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "未找到已有标签,使用默认版本: $DefaultVersion"
return [version]$DefaultVersion
}
$versionStr = $latestTag -replace "^$([regex]::Escape($TagPrefix))"
$current = [version]$versionStr
$messages = git log "$latestTag..HEAD" --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新的提交,版本不变'
return $current
}
$bump = 'Patch'
if ($messages -match '^feat(\(|:)') { $bump = 'Minor' }
if ($messages -match 'BREAKING CHANGE') { $bump = 'Major' }
$next = switch ($bump) {
'Major' { [version]::new($current.Major + 1, 0, 0) }
'Minor' { [version]::new($current.Major, $current.Minor + 1, 0) }
'Patch' { [version]::new($current.Major, $current.Minor, $current.Build + 1) }
}
Write-Host "版本: $current -> $next (递增: $bump)"
return $next
}

# --- 函数 3: 变更日志自动生成 ---
function New-ChangeLog {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[version]$Version,
[string]$TagPrefix = 'v'
)
$latestTag = git describe --tags --abbrev=0 2>$null
$range = if ($LASTEXITCODE -eq 0) { "$latestTag..HEAD" } else { 'HEAD' }
$messages = git log $range --pretty=format:'%s' 2>$null
if (-not $messages) {
Write-Host '没有新变更,跳过 CHANGELOG 生成'
return
}

$lines = @(
"## [$Version] - $(Get-Date -Format 'yyyy-MM-dd')"
)

$features = $messages | Where-Object { $_ -match '^feat' }
$fixes = $messages | Where-Object { $_ -match '^fix' }
$others = $messages | Where-Object {
$_ -notmatch '^feat' -and $_ -notmatch '^fix' -and $_ -notmatch '^chore'
}

if ($features) {
$lines += '', '### 新功能'
$features | ForEach-Object { $lines += "- $_" }
}
if ($fixes) {
$lines += '', '### 修复'
$fixes | ForEach-Object { $lines += "- $_" }
}
if ($others) {
$lines += '', '### 其他变更'
$others | ForEach-Object { $lines += "- $_" }
}

$changeLogPath = 'CHANGELOG.md'
$existing = if (Test-Path $changeLogPath) { Get-Content $changeLogPath -Raw } else { '' }
$newContent = ($lines -join "`n") + "`n`n" + $existing
Set-Content -Path $changeLogPath -Value $newContent.TrimEnd() -Encoding UTF8
Write-Host "CHANGELOG 已更新: $changeLogPath" -ForegroundColor Green
}

# --- 主流程: 调用示例 ---
Write-Host '========== 流水线工具执行 ==========' -ForegroundColor Cyan

# 运行代码质量检查
$qualityOk = Invoke-CodeQualityGate -Path '.\Scripts' -MaxWarnings 10 -MaxErrors 0

# 计算下一个版本号
$nextVersion = Get-NextSemanticVersion -TagPrefix 'v'
Write-Host "下一个发布版本: $nextVersion"

# 生成变更日志
New-ChangeLog -Version $nextVersion -TagPrefix 'v'

if ($qualityOk) {
Write-Host "`n所有门控检查通过,可以继续发布流程" -ForegroundColor Green
} else {
Write-Host "`n门控检查未通过,请修复后重试" -ForegroundColor Red
exit 1
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
========== 流水线工具执行 ==========

代码质量报告:
错误: 0 (阈值: 0)
警告: 3 (阈值: 10)
结果: 通过

版本: 1.0.0 -> 1.1.0 (递增: Minor)
下一个发布版本: 1.1.0

CHANGELOG 已更新: CHANGELOG.md

所有门控检查通过,可以继续发布流程

注意事项

  1. 流水线中的执行策略:CI/CD runner 上的 PowerShell 执行策略可能限制脚本运行。在 workflow 中显式设置 pwsh -ExecutionPolicy Bypass -File script.ps1,或在脚本开头使用 Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned 来确保脚本能正常执行。

  2. 敏感信息管理:绝不要将 API 密钥、连接字符串等敏感信息硬编码在脚本中。GitHub Actions 使用 Secrets,Azure DevOps 使用 Variable Groups 配合 Key Vault,Jenkins 使用 Credentials Binding。在流水线中通过环境变量($env:API_KEY)引用这些安全值。

  3. Pester 测试的隔离性:每个测试用例应该独立运行,不依赖其他测试的执行顺序或状态。使用 BeforeAllAfterAll 进行测试环境的初始化和清理,避免测试之间产生副作用,尤其是在流水线中并发执行测试的场景。

  4. 幂等部署设计:部署脚本必须是幂等的——多次执行的结果与一次执行的结果相同。在部署前先检查目标状态,如果已经是期望状态则跳过操作。这样可以安全地重试失败的部署步骤,而不会产生重复创建或数据损坏等问题。

  5. 跨平台兼容性:如果流水线可能在 Linux 或 macOS runner 上运行,避免使用 Windows 专用的 cmdlet(如 Stop-WebSiteNew-PSSession)。改用 REST API 或 SSH 方式进行远程管理,并使用 Join-Path 代替硬编码路径分隔符,确保脚本在不同操作系统上行为一致。

  6. 流水线日志与遥测:在关键步骤添加结构化的日志输出(如 ::group::::set-output name=key::value),便于在流水线界面中快速定位问题。对于长时间运行的部署,建议集成 Slack 或 Teams Webhook 通知,让团队实时感知部署进度和结果。

PowerShell 技能连载 - CI/CD 流水线集成

适用于 PowerShell 7.0 及以上版本

持续集成/持续部署(CI/CD)是现代 DevOps 的核心实践。PowerShell 作为 Windows 生态的首选脚本语言,天然适配 Azure DevOps、GitHub Actions、Jenkins 等 CI/CD 平台。通过编写结构化的部署脚本,可以将应用发布流程标准化、可重复、可审计。

本文将讲解如何编写适配 CI/CD 的 PowerShell 部署脚本、多环境配置管理,以及 GitHub Actions 的集成示例。

CI/CD 脚本设计原则

好的 CI/CD 脚本应遵循以下原则:幂等性(多次执行结果相同)、参数化(通过参数控制行为)、结构化输出(便于日志解析)和错误处理(失败时明确报告原因):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 标准 CI/CD 脚本模板
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('dev', 'staging', 'production')]
[string]$Environment,

[string]$Version,
[string]$ConfigPath = "./config",
[switch]$DryRun
)

$ErrorActionPreference = 'Stop'

function Write-Step {
param([string]$Message)
Write-Host "`n========== $Message ==========" -ForegroundColor Cyan
}

function Write-Success {
param([string]$Message)
Write-Host " OK: $Message" -ForegroundColor Green
}

function Write-Fail {
param([string]$Message)
Write-Host " FAIL: $Message" -ForegroundColor Red
}

# 加载环境配置
Write-Step "加载配置:$Environment"
$configFile = Join-Path $ConfigPath "$Environment.json"
if (-not (Test-Path $configFile)) {
Write-Fail "配置文件不存在:$configFile"
exit 1
}
$config = Get-Content $configFile | ConvertFrom-Json
Write-Success "配置已加载"

# 构建步骤
Write-Step "构建应用"
$buildOutput = Join-Path $PWD "dist"
if (Test-Path $buildOutput) { Remove-Item $buildOutput -Recurse -Force }

if (-not $DryRun) {
# 实际构建逻辑
dotnet publish -c Release -o $buildOutput
if ($LASTEXITCODE -ne 0) {
Write-Fail "构建失败"
exit 1
}
}
Write-Success "构建完成"

Write-Host "`n部署就绪:$Environment @ $Version" -ForegroundColor Green

执行结果示例:

1
2
3
4
5
6
7
========== 加载配置:production ==========
OK: 配置已加载

========== 构建应用 ==========
OK: 构建完成

部署就绪:production @ 2.5.0

多环境配置管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 环境配置文件结构
$configs = @{
dev = @{
AppName = "myapp-dev"
Server = "dev-server-01"
Port = 8080
Debug = $true
LogLevel = "Debug"
Database = "Server=dev-db;Database=myapp_dev;"
}
staging = @{
AppName = "myapp-staging"
Server = "staging-server-01"
Port = 80
Debug = $false
LogLevel = "Information"
Database = "Server=staging-db;Database=myapp_staging;"
}
production = @{
AppName = "myapp"
Server = "prod-server-01"
Port = 80
Debug = $false
LogLevel = "Warning"
Database = "Server=prod-db;Database=myapp_prod;"
}
}

# 生成配置文件
foreach ($env in $configs.Keys) {
$path = "config/$env.json"
$configs[$env] | ConvertTo-Json -Depth 5 | Set-Content $path
Write-Host "已生成:$path" -ForegroundColor Green
}

# 安全配置替换(从环境变量读取敏感信息)
function Get-SecureConfig {
param([string]$Environment)

$config = Get-Content "config/$Environment.json" | ConvertFrom-Json

# 从环境变量替换敏感配置
$config.Database = $config.Database -replace 'Database=',
"User ID=$($env:DB_USER);Password=$($env:DB_PASSWORD);Database="

return $config
}

执行结果示例:

1
2
3
已生成:config/dev.json
已生成:config/staging.json
已生成:config/production.json

GitHub Actions 集成

以下是一个完整的 GitHub Actions 工作流,使用 PowerShell 执行部署:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 生成 GitHub Actions 工作流文件
$workflow = @'
name: Deploy Application

on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'
type: choice
options:
- dev
- staging
- production

jobs:
deploy:
runs-on: windows-latest
environment: ${{ github.event.inputs.environment || 'staging' }}

steps:
- uses: actions/checkout@v4

- name: Run deployment script
shell: pwsh
env:
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
./scripts/deploy.ps1 `
-Environment "${{ github.event.inputs.environment || 'staging' }}" `
-Version "${{ github.sha }}"

- name: Health check
shell: pwsh
run: |
$env_name = "${{ github.event.inputs.environment || 'staging' }}"
$config = Get-Content "config/$env_name.json" | ConvertFrom-Json
$url = "http://$($config.Server):$($config.Port)/health"

try {
$response = Invoke-RestMethod -Uri $url -TimeoutSec 30
Write-Host "Health check passed: $($response.status)"
} catch {
Write-Error "Health check failed: $($_.Exception.Message)"
exit 1
}
'@

$workflowDir = ".github/workflows"
New-Item -Path $workflowDir -ItemType Directory -Force | Out-Null
Set-Content -Path "$workflowDir/deploy.yml" -Value $workflow
Write-Host "GitHub Actions 工作流已创建" -ForegroundColor Green

执行结果示例:

1
GitHub Actions 工作流已创建

部署回滚脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
function Invoke-Rollback {
<#
.SYNOPSIS
回滚到指定版本
#>
param(
[Parameter(Mandatory)]
[string]$Environment,

[string]$TargetVersion,

[int]$KeepReleases = 5
)

$releaseDir = "C:\Releases\$Environment"
$currentLink = Join-Path $releaseDir "current"

# 获取当前版本
$currentVersion = (Get-Item $currentLink -ErrorAction SilentlyContinue).Target
Write-Host "当前版本:$currentVersion" -ForegroundColor Cyan

# 列出可用版本
$releases = Get-ChildItem $releaseDir -Directory |
Where-Object { $_.Name -match '^\d{8}-\d{6}-v' } |
Sort-Object Name -Descending

Write-Host "可用版本:" -ForegroundColor Yellow
$releases | Select-Object -First 10 Name |
Format-Table -AutoSize

if ($TargetVersion) {
$target = $releases | Where-Object { $_.Name -like "*$TargetVersion*" }
} else {
$target = $releases[1] # 上一个版本
}

if (-not $target) {
Write-Error "未找到目标版本"
return
}

Write-Host "回滚到:$($target.Name)" -ForegroundColor Yellow

# 更新符号链接
if (Test-Path $currentLink) { Remove-Item $currentLink }
New-Item -ItemType SymbolicLink -Path $currentLink -Target $target.FullName | Out-Null

# 重启应用
Restart-Service "MyApp-$Environment"
Write-Host "回滚完成" -ForegroundColor Green

# 清理旧版本
$toDelete = $releases | Select-Object -Skip $KeepReleases
foreach ($old in $toDelete) {
Remove-Item $old.FullName -Recurse -Force
Write-Host "已清理:$($old.Name)" -ForegroundColor DarkGray
}
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
当前版本:20250603-080000-v2.5.0
可用版本:
Name
----
20250603-080000-v2.5.0
20250602-080000-v2.4.0
20250601-080000-v2.3.0

回滚到:20250602-080000-v2.4.0
回滚完成

部署通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function Send-DeploymentNotification {
param(
[string]$Environment,
[string]$Version,
[string]$Status,
[string]$WebhookUrl
)

$color = switch ($Status) {
'success' { '#36a64f' }
'failed' { '#ff0000' }
'rollback' { '#ff9800' }
default { '#808080' }
}

$body = @{
attachments = @(
@{
color = $color
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = "*部署${Status}*`n环境:$Environment`n版本:$Version`n操作人:$env:USER`n时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
}
}
)
}
)
} | ConvertTo-Json -Depth 5

Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $body -ContentType 'application/json'
Write-Host "通知已发送" -ForegroundColor Green
}

# 成功通知
Send-DeploymentNotification -Environment "production" -Version "2.5.0" -Status "success" -WebhookUrl $env:SLACK_WEBHOOK

执行结果示例:

1
通知已发送

注意事项

  1. 幂等性:部署脚本必须幂等,重复执行不应导致错误或数据不一致
  2. 蓝绿部署:生产环境建议使用蓝绿部署或金丝雀发布策略,降低部署风险
  3. 密钥管理:敏感配置应使用 CI/CD 平台的密钥管理(GitHub Secrets、Azure Key Vault),不要提交到代码仓库
  4. 回滚策略:始终保留最近 N 个版本的发布包,确保可以快速回滚
  5. 健康检查:部署后自动执行健康检查,确认服务正常运行后再标记部署完成
  6. 审计日志:所有部署操作应记录审计日志,包括时间、操作人、版本号和结果

PowerShell 技能连载 - CI/CD 流水线中的 PowerShell 实践

适用于 PowerShell 7.0 及以上版本,需要 GitHub 账号

2025 年,CI/CD(持续集成/持续部署)已经成为软件交付的标准流程。无论是小型个人项目还是企业级微服务架构,自动化构建、测试和部署的能力都直接影响着交付效率和代码质量。GitHub Actions 作为目前最流行的 CI/CD 平台之一,原生支持 PowerShell 运行环境,这让 PowerShell 用户可以无缝融入 DevOps 工作流。

PowerShell 在 CI/CD 场景中有独特优势:强大的对象管道让数据处理变得简洁,跨平台支持(PowerShell 7+)让同一套脚本可以在 Windows、Linux 和 macOS Runner 上运行,丰富的模块生态则覆盖了从代码质量检查到部署验证的各个环节。本文将从脚本设计原则出发,逐步构建一个完整的 CI/CD 流水线方案。

设计原则:编写可测试的 PowerShell 脚本

CI/CD 流水线的可靠性取决于其中每一段脚本的质量。一段”能跑就行”的脚本在本地可能没问题,但放到自动化流水线中,缺乏错误处理、日志输出和结构化返回值,就会成为调试噩梦。以下是编写 CI/CD 脚本时应遵循的核心原则:

  1. 单一职责:每个函数只做一件事,方便单独测试和复用
  2. 显式错误处理:使用 try/catch 捕获异常,绝不吞掉错误
  3. 结构化输出:返回对象而非格式化文本,方便下游消费
  4. 参数化配置:所有路径、阈值、环境变量通过参数传入,不硬编码

下面的函数演示了这些原则的实际应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function Invoke-ProjectBuild {
param(
[Parameter(Mandatory)]
[string]$ProjectPath,

[string]$Configuration = "Release",

[string]$OutputPath = "./artifacts"
)

$ErrorActionPreference = "Stop"
$sw = [System.Diagnostics.Stopwatch]::StartNew()

try {
if (-not (Test-Path $ProjectPath)) {
throw "项目路径不存在: $ProjectPath"
}

# 创建输出目录
if (-not (Test-Path $OutputPath)) {
New-Item -Path $OutputPath -ItemType Directory | Out-Null
}

Write-Host "[BUILD] 开始构建 $ProjectPath ($Configuration)..."

# 模拟构建过程
$buildResult = & dotnet publish $ProjectPath `
--configuration $Configuration `
--output $OutputPath 2>&1

$sw.Stop()

$result = [PSCustomObject]@{
Success = $LASTEXITCODE -eq 0
Duration = $sw.Elapsed
OutputPath = $OutputPath
Timestamp = Get-Date
}

if (-not $result.Success) {
throw "构建失败,退出码: $LASTEXITCODE"
}

Write-Host "[BUILD] 构建完成,耗时 $($sw.Elapsed.ToString('mm\:ss'))"
return $result
}
catch {
$sw.Stop()
Write-Error "[BUILD] 构建异常: $($_.Exception.Message)"
throw
}
}
1
2
3
4
5
6
7
8
PS> Invoke-ProjectBuild -ProjectPath "./src/MyApp.csproj"
[BUILD] 开始构建 ./src/MyApp.csproj (Release)...
[BUILD] 构建完成,耗时 00:12

Success : True
Duration : 00:00:12.3456789
OutputPath : ./artifacts
Timestamp : 2025/4/21 10:00:00

Pester 单元测试

在 CI/CD 流水线中,自动化测试是质量保障的基石。Pester 是 PowerShell 生态中最成熟的测试框架,支持 Describe/Context/It 三层组织结构,内联 Mock 能力让外部依赖的隔离变得简单。下面的测试覆盖了构建函数的正常路径和异常路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
BeforeAll {
# 加载待测函数
. "$PSScriptRoot/../build/Invoke-ProjectBuild.ps1"
}

Describe "Invoke-ProjectBuild" {
Context "当项目路径存在时" {
BeforeAll {
# 创建临时项目目录
$testProject = Join-Path $TestDrive "TestApp.csproj"
Set-Content -Path $testProject -Value "<Project></Project>"
}

It "应返回成功的构建结果" {
$result = Invoke-ProjectBuild -ProjectPath $testProject `
-OutputPath "$TestDrive/output"

$result | Should -Not -BeNullOrEmpty
$result.Success | Should -BeTrue
$result.OutputPath | Should -Exist
}

It "应在结果中包含耗时信息" {
$result = Invoke-ProjectBuild -ProjectPath $testProject `
-OutputPath "$TestDrive/output2"

$result.Duration | Should -BeGreaterThan ([TimeSpan]::Zero)
$result.Timestamp | Should -BeGreaterOrEqual (Get-Date).AddMinutes(-1)
}
}

Context "当项目路径不存在时" {
It "应抛出异常" {
{
Invoke-ProjectBuild -ProjectPath "/nonexistent/project.csproj"
} | Should -Throw "*项目路径不存在*"
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
PS> Invoke-Pester -Path ./tests/Invoke-ProjectBuild.Tests.ps1 -Output Detailed

Starting discovery in 1 files.
Discovery finished in 0.23s.
[Des] Invoke-ProjectBuild
[Ctx] 当项目路径存在时
[It] 应返回成功的构建结果
[It] 应在结果中包含耗时信息
[Ctx] 当项目路径不存在时
[It] 应抛出异常
Tests passed: 3, Failed: 0, Skipped: 0, NotRun: 0
Total time: 1.45s

GitHub Actions Workflow 配置

有了可测试的脚本之后,接下来把它集成到 GitHub Actions 中。PowerShell 在 GitHub Actions 的 Windows 和 Ubuntu Runner 上都是预装的,无需额外安装。关键配置点:

  • 使用 pwsh shell 确保使用 PowerShell 7(而非 Windows PowerShell 5.1)
  • 通过 matrix 策略实现跨平台并行测试
  • 使用 actions/cache 缓存模块依赖,加速后续运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
name: PowerShell CI/CD Pipeline

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: ubuntu-latest
shell: pwsh
- os: windows-latest
shell: pwsh

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 缓存 PowerShell 模块
uses: actions/cache@v4
with:
path: ~/.local/share/powershell/Modules
key: ps-modules-${{ runner.os }}-${{ hashFiles('**/requirements.psd1') }}

- name: 安装 Pester
shell: pwsh
run: |
Install-Module -Name Pester -Force -Scope CurrentUser
Import-Module Pester

- name: 运行 Lint 检查
shell: pwsh
run: |
Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser
$results = Invoke-ScriptAnalyzer -Path ./src -Severity Warning
if ($results) {
$results | Format-Table -AutoSize
Write-Error "Lint 检查未通过"
}

- name: 运行 Pester 测试
shell: pwsh
run: |
$config = New-PesterConfiguration
$config.Run.Path = "./tests"
$config.Output.Verbosity = "Detailed"
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = "testResults.xml"
Invoke-Pester -Configuration $config

- name: 上传测试结果
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.os }}
path: testResults.xml
1
2
3
4
5
6
7
8
9
10
# GitHub Actions 运行输出摘要
Run Lint 检查
RuleName Severity ScriptName Line Message
---------- -------- ---------- ---- -------
PSUseShouldProcessForStateChangingFunctions Warning build.ps1 12

Run 运行 Pester 测试
[+] DscResource.Tests.ps1 1.23s
[+] Invoke-ProjectBuild.Tests.ps1 0.87s
Tests passed: 8, Failed: 0

部署验证脚本

CI/CD 不止于构建和测试,部署后的验证同样重要。以下函数在部署完成后自动检查服务的健康状态,支持并发检测多个端点,输出结构化的结果摘要。配合流水线中的 continue-on-error: false,一旦任何端点异常就会中断部署。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
function Test-DeploymentHealth {
param(
[Parameter(Mandatory)]
[string[]]$Endpoints,

[int]$TimeoutSeconds = 10,

[int]$RetryCount = 3,

[int]$RetryDelaySeconds = 5
)

$results = @()

foreach ($endpoint in $Endpoints) {
$attempt = 0
$success = $false

while ($attempt -lt $RetryCount -and -not $success) {
$attempt++
$sw = [System.Diagnostics.Stopwatch]::StartNew()

try {
$response = Invoke-WebRequest -Uri $endpoint `
-TimeoutSec $TimeoutSeconds `
-UseBasicParsing

$sw.Stop()
$success = $response.StatusCode -eq 200

if ($success) {
$results += [PSCustomObject]@{
Endpoint = $endpoint
Status = "OK"
StatusCode = $response.StatusCode
LatencyMs = $sw.ElapsedMilliseconds
Attempt = $attempt
Timestamp = Get-Date
}
Write-Host "[HEALTH] $endpoint - OK ($($sw.ElapsedMilliseconds)ms)"
}
}
catch {
$sw.Stop()
Write-Warning "[HEALTH] $endpoint - 第 $attempt 次检测失败: $($_.Exception.Message)"

if ($attempt -lt $RetryCount) {
Write-Host " 等待 $RetryDelaySeconds 秒后重试..."
Start-Sleep -Seconds $RetryDelaySeconds
}
}
}

if (-not $success) {
$results += [PSCustomObject]@{
Endpoint = $endpoint
Status = "FAIL"
StatusCode = "N/A"
LatencyMs = $sw.ElapsedMilliseconds
Attempt = $attempt
Timestamp = Get-Date
}
}
}

# 检查是否有失败的端点
$failedCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count
if ($failedCount -gt 0) {
Write-Error "[HEALTH] $failedCount 个端点检测失败!"
}

return $results
}
1
2
3
4
5
6
7
8
9
10
PS> Test-DeploymentHealth -Endpoints "https://api.example.com/health","https://app.example.com"
[HEALTH] https://api.example.com/health - OK (145ms)
WARNING: [HEALTH] https://app.example.com - 第 1 次检测失败: 连接超时
等待 5 秒后重试...
[HEALTH] https://app.example.com - OK (230ms)

Endpoint Status StatusCode LatencyMs Attempt Timestamp
-------- ------ ---------- --------- ------- ---------
https://api.example.com/health OK 200 145 1 2025/4/21 10:05:00
https://app.example.com OK 200 230 2 2025/4/21 10:05:06

完整流水线:从构建到部署验证

将前面的组件组合在一起,就形成了一个完整的 CI/CD 编排脚本。这个入口函数按照固定阶段顺序执行,每个阶段失败都会立即中断,确保不会在构建失败的情况下继续部署:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
function Invoke-CiCdPipeline {
param(
[string]$ProjectPath = "./src",
[string]$Environment = "staging",
[string[]]$HealthEndpoints
)

$ErrorActionPreference = "Stop"
$pipelineStart = Get-Date
$stageResults = @{}

Write-Host "========================================"
Write-Host " CI/CD Pipeline - $Environment"
Write-Host " 开始时间: $pipelineStart"
Write-Host "========================================"

# 阶段 1: Lint
Write-Host "`n[Stage 1/4] 代码质量检查..."
$analysisResults = Invoke-ScriptAnalyzer -Path $ProjectPath -Severity Error
if ($analysisResults) {
$analysisResults | Format-Table -AutoSize
throw "代码质量检查未通过"
}
$stageResults["Lint"] = "PASSED"
Write-Host " Lint 检查通过"

# 阶段 2: 测试
Write-Host "`n[Stage 2/4] 运行单元测试..."
$pesterConfig = New-PesterConfiguration
$pesterConfig.Run.Path = "./tests"
$pesterConfig.Output.Verbosity = "Normal"
$testResult = Invoke-Pester -Configuration $pesterConfig -PassThru
if ($testResult.FailedCount -gt 0) {
throw "测试失败: $($testResult.FailedCount) 个用例未通过"
}
$stageResults["Test"] = "PASSED ($($testResult.PassedCount) passed)"
Write-Host " 全部 $($testResult.PassedCount) 个测试通过"

# 阶段 3: 构建
Write-Host "`n[Stage 3/4] 构建项目..."
$buildResult = Invoke-ProjectBuild -ProjectPath "$ProjectPath/App.csproj"
$stageResults["Build"] = "PASSED ($($buildResult.Duration.ToString('mm\:ss')))"
Write-Host " 构建完成"

# 阶段 4: 健康检查(仅当提供了端点时)
if ($HealthEndpoints) {
Write-Host "`n[Stage 4/4] 部署验证..."
$healthResults = Test-DeploymentHealth -Endpoints $HealthEndpoints
$stageResults["HealthCheck"] = "PASSED ($($HealthEndpoints.Count) endpoints)"
Write-Host " 所有端点健康"
}

# 输出汇总
$totalDuration = (Get-Date) - $pipelineStart
Write-Host "`n========================================"
Write-Host " Pipeline 完成"
Write-Host " 总耗时: $($totalDuration.ToString('mm\:ss'))"
Write-Host "========================================"

foreach ($stage in $stageResults.GetEnumerator()) {
Write-Host (" {0,-15} {1}" -f $stage.Key, $stage.Value)
}

return [PSCustomObject]@{
Success = $true
Duration = $totalDuration
Stages = $stageResults
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
PS> Invoke-CiCdPipeline -Environment "staging" -HealthEndpoints "https://api.example.com/health"

========================================
CI/CD Pipeline - staging
开始时间: 2025/4/21 10:00:00
========================================

[Stage 1/4] 代码质量检查...
Lint 检查通过

[Stage 2/4] 运行单元测试...
全部 8 个测试通过

[Stage 3/4] 构建项目...
[BUILD] 开始构建 ./src/App.csproj (Release)...
[BUILD] 构建完成,耗时 00:12
构建完成

[Stage 4/4] 部署验证...
[HEALTH] https://api.example.com/health - OK (98ms)
所有端点健康

========================================
Pipeline 完成
总耗时: 00:52
========================================
Lint PASSED
Test PASSED (8 passed)
Build PASSED (00:12)
HealthCheck PASSED (1 endpoints)

小结

将 PowerShell 融入 CI/CD 流水线的关键在于:脚本要可测试(Pester 覆盖正常和异常路径)、可观测(结构化日志输出)、可组合(单一职责函数通过编排脚本串联)。GitHub Actions 原生支持 pwsh,使得 PowerShell 脚本可以直接作为 Workflow 步骤运行,无需额外适配。建议在本地先用 Pester 充分测试脚本逻辑,再集成到 Actions Workflow 中,这样能大幅减少调试周期。