Skip to content

Commit 585b241

Browse files
WilliamBerryiiiBill Berry
andauthored
feat(scripts): extract embedded PowerShell from workflows into testable scripts (#738)
## Description Extract embedded PowerShell from two GitHub Actions workflows into standalone, testable scripts as a pilot for the workflow-embedded testing pattern (issue #736). Both workflows retain their step structure with thin wrapper calls replacing inline code. The extracted scripts follow a Core-function + script-guard architecture using `$MyInvocation.InvocationName -ne '.'` for dot-source testability, adopt `CIHelpers.psm1` for injection-safe CI output, and include comprehensive Pester test suites achieving 100% code coverage across 37 tests. ### Extracted Scripts `Get-ChangedTestFiles.ps1` (extracted from `pester-tests.yml`) runs `git diff --name-only` against the base branch, resolves corresponding `.Tests.ps1` files across `scripts/tests/` and skill directories, includes directly changed tests, and deduplicates. Returns a `[PSCustomObject]` with `HasChanges`, `TestPaths`, and `ChangedFiles`. `Find-CollectionManifests.ps1` (extracted from `extension-package.yml`) discovers `*.collection.yml` manifests, filters by maturity and release channel (deprecated always excluded, experimental excluded for Stable), and returns a `[PSCustomObject]` with `MatrixJson`, `MatrixItems`, and `Skipped`. Requires the `PowerShell-Yaml` module. ### Workflow Modifications `pester-tests.yml` had ~63 lines of embedded PowerShell removed and replaced with a single-line call to `Get-ChangedTestFiles.ps1`. `extension-package.yml` had ~41 lines removed and replaced with a call to `Find-CollectionManifests.ps1`. ### Test Coverage 37 total Pester tests (16 + 21) validate both scripts with isolated GUID-based temp directories. Coverage configuration in `pester.config.ps1` was updated to include `scripts/tests/` in the coverage directory list. ## Related Issue(s) Related to #736 ## Type of Change Select all that apply: **Code & Documentation:** - [ ] Bug fix (non-breaking change fixing an issue) - [x] New feature (non-breaking change adding functionality) - [ ] Breaking change (fix or feature causing existing functionality to change) - [ ] Documentation update **Infrastructure & Configuration:** - [x] GitHub Actions workflow - [ ] Linting configuration (markdown, PowerShell, etc.) - [ ] Security configuration - [ ] DevContainer configuration - [ ] Dependency update **AI Artifacts:** - [ ] Reviewed contribution with `prompt-builder` agent and addressed all feedback - [ ] Copilot instructions (`.github/instructions/*.instructions.md`) - [ ] Copilot prompt (`.github/prompts/*.prompt.md`) - [ ] Copilot agent (`.github/agents/*.agent.md`) - [ ] Copilot skill (`.github/skills/*/SKILL.md`) **Other:** - [x] Script/automation (`.ps1`, `.sh`, `.py`) - [ ] Other (please describe): ## Testing All extracted scripts are fully tested with Pester 5.7.1: - `Get-ChangedTestFiles.Tests.ps1`: 16 tests covering empty diff, matching test resolution, missing test handling, directly changed test inclusion, skill directory discovery (depth 1 and 2), missing skills directory, deduplication, git diff failure, and script guard CI integration - `Find-CollectionManifests.Tests.ps1`: 21 tests covering single/multiple manifest discovery, deprecated/experimental filtering, channel-specific behavior, skipped collection tracking with reasons, missing name/maturity field defaults, valid JSON output, and script guard CI execution - All tests use isolated GUID-based temp directories with cleanup - 100% code coverage achieved on both scripts - Validated via `npm run test:ps` with `pester-summary.json` confirming 37/37 pass ## Checklist ### Required Checks - [ ] Documentation is updated (if applicable) - [x] Files follow existing naming conventions - [x] Changes are backwards compatible (if applicable) - [x] Tests added for new functionality (if applicable) ### Required Automated Checks The following validation commands must pass before merging: - [x] Markdown linting: `npm run lint:md` - [x] Spell checking: `npm run spell-check` - [x] Frontmatter validation: `npm run lint:frontmatter` - [x] Skill structure validation: `npm run validate:skills` - [x] Link validation: `npm run lint:md-links` - [x] PowerShell analysis: `npm run lint:ps` - [x] Plugin freshness: `npm run plugin:generate` ## Security Considerations - [x] This PR does not contain any sensitive or NDA information - [x] Any new dependencies have been reviewed for security issues - [x] Security-related scripts follow the principle of least privilege ## Additional Notes This is a pilot for the workflow-embedded PowerShell testing pattern. The Core-function + script-guard architecture enables unit testing of CI logic without executing CI side effects. If validated, this pattern can be applied to other workflows with embedded PowerShell. Co-authored-by: Bill Berry <wbery@microsoft.com>
1 parent 780cf22 commit 585b241

7 files changed

Lines changed: 945 additions & 107 deletions

File tree

.github/workflows/extension-package.yml

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -72,48 +72,7 @@ jobs:
7272
id: discover
7373
shell: pwsh
7474
run: |
75-
Import-Module PowerShell-Yaml -ErrorAction Stop
76-
77-
$collectionsDir = 'collections'
78-
$channel = '${{ inputs.channel }}'.Trim()
79-
if (-not $channel) { $channel = 'Stable' }
80-
81-
if (-not (Test-Path $collectionsDir)) {
82-
Write-Error "Collections directory not found: $collectionsDir"
83-
exit 1
84-
}
85-
86-
$collectionFiles = Get-ChildItem -Path $collectionsDir -Filter '*.collection.yml' -File | Sort-Object Name
87-
$matrixItems = @()
88-
89-
foreach ($file in $collectionFiles) {
90-
$manifest = ConvertFrom-Yaml -Yaml (Get-Content -Path $file.FullName -Raw)
91-
$id = [string]$manifest.id
92-
$name = if ($manifest.ContainsKey('name')) { [string]$manifest.name } else { $id }
93-
$maturity = if ($manifest.ContainsKey('maturity') -and $manifest.maturity) { [string]$manifest.maturity } else { 'stable' }
94-
95-
if ($maturity -eq 'deprecated') {
96-
Write-Host "::notice::Skipping deprecated collection: $id"
97-
continue
98-
}
99-
100-
if ($channel -eq 'Stable' -and $maturity -eq 'experimental') {
101-
Write-Host "::notice::Skipping experimental collection '$id' for Stable channel"
102-
continue
103-
}
104-
105-
$matrixItems += @{
106-
id = $id
107-
name = $name
108-
manifest = $file.FullName -replace '\\', '/'
109-
maturity = $maturity
110-
}
111-
}
112-
113-
$matrixJson = @{ include = $matrixItems } | ConvertTo-Json -Depth 5 -Compress
114-
"matrix=$matrixJson" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
115-
Write-Host "Discovered collections:"
116-
$matrixJson | ConvertFrom-Json | ConvertTo-Json -Depth 5
75+
& ./scripts/extension/Find-CollectionManifests.ps1 -Channel '${{ inputs.channel }}'
11776
11877
package:
11978
name: Package ${{ matrix.id }}

.github/workflows/pester-tests.yml

Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -52,70 +52,7 @@ jobs:
5252
if: inputs.changed-files-only
5353
shell: pwsh
5454
run: |
55-
# Get changed PowerShell files between base and head
56-
$diffOutput = git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.ps1' '*.psm1' 2>$null
57-
$changedFiles = @($diffOutput -split "`n" | Where-Object { $_ -and $_.Trim() })
58-
59-
if ($changedFiles.Count -eq 0) {
60-
Write-Host "No changed PowerShell files found"
61-
if ($env:GITHUB_ENV) {
62-
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
63-
}
64-
exit 0
65-
}
66-
67-
Write-Host "Changed PowerShell files:"
68-
$changedFiles | ForEach-Object { Write-Host " - $_" }
69-
70-
# Build test search paths: scripts/tests + skill test dirs at known depths
71-
# Skills live at .github/skills/<skill>/ or .github/skills/<collection>/<skill>/
72-
$testSearchPaths = @('scripts/tests')
73-
$skillsRoot = Join-Path (Get-Location) '.github' 'skills'
74-
if (Test-Path $skillsRoot) {
75-
foreach ($depth in @('*', '*/*')) {
76-
$pattern = Join-Path $skillsRoot $depth 'tests'
77-
Get-Item -Path $pattern -ErrorAction SilentlyContinue |
78-
Where-Object { $_.PSIsContainer -and (Test-Path (Join-Path $_.Parent.FullName 'scripts')) } |
79-
ForEach-Object { $testSearchPaths += $_.FullName }
80-
}
81-
}
82-
83-
# Map source files to their test files
84-
$testFiles = @()
85-
foreach ($file in $changedFiles) {
86-
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($file)
87-
# Look for corresponding test file in all test search paths (scripts/tests and skill test dirs)
88-
$matchingTests = $testSearchPaths | ForEach-Object {
89-
Get-ChildItem -Path $_ -Filter "$fileName.Tests.ps1" -Recurse -ErrorAction SilentlyContinue
90-
}
91-
if ($matchingTests) {
92-
$testFiles += $matchingTests.FullName
93-
}
94-
}
95-
96-
# Also include any directly changed test files
97-
$changedTests = @($changedFiles | Where-Object { $_ -like '*.Tests.ps1' })
98-
foreach ($test in $changedTests) {
99-
$fullPath = Join-Path (Get-Location) $test
100-
if (Test-Path $fullPath) {
101-
$testFiles += $fullPath
102-
}
103-
}
104-
105-
$testFiles = @($testFiles | Select-Object -Unique)
106-
if ($testFiles.Count -eq 0) {
107-
Write-Host "No matching test files for changed PowerShell files"
108-
if ($env:GITHUB_ENV) {
109-
"HAS_CHANGES=false" | Out-File -FilePath $env:GITHUB_ENV -Append
110-
}
111-
} else {
112-
Write-Host "Found $($testFiles.Count) test file(s) to run:"
113-
$testFiles | ForEach-Object { Write-Host " - $_" }
114-
if ($env:GITHUB_ENV) {
115-
"HAS_CHANGES=true" | Out-File -FilePath $env:GITHUB_ENV -Append
116-
"TEST_PATHS=$($testFiles -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append
117-
}
118-
}
55+
& ./scripts/tests/Get-ChangedTestFiles.ps1 -BaseBranch '${{ github.base_ref }}'
11956
12057
- name: Run Pester Tests
12158
id: pester
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env pwsh
2+
# Copyright (c) Microsoft Corporation.
3+
# SPDX-License-Identifier: MIT
4+
#
5+
# Find-CollectionManifests.ps1
6+
#
7+
# Purpose: Discover and filter collection manifests for extension packaging matrix
8+
# Author: HVE Core Team
9+
10+
#Requires -Version 7.0
11+
#Requires -Modules PowerShell-Yaml
12+
13+
[CmdletBinding()]
14+
param(
15+
[Parameter(Mandatory = $false)]
16+
[string]$Channel = 'Stable',
17+
18+
[Parameter(Mandatory = $false)]
19+
[string]$CollectionsDir = (Join-Path $PSScriptRoot '../../collections')
20+
)
21+
22+
$ErrorActionPreference = 'Stop'
23+
24+
# Import CI helpers for output writing
25+
Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force
26+
27+
#region Functions
28+
29+
function Find-CollectionManifestsCore {
30+
<#
31+
.SYNOPSIS
32+
Discovers collection manifest files and builds a GitHub Actions matrix.
33+
.DESCRIPTION
34+
Reads *.collection.yml files from the specified directory, parses each with
35+
ConvertFrom-Yaml, and filters by maturity against the release channel.
36+
Deprecated collections are always excluded; experimental collections are
37+
excluded for the Stable channel.
38+
.PARAMETER Channel
39+
Release channel controlling maturity filtering (default: Stable).
40+
.PARAMETER CollectionsDir
41+
Directory containing *.collection.yml manifest files.
42+
#>
43+
[CmdletBinding()]
44+
[OutputType([PSCustomObject])]
45+
param(
46+
[Parameter(Mandatory = $false)]
47+
[string]$Channel = 'Stable',
48+
49+
[Parameter(Mandatory = $false)]
50+
[string]$CollectionsDir = 'collections'
51+
)
52+
53+
$channel = $Channel.Trim()
54+
if (-not $channel) { $channel = 'Stable' }
55+
56+
$collectionFiles = Get-ChildItem -Path $CollectionsDir -Filter '*.collection.yml' -File -ErrorAction SilentlyContinue | Sort-Object Name
57+
if (-not $collectionFiles -or $collectionFiles.Count -eq 0) {
58+
Write-Warning "No collection manifest files found in $CollectionsDir"
59+
return [PSCustomObject]@{
60+
MatrixJson = '{"include":[]}'
61+
MatrixItems = @()
62+
Skipped = @()
63+
}
64+
}
65+
66+
$matrixItems = @()
67+
$skipped = @()
68+
69+
foreach ($file in $collectionFiles) {
70+
$content = Get-Content -Path $file.FullName -Raw
71+
$manifest = ConvertFrom-Yaml -Yaml $content
72+
73+
$id = [string]$manifest.id
74+
$name = if ($manifest.ContainsKey('name')) { [string]$manifest.name } else { $id }
75+
$maturity = if ($manifest.ContainsKey('maturity') -and $manifest.maturity) { [string]$manifest.maturity } else { 'stable' }
76+
77+
# Always skip deprecated
78+
if ($maturity -eq 'deprecated') {
79+
$skipped += [PSCustomObject]@{ Id = $id; Name = $name; Reason = 'deprecated' }
80+
Write-Verbose "Skipping deprecated collection: $name ($id)"
81+
continue
82+
}
83+
84+
# Skip experimental for Stable channel
85+
if ($maturity -eq 'experimental' -and $channel -eq 'Stable') {
86+
$skipped += [PSCustomObject]@{ Id = $id; Name = $name; Reason = 'experimental (Stable channel)' }
87+
Write-Verbose "Skipping experimental collection for Stable channel: $name ($id)"
88+
continue
89+
}
90+
91+
$matrixItems += @{
92+
id = $id
93+
name = $name
94+
manifest = $file.FullName -replace '\\', '/'
95+
maturity = $maturity
96+
}
97+
}
98+
99+
$matrixJson = @{ include = $matrixItems } | ConvertTo-Json -Depth 5 -Compress
100+
101+
return [PSCustomObject]@{
102+
MatrixJson = $matrixJson
103+
MatrixItems = $matrixItems
104+
Skipped = $skipped
105+
}
106+
}
107+
108+
#endregion
109+
110+
# Script guard: only execute CI output when run directly, not when dot-sourced
111+
if ($MyInvocation.InvocationName -ne '.') {
112+
$result = Find-CollectionManifestsCore -Channel $Channel -CollectionsDir $CollectionsDir
113+
114+
# Report skipped collections
115+
foreach ($skip in $result.Skipped) {
116+
Write-CIAnnotation -Message "Skipping $($skip.Name) ($($skip.Id)): $($skip.Reason)" -Level Notice
117+
}
118+
119+
Write-Host "Discovered collections:"
120+
$result.MatrixJson | ConvertFrom-Json | ConvertTo-Json -Depth 5
121+
122+
# Write CI output using injection-safe helpers
123+
Set-CIOutput -Name 'matrix' -Value $result.MatrixJson
124+
}

0 commit comments

Comments
 (0)