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 中,这样能大幅减少调试周期。