microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
79db525325e5e5bc087af12d0fc658c8db2d9458

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/lib/Modules/CIHelpers.psm1

609lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# CIHelpers.psm1
5#
6# Purpose: Shared CI platform detection and output utilities for hve-core scripts.
7# Author: HVE Core Team
8
9#Requires -Version 7.0
10
11function ConvertTo-GitHubActionsEscaped {
12 <#
13 .SYNOPSIS
14 Escapes a string for safe use in GitHub Actions workflow commands.
15
16 .DESCRIPTION
17 Percent-encodes characters that have special meaning in GitHub Actions
18 logging commands to prevent workflow command injection attacks.
19
20 .PARAMETER Value
21 The string to escape.
22
23 .PARAMETER ForProperty
24 If set, also escapes colon and comma characters used in property values.
25 #>
26 [CmdletBinding()]
27 [OutputType([string])]
28 param(
29 [Parameter(Mandatory = $true)]
30 [AllowEmptyString()]
31 [string]$Value,
32
33 [Parameter(Mandatory = $false)]
34 [switch]$ForProperty
35 )
36
37 if ([string]::IsNullOrEmpty($Value)) {
38 return $Value
39 }
40
41 # Order matters: escape % first to avoid double-encoding
42 $escaped = $Value -replace '%', '%25'
43 $escaped = $escaped -replace "`r", '%0D'
44 $escaped = $escaped -replace "`n", '%0A'
45 # Escape :: patterns to neutralize command sequences (defense in depth)
46 # This prevents ::command:: patterns. When ForProperty is false, single colons like C:\ are preserved.
47 $escaped = $escaped -replace '::', '%3A%3A'
48
49 if ($ForProperty) {
50 $escaped = $escaped -replace ':', '%3A'
51 $escaped = $escaped -replace ',', '%2C'
52 }
53
54 return $escaped
55}
56
57function ConvertTo-AzureDevOpsEscaped {
58 <#
59 .SYNOPSIS
60 Escapes a string for safe use in Azure DevOps logging commands.
61
62 .DESCRIPTION
63 Percent-encodes characters that have special meaning in Azure DevOps
64 logging commands to prevent workflow command injection attacks.
65
66 .PARAMETER Value
67 The string to escape.
68
69 .PARAMETER ForProperty
70 If set, also escapes semicolon and bracket characters used in property values.
71 #>
72 [CmdletBinding()]
73 [OutputType([string])]
74 param(
75 [Parameter(Mandatory = $true)]
76 [AllowEmptyString()]
77 [string]$Value,
78
79 [Parameter(Mandatory = $false)]
80 [switch]$ForProperty
81 )
82
83 if ([string]::IsNullOrEmpty($Value)) {
84 return $Value
85 }
86
87 # Order matters: escape % first to avoid double-encoding
88 $escaped = $Value -replace '%', '%AZP25'
89 $escaped = $escaped -replace "`r", '%AZP0D'
90 $escaped = $escaped -replace "`n", '%AZP0A'
91 # Escape brackets to prevent ##vso[ command patterns (defense in depth)
92 $escaped = $escaped -replace '\[', '%AZP5B'
93 $escaped = $escaped -replace '\]', '%AZP5D'
94
95 if ($ForProperty) {
96 $escaped = $escaped -replace ';', '%AZP3B'
97 }
98
99 return $escaped
100}
101
102function Get-CIPlatform {
103 <#
104 .SYNOPSIS
105 Detects the current CI platform.
106
107 .DESCRIPTION
108 Returns the CI platform identifier based on environment variables.
109 Supports GitHub Actions, Azure DevOps, and local development.
110
111 .OUTPUTS
112 System.String - 'github', 'azdo', or 'local'
113 #>
114 [CmdletBinding()]
115 [OutputType([string])]
116 param()
117
118 if ($env:GITHUB_ACTIONS -eq 'true') {
119 return 'github'
120 }
121 if ($env:TF_BUILD -eq 'True' -or $env:AZURE_PIPELINES -eq 'True') {
122 return 'azdo'
123 }
124 return 'local'
125}
126
127function Test-CIEnvironment {
128 <#
129 .SYNOPSIS
130 Tests whether running in a CI environment.
131
132 .DESCRIPTION
133 Returns true if running in GitHub Actions or Azure DevOps.
134
135 .OUTPUTS
136 System.Boolean - $true if in CI, $false otherwise
137 #>
138 [CmdletBinding()]
139 [OutputType([bool])]
140 param()
141
142 return (Get-CIPlatform) -ne 'local'
143}
144
145function Set-CIOutput {
146 <#
147 .SYNOPSIS
148 Sets a CI output variable.
149
150 .DESCRIPTION
151 Sets an output variable that can be consumed by subsequent workflow steps.
152 Uses GITHUB_OUTPUT for GitHub Actions and task.setvariable for Azure DevOps.
153
154 .PARAMETER Name
155 The variable name.
156
157 .PARAMETER Value
158 The variable value.
159
160 .PARAMETER IsOutput
161 For Azure DevOps, marks the variable as an output variable.
162 #>
163 [CmdletBinding()]
164 param(
165 [Parameter(Mandatory = $true)]
166 [string]$Name,
167
168 [Parameter(Mandatory = $true)]
169 [string]$Value,
170
171 [Parameter(Mandatory = $false)]
172 [switch]$IsOutput
173 )
174
175 $platform = Get-CIPlatform
176
177 switch ($platform) {
178 'github' {
179 if ($env:GITHUB_OUTPUT) {
180 # GITHUB_OUTPUT uses file-based output, less vulnerable but still escape newlines
181 $escapedName = ConvertTo-GitHubActionsEscaped -Value $Name
182 $escapedValue = ConvertTo-GitHubActionsEscaped -Value $Value
183 "$escapedName=$escapedValue" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
184 }
185 else {
186 Write-Verbose "GITHUB_OUTPUT not set, would set: $Name=$Value"
187 }
188 }
189 'azdo' {
190 $outputFlag = if ($IsOutput) { ';isOutput=true' } else { '' }
191 $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty
192 $escapedValue = ConvertTo-AzureDevOpsEscaped -Value $Value
193 Write-Output "##vso[task.setvariable variable=$escapedName$outputFlag]$escapedValue"
194 }
195 'local' {
196 Write-Verbose "CI Output: $Name=$Value"
197 }
198 }
199}
200
201function Set-CIEnv {
202 <#
203 .SYNOPSIS
204 Sets a CI environment variable.
205
206 .DESCRIPTION
207 Writes environment variables for GitHub Actions or Azure DevOps.
208
209 .PARAMETER Name
210 The environment variable name.
211
212 .PARAMETER Value
213 The environment variable value.
214 #>
215 [CmdletBinding()]
216 param(
217 [Parameter(Mandatory = $true)]
218 [string]$Name,
219
220 [Parameter(Mandatory = $true)]
221 [string]$Value
222 )
223
224 $platform = Get-CIPlatform
225
226 switch ($platform) {
227 'github' {
228 if ($env:GITHUB_ENV) {
229 if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') {
230 throw "Invalid GitHub Actions environment variable name: '$Name'. Names must match '^[A-Za-z_][A-Za-z0-9_]*\$'."
231 }
232
233 $delimiter = "EOF_$([guid]::NewGuid().ToString('N'))"
234 @(
235 "$Name<<$delimiter"
236 $Value
237 $delimiter
238 ) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
239 }
240 else {
241 Write-Verbose "GITHUB_ENV not set, would set: $Name=$Value"
242 }
243 }
244 'azdo' {
245 $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty
246 $escapedValue = ConvertTo-AzureDevOpsEscaped -Value $Value
247 Write-Output "##vso[task.setvariable variable=$escapedName]$escapedValue"
248 }
249 'local' {
250 Write-Verbose "CI Env: $Name=$Value"
251 }
252 }
253}
254
255function Write-CIStepSummary {
256 <#
257 .SYNOPSIS
258 Writes content to the CI step summary.
259
260 .DESCRIPTION
261 Appends markdown content to the step summary for GitHub Actions.
262 For Azure DevOps, outputs as a section header and content.
263
264 .PARAMETER Content
265 The markdown content to append.
266
267 .PARAMETER Path
268 Path to a file containing markdown content.
269 #>
270 [CmdletBinding()]
271 param(
272 [Parameter(Mandatory = $true, ParameterSetName = 'Content')]
273 [string]$Content,
274
275 [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
276 [string]$Path
277 )
278
279 $platform = Get-CIPlatform
280 $markdown = if ($PSCmdlet.ParameterSetName -eq 'Path') {
281 Get-Content -Path $Path -Raw
282 }
283 else {
284 $Content
285 }
286
287 switch ($platform) {
288 'github' {
289 if ($env:GITHUB_STEP_SUMMARY) {
290 $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
291 }
292 else {
293 Write-Verbose "GITHUB_STEP_SUMMARY not set"
294 Write-Verbose $markdown
295 }
296 }
297 'azdo' {
298 Write-Output "##[section]Step Summary"
299 Write-Output $markdown
300 }
301 'local' {
302 Write-Verbose "Step Summary:"
303 Write-Verbose $markdown
304 }
305 }
306}
307
308function Write-CIAnnotation {
309 <#
310 .SYNOPSIS
311 Writes a CI annotation (warning, error, notice).
312
313 .DESCRIPTION
314 Creates a workflow annotation that appears in the GitHub Actions or Azure DevOps UI.
315
316 .PARAMETER Message
317 The annotation message.
318
319 .PARAMETER Level
320 The severity level: Warning, Error, or Notice.
321
322 .PARAMETER File
323 Optional file path for file-level annotations.
324
325 .PARAMETER Line
326 Optional line number for the annotation.
327
328 .PARAMETER Column
329 Optional column number for the annotation.
330 #>
331 [CmdletBinding()]
332 param(
333 [Parameter(Mandatory = $true)]
334 [AllowEmptyString()]
335 [string]$Message,
336
337 [Parameter(Mandatory = $false)]
338 [ValidateSet('Warning', 'Error', 'Notice')]
339 [string]$Level = 'Warning',
340
341 [Parameter(Mandatory = $false)]
342 [string]$File,
343
344 [Parameter(Mandatory = $false)]
345 [int]$Line,
346
347 [Parameter(Mandatory = $false)]
348 [int]$Column
349 )
350
351 $platform = Get-CIPlatform
352
353 switch ($platform) {
354 'github' {
355 $levelLower = $Level.ToLower()
356 $annotation = "::$levelLower"
357 $params = @()
358 if ($File) {
359 $normalizedFile = $File -replace '\\', '/'
360 $escapedFile = ConvertTo-GitHubActionsEscaped -Value $normalizedFile -ForProperty
361 $params += "file=$escapedFile"
362 }
363 if ($Line -gt 0) { $params += "line=$Line" }
364 if ($Column -gt 0) { $params += "col=$Column" }
365 if ($params.Count -gt 0) {
366 $annotation += " $($params -join ',')"
367 }
368 $escapedMessage = ConvertTo-GitHubActionsEscaped -Value $Message
369 Write-Output "$annotation::$escapedMessage"
370 }
371 'azdo' {
372 $typeMap = @{
373 'Warning' = 'warning'
374 'Error' = 'error'
375 'Notice' = 'info'
376 }
377 $adoType = $typeMap[$Level]
378 $annotation = "##vso[task.logissue type=$adoType"
379 if ($File) {
380 $escapedFile = ConvertTo-AzureDevOpsEscaped -Value $File -ForProperty
381 $annotation += ";sourcepath=$escapedFile"
382 }
383 if ($Line -gt 0) { $annotation += ";linenumber=$Line" }
384 if ($Column -gt 0) { $annotation += ";columnnumber=$Column" }
385 $escapedMessage = ConvertTo-AzureDevOpsEscaped -Value $Message
386 Write-Output "$annotation]$escapedMessage"
387 }
388 'local' {
389 $prefix = switch ($Level) {
390 'Warning' { 'WARNING' }
391 'Error' { 'ERROR' }
392 'Notice' { 'NOTICE' }
393 }
394 $location = if ($File) { " [$File" + $(if ($Line) { ":$Line" } else { '' }) + ']' } else { '' }
395 Write-Warning "$prefix$location $Message"
396 }
397 }
398}
399
400function Write-CIAnnotations {
401 <#
402 .SYNOPSIS
403 Writes CI annotations for summary results.
404
405 .DESCRIPTION
406 Emits annotations for each issue in a summary object, mapping errors and warnings
407 to the platform-specific annotation formats.
408
409 .PARAMETER Summary
410 Summary object containing Results with Issues and file metadata.
411 #>
412 [CmdletBinding()]
413 param(
414 [Parameter(Mandatory = $true)]
415 $Summary
416 )
417
418 if (-not $Summary -or -not $Summary.Results) {
419 return
420 }
421
422 foreach ($result in $Summary.Results) {
423 if (-not $result -or -not $result.Issues) {
424 continue
425 }
426
427 foreach ($issue in $result.Issues) {
428 if (-not $issue) {
429 continue
430 }
431
432 # Skip issues with null or empty messages
433 if ([string]::IsNullOrWhiteSpace($issue.Message)) {
434 continue
435 }
436
437 $level = if ($issue.Type -eq 'Error') { 'Error' } else { 'Warning' }
438 $line = if ($issue.Line -gt 0) { $issue.Line } else { 1 }
439 $filePath = if ($result.RelativePath) { $result.RelativePath } elseif ($issue.FilePath) { $issue.FilePath } else { $null }
440
441 $annotationParams = @{
442 Message = [string]$issue.Message
443 Level = $level
444 }
445
446 if ($filePath) {
447 $annotationParams['File'] = [string]$filePath
448 $annotationParams['Line'] = $line
449 }
450
451 if ($issue.Column -gt 0) {
452 $annotationParams['Column'] = $issue.Column
453 }
454
455 Write-CIAnnotation @annotationParams
456 }
457 }
458}
459
460function Set-CITaskResult {
461 <#
462 .SYNOPSIS
463 Sets the CI task/step result status.
464
465 .DESCRIPTION
466 Sets the overall result of the current task or step.
467
468 .PARAMETER Result
469 The result status: Succeeded, SucceededWithIssues, or Failed.
470 #>
471 [CmdletBinding()]
472 param(
473 [Parameter(Mandatory = $true)]
474 [ValidateSet('Succeeded', 'SucceededWithIssues', 'Failed')]
475 [string]$Result
476 )
477
478 $platform = Get-CIPlatform
479
480 switch ($platform) {
481 'github' {
482 Write-Verbose "GitHub Actions task result: $Result"
483 if ($Result -eq 'Failed') {
484 Write-Output "::error::Task failed"
485 }
486 }
487 'azdo' {
488 Write-Output "##vso[task.complete result=$Result]"
489 }
490 'local' {
491 Write-Verbose "Task result: $Result"
492 }
493 }
494}
495
496function Publish-CIArtifact {
497 <#
498 .SYNOPSIS
499 Publishes a CI artifact.
500
501 .DESCRIPTION
502 Publishes a file or folder as a CI artifact.
503 For GitHub Actions, outputs the path for use with actions/upload-artifact.
504 For Azure DevOps, uses the artifact.upload command.
505
506 .PARAMETER Path
507 The path to the file or folder to publish.
508
509 .PARAMETER Name
510 The artifact name.
511
512 .PARAMETER ContainerFolder
513 For Azure DevOps, the container folder path within the artifact.
514 #>
515 [CmdletBinding()]
516 param(
517 [Parameter(Mandatory = $true)]
518 [string]$Path,
519
520 [Parameter(Mandatory = $true)]
521 [string]$Name,
522
523 [Parameter(Mandatory = $false)]
524 [string]$ContainerFolder
525 )
526
527 $platform = Get-CIPlatform
528
529 if (-not (Test-Path $Path)) {
530 Write-Warning "Artifact path not found: $Path"
531 return
532 }
533
534 switch ($platform) {
535 'github' {
536 Set-CIOutput -Name "artifact-path-$Name" -Value $Path
537 Set-CIOutput -Name "artifact-name-$Name" -Value $Name
538 Write-Verbose "GitHub artifact ready: $Name at $Path"
539 }
540 'azdo' {
541 $container = if ($ContainerFolder) { $ContainerFolder } else { $Name }
542 $escapedContainer = ConvertTo-AzureDevOpsEscaped -Value $container -ForProperty
543 $escapedName = ConvertTo-AzureDevOpsEscaped -Value $Name -ForProperty
544 $escapedPath = ConvertTo-AzureDevOpsEscaped -Value $Path
545 Write-Output "##vso[artifact.upload containerfolder=$escapedContainer;artifactname=$escapedName]$escapedPath"
546 }
547 'local' {
548 Write-Verbose "Artifact: $Name at $Path"
549 }
550 }
551}
552
553function Get-StandardTimestamp {
554 <#
555 .SYNOPSIS
556 Returns the current UTC time as an ISO 8601 string.
557
558 .DESCRIPTION
559 Returns the current UTC time formatted with the round-trip specifier ("o"),
560 producing a string such as "2025-01-15T18:30:00.0000000Z". Use this
561 function wherever a timestamp is needed to ensure consistent, timezone-
562 unambiguous log output across all scripts.
563
564 .OUTPUTS
565 System.String - UTC timestamp in ISO 8601 round-trip format ending in Z.
566 #>
567 [CmdletBinding()]
568 [OutputType([string])]
569 param()
570
571 return (Get-Date).ToUniversalTime().ToString('o')
572}
573
574function Get-StandardTimestampPattern {
575 <#
576 .SYNOPSIS
577 Returns the regex pattern that matches Get-StandardTimestamp output.
578
579 .DESCRIPTION
580 Returns a single-source regex anchored to the ISO 8601 round-trip format
581 produced by Get-StandardTimestamp (e.g. "2025-01-15T18:30:00.0000000Z").
582 Use this function in tests instead of hard-coding the pattern so that all
583 assertions stay in sync when the timestamp format changes.
584
585 .OUTPUTS
586 System.String - Anchored regex pattern for ISO 8601 UTC timestamps.
587 #>
588 [CmdletBinding()]
589 [OutputType([string])]
590 param()
591
592 return '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z$'
593}
594
595Export-ModuleMember -Function @(
596 'Get-StandardTimestamp',
597 'Get-StandardTimestampPattern',
598 'ConvertTo-GitHubActionsEscaped',
599 'ConvertTo-AzureDevOpsEscaped',
600 'Get-CIPlatform',
601 'Test-CIEnvironment',
602 'Set-CIOutput',
603 'Set-CIEnv',
604 'Write-CIStepSummary',
605 'Write-CIAnnotation',
606 'Write-CIAnnotations',
607 'Set-CITaskResult',
608 'Publish-CIArtifact'
609)
610