diff --git a/.github/scripts/Initialize-CI.ps1 b/.github/scripts/Initialize-CI.ps1 new file mode 100644 index 000000000..4c4b53f59 --- /dev/null +++ b/.github/scripts/Initialize-CI.ps1 @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [switch] $ForTest, + [switch] $UseAzAuth +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$modulePath = Join-Path $env:RUNNER_TEMP 'TempModules' +$null = New-Item $modulePath -ItemType Directory -Force +Save-Module -Name Microsoft.PowerShell.PSResourceGet -MinimumVersion 0.9.0 -Path $modulePath -Force +$env:PSModulePath = $modulePath + [IO.Path]::PathSeparator + $env:PSModulePath +"PSModulePath=$env:PSModulePath" >> $env:GITHUB_ENV + +if (-not $ForTest) { + return +} + +"TEMP=$env:RUNNER_TEMP" >> $env:GITHUB_ENV +Save-Module -Name Pester -RequiredVersion 4.10.1 -Path $modulePath -Force +if (-not $UseAzAuth) { + Save-Module -Name Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Path $modulePath -Force +} + +$headers = @{ + 'User-Agent' = 'PSResourceGet-CI' + Accept = 'application/vnd.github+json' + Authorization = "Bearer $env:GH_TOKEN" +} +$releases = Invoke-RestMethod 'https://api.github.com/repos/PowerShell/DSC/releases' -Headers $headers -MaximumRetryCount 3 +$release = $releases | Where-Object { -not $_.draft } | Sort-Object published_at -Descending | Select-Object -First 1 +$architecture = switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) { + 'X64' { 'x86_64' } + 'Arm64' { 'aarch64' } + default { throw "Unsupported DSC architecture: $_" } +} +$platform = if ($IsWindows) { 'pc-windows-msvc' } elseif ($IsMacOS) { 'apple-darwin' } else { 'linux' } +$extension = if ($IsWindows) { '.zip' } else { '.tar.gz' } +$assets = @($release.assets | Where-Object { $_.name.EndsWith("-$architecture-$platform$extension") }) +if ($assets.Count -ne 1) { + throw "Expected one DSC asset for $architecture-$platform in release $($release.tag_name); found $($assets.Count)." +} +$dscPath = Join-Path $env:RUNNER_TEMP 'dsc' +$null = New-Item $dscPath -ItemType Directory -Force +$archive = Join-Path $env:RUNNER_TEMP $assets[0].name +Invoke-WebRequest $assets[0].browser_download_url -OutFile $archive +if ($IsWindows) { + Expand-Archive $archive -DestinationPath $dscPath -Force +} +else { + & tar -xzf $archive -C $dscPath + if ($LASTEXITCODE -ne 0) { throw 'Failed to extract DSC.' } +} +$executableName = if ($IsWindows) { 'dsc.exe' } else { 'dsc' } +$executables = @(Get-ChildItem $dscPath -Recurse -File -Filter $executableName) +if ($executables.Count -ne 1) { + throw "Expected one DSC executable; found $($executables.Count)." +} +& $executables[0].FullName --version +if ($LASTEXITCODE -ne 0) { throw 'DSC could not start.' } +"DSC_ROOT=$($executables[0].DirectoryName)" >> $env:GITHUB_ENV + +if (-not $UseAzAuth) { + # Windows PSResourceGet discovers an .exe; Unix discovers the netcore .dll. + $assetName = if ($IsWindows) { 'Microsoft.NetFx48.NuGet.CredentialProvider.zip' } else { 'Microsoft.Net8.NuGet.CredentialProvider.tar.gz' } + $providerArchive = Join-Path $env:RUNNER_TEMP $assetName + $providerPath = Join-Path $env:RUNNER_TEMP 'credential-provider' + $null = New-Item $providerPath -ItemType Directory -Force + Invoke-WebRequest "https://github.com/microsoft/artifacts-credprovider/releases/download/v2.0.4/$assetName" -OutFile $providerArchive + if ($IsWindows) { + Expand-Archive $providerArchive -DestinationPath $providerPath -Force + } + else { + & tar -xzf $providerArchive -C $providerPath + if ($LASTEXITCODE -ne 0) { throw 'Failed to extract the credential provider.' } + } + $pluginsPath = Join-Path $HOME '.nuget/plugins' + $null = New-Item $pluginsPath -ItemType Directory -Force + Copy-Item (Join-Path $providerPath 'plugins/*') $pluginsPath -Recurse -Force + $providerFile = if ($IsWindows) { 'netfx/CredentialProvider.Microsoft/CredentialProvider.Microsoft.exe' } else { 'netcore/CredentialProvider.Microsoft/CredentialProvider.Microsoft.dll' } + if (-not (Test-Path (Join-Path $pluginsPath $providerFile))) { + throw 'The credential provider was not installed in the expected discovery location.' + } +} diff --git a/.github/scripts/Invoke-CITests.ps1 b/.github/scripts/Invoke-CITests.ps1 new file mode 100644 index 000000000..b9fbe36c0 --- /dev/null +++ b/.github/scripts/Invoke-CITests.ps1 @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('Full', 'AzAuth')] + [string] $Suite +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$config = Get-Content (Join-Path $root 'package.config.json') -Raw | ConvertFrom-Json +$testPath = Join-Path $root $config.TestPath +$modulePath = Join-Path (Join-Path $root $config.BuildOutputPath) $config.ModuleName +$acrFiles = @( + 'FindPSResourceContainerRegistryServer.Tests.ps1' + 'InstallPSResourceContainerRegistryServer.Tests.ps1' + 'PublishPSResourceContainerRegistryServer.Tests.ps1' +) +$files = @(Get-ChildItem $testPath -Recurse -Filter '*.Tests.ps1' -File | Where-Object { + $Suite -eq 'Full' -or $_.Name -in $acrFiles +}) +if ($files.Count -eq 0) { throw "No test files selected for suite $Suite." } + +$env:USINGAZAUTH = ($Suite -eq 'AzAuth').ToString().ToLowerInvariant() +$repositoryNamesFolder = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TempModules' +$null = New-Item $repositoryNamesFolder -ItemType Directory -Force +$null = New-Item (Join-Path $repositoryNamesFolder 'ACRTestRepositoryNames.txt') -ItemType File -Force + +if ($Suite -eq 'Full') { + foreach ($name in 'TENANTID', 'GITHUB_USERNAME', 'ADO_USERNAME', 'MAPPED_GITHUB_PAT', 'MAPPED_ADO_PUBLIC_PAT', 'MAPPED_ADO_PRIVATE_PAT', 'MAPPED_ADO_PRIVATE_REPO_URL') { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Missing $name. Configure the ci-integration environment as described in README.md." + } + } + Import-Module Microsoft.PowerShell.SecretManagement + Import-Module Microsoft.PowerShell.SecretStore + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false + Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault + + $acrToken = & az account get-access-token --resource 'https://management.azure.com/' --query accessToken --output tsv --only-show-errors + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($acrToken)) { + throw 'Could not acquire an ARM token for the ACR SecretStore tests.' + } + Write-Host "::add-mask::$acrToken" + Set-Secret -Name $env:TENANTID -Secret (ConvertTo-SecureString $acrToken -AsPlainText -Force) -Vault SecretStore + + # Azure DevOps needs its own token audience, not an ARM access token. + $adoToken = & az account get-access-token --resource '499b84ac-1321-427f-aa17-267ca6975798' --query accessToken --output tsv --only-show-errors + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($adoToken)) { + throw 'Could not acquire an Azure DevOps token for the credential provider tests.' + } + Write-Host "::add-mask::$adoToken" + $env:VSS_NUGET_EXTERNAL_FEED_ENDPOINTS = @{ + endpointCredentials = @(@{ + endpoint = 'https://pkgs.dev.azure.com/powershell-rel/PSResourceGet/_packaging/psrg-credprovidertest/nuget/v2' + username = 'ci' + password = $adoToken + }) + } | ConvertTo-Json -Compress -Depth 4 +} + +# Run in this fresh shell, never the process that imported the bootstrap module. +Import-Module Pester -RequiredVersion 4.10.1 -Force +Import-Module $modulePath -Force +Set-Location $testPath +$resultFile = Join-Path $testPath 'result.pester.xml' +try { + # Preserve the existing suite's handling of expected non-terminating errors. + $ErrorActionPreference = 'Continue' + $result = Invoke-Pester -Script $files.FullName -Tag CI -ExcludeTag ManualValidationOnly ` + -OutputFormat NUnitXml -OutputFile $resultFile -PassThru +} +finally { + $ErrorActionPreference = 'Stop' +} +if (-not (Test-Path $resultFile) -or $null -eq $result -or $result.TotalCount -eq 0) { + throw "Suite $Suite did not produce test results." +} +if ($result.FailedCount -gt 0) { + throw "$($result.FailedCount) Pester tests failed in suite $Suite." +} +# Expected native failures (for example DSC negative tests) must not fail the shell. +$global:LASTEXITCODE = 0 diff --git a/.github/scripts/Remove-CITestRepositories.ps1 b/.github/scripts/Remove-CITestRepositories.ps1 new file mode 100644 index 000000000..f2e89a874 --- /dev/null +++ b/.github/scripts/Remove-CITestRepositories.ps1 @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$ErrorActionPreference = 'Stop' +$repositoryNamesFolder = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'TempModules' +$repositoryNamesFile = Join-Path $repositoryNamesFolder 'ACRTestRepositoryNames.txt' +if (-not (Test-Path $repositoryNamesFile)) { + Write-Warning 'ACR tests did not initialize their cleanup file; no repositories will be deleted.' + return +} +$names = @(Get-Content $repositoryNamesFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) +if ($names.Count -eq 0) { + return +} +$repositories = & az acr repository list --name psresourcegettest --output json --only-show-errors +if ($LASTEXITCODE -ne 0) { throw 'Could not list ACR repositories for cleanup.' } +$repositories = $repositories | ConvertFrom-Json +foreach ($name in $names) { + # Only delete GUID-suffixed packages produced by the publish tests, never fixtures. + if ($name -notmatch '^temp-(testmodule|testmodulewithoutrequiredmodule-|testscript|testscriptwithexternaldeps|scriptwithoutemptylinesinmetadata|scriptwithoutemptylinesbetweencommentblocks)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw "Refusing to delete unexpected ACR repository name: $name" + } + if ($name -in $repositories) { + & az acr repository delete --name psresourcegettest --repository $name --yes --only-show-errors + if ($LASTEXITCODE -ne 0) { throw "Failed to delete test repository $name." } + } +} diff --git a/.github/scripts/tests/CI.Tests.ps1 b/.github/scripts/tests/CI.Tests.ps1 new file mode 100644 index 000000000..9f6eda60b --- /dev/null +++ b/.github/scripts/tests/CI.Tests.ps1 @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +$scriptsPath = Split-Path $PSScriptRoot -Parent +$root = Split-Path (Split-Path $scriptsPath -Parent) -Parent +$testFiles = @(Get-ChildItem (Join-Path $root 'test') -Recurse -Filter '*.Tests.ps1' -File) + +Describe 'CI workflow coverage' { + BeforeAll { + $workflow = Get-Content (Join-Path $root '.github/workflows/ci.yml') -Raw + $testWorkflow = Get-Content (Join-Path $root '.github/workflows/ci-tests.yml') -Raw + } + + It 'invokes the same test workflow without event or branch conditions' { + $workflow | Should -Match 'uses: \./\.github/workflows/ci-tests\.yml' + $workflow | Should -Not -Match '(?m)^\s+if:' + $workflow | Should -Not -Match '(?m)^concurrency:' + $testWorkflow | Should -Not -Match '(?m)^ if:' + } + + It 'runs the complete four-platform suite plus the Windows AzAuth suite' { + [regex]::Matches($testWorkflow, '(?m)^ - name:').Count | Should -Be 5 + [regex]::Matches($testWorkflow, '(?m)^ azAuth: false').Count | Should -Be 4 + [regex]::Matches($testWorkflow, '(?m)^ azAuth: true').Count | Should -Be 1 + $testWorkflow | Should -Match ([regex]::Escape('CI_SUITE: ${{ matrix.azAuth && ''AzAuth'' || ''Full'' }}')) + $testWorkflow | Should -Match '(?m)^ environment: ci-integration' + } +} + +Describe 'CI test execution' { + BeforeAll { + function az {} + function Set-SecretStoreConfiguration { param($Authentication, $Interaction, $Confirm) } + function Register-SecretVault { param($Name, $ModuleName, [switch] $DefaultVault) } + function Set-Secret { param($Name, $Secret, $Vault) } + $environmentNames = @( + 'TENANTID', 'GITHUB_USERNAME', 'ADO_USERNAME', 'MAPPED_GITHUB_PAT', + 'MAPPED_ADO_PUBLIC_PAT', 'MAPPED_ADO_PRIVATE_PAT', 'MAPPED_ADO_PRIVATE_REPO_URL', + 'USINGAZAUTH', 'VSS_NUGET_EXTERNAL_FEED_ENDPOINTS' + ) + $savedEnvironment = @{} + foreach ($name in $environmentNames) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + $savedExitCode = $global:LASTEXITCODE + $state = @{} + } + + BeforeEach { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, 'ci-helper-test-value') + } + $state.SelectedFiles = @() + $state.Result = [pscustomobject]@{ TotalCount = 1; FailedCount = 0 } + Mock Import-Module {} + Mock Set-Location {} + Mock New-Item {} + Mock Test-Path { $true } + Mock Get-ChildItem { $testFiles } + Mock Get-Content { '{"TestPath":"test","BuildOutputPath":"out","ModuleName":"Microsoft.PowerShell.PSResourceGet"}' } + Mock Invoke-Pester { + $state.SelectedFiles = @($Script) + $state.Result + } + Mock az { + $global:LASTEXITCODE = 0 + 'ci-helper-test-token' + } + Mock Set-SecretStoreConfiguration {} + Mock Register-SecretVault {} + Mock Set-Secret {} + } + + AfterAll { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name]) + } + $global:LASTEXITCODE = $savedExitCode + } + + It 'does not offer a reduced public suite' { + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Public } | Should -Throw + Assert-MockCalled Invoke-Pester -Times 0 -Exactly -Scope It + } + + It 'runs every file with the full authenticated suite' { + & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full + $state.SelectedFiles.Count | Should -Be $testFiles.Count + Assert-MockCalled Invoke-Pester -Times 1 -Exactly -Scope It -ParameterFilter { + $Tag -eq 'CI' -and $ExcludeTag -eq 'ManualValidationOnly' -and $OutputFormat -eq 'NUnitXml' -and $PassThru + } + Assert-MockCalled Set-Secret -Times 1 -Exactly -Scope It + Assert-MockCalled az -Times 2 -Exactly -Scope It + $endpoint = ($env:VSS_NUGET_EXTERNAL_FEED_ENDPOINTS | ConvertFrom-Json).endpointCredentials[0] + $endpoint.endpoint | Should -Be 'https://pkgs.dev.azure.com/powershell-rel/PSResourceGet/_packaging/psrg-credprovidertest/nuget/v2' + $endpoint.password | Should -Be 'ci-helper-test-token' + $env:USINGAZAUTH | Should -Be 'false' + } + + It 'runs just the three ACR files with AzAuth and no SecretStore' { + & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite AzAuth + $state.SelectedFiles.Count | Should -Be 3 + ($state.SelectedFiles -match 'ContainerRegistryServer').Count | Should -Be 3 + $env:USINGAZAUTH | Should -Be 'true' + Assert-MockCalled Set-Secret -Times 0 -Exactly -Scope It + Assert-MockCalled az -Times 0 -Exactly -Scope It + } + + It 'fails when a required credential is absent' { + $env:MAPPED_GITHUB_PAT = '' + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full } | Should -Throw 'Missing MAPPED_GITHUB_PAT' + Assert-MockCalled Invoke-Pester -Times 0 -Exactly -Scope It + } + + It 'fails when Azure token acquisition fails' { + Mock az { $global:LASTEXITCODE = 1 } + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full } | Should -Throw 'Could not acquire' + Assert-MockCalled Invoke-Pester -Times 0 -Exactly -Scope It + } + + It 'fails the job when a Pester test fails' { + $state.Result.FailedCount = 1 + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full } | Should -Throw '1 Pester tests failed' + } + + It 'fails the job if no tests ran' { + $state.Result.TotalCount = 0 + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full } | Should -Throw 'did not produce test results' + } + + It 'fails the job if the XML report is missing' { + Mock Test-Path { $false } + { & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full } | Should -Throw 'did not produce test results' + } + + It 'does not use an expected native failure as the successful suite exit code' { + Mock Invoke-Pester { + $global:LASTEXITCODE = 1 + $state.Result + } + & (Join-Path $scriptsPath 'Invoke-CITests.ps1') -Suite Full + $global:LASTEXITCODE | Should -Be 0 + } +} + +Describe 'CI ACR cleanup' { + BeforeAll { + function az {} + $savedExitCode = $global:LASTEXITCODE + $state = @{} + } + BeforeEach { + $state.PackageName = 'temp-testmodule12345678-1234-1234-1234-123456789abc' + Mock Test-Path { $true } + Mock Get-Content { $state.PackageName } + Mock az { + $global:LASTEXITCODE = 0 + ConvertTo-Json -InputObject @($state.PackageName, 'fixture-package') + } + } + AfterAll { + $global:LASTEXITCODE = $savedExitCode + } + + It 'deletes only a generated repository recorded by this job' { + & (Join-Path $scriptsPath 'Remove-CITestRepositories.ps1') + Assert-MockCalled az -Times 1 -Exactly -Scope It -ParameterFilter { + $args[2] -eq 'delete' -and $args[6] -eq $state.PackageName + } + } + + It 'refuses to delete fixture repositories' { + $state.PackageName = 'fixture-package' + { & (Join-Path $scriptsPath 'Remove-CITestRepositories.ps1') } | Should -Throw 'Refusing to delete' + Assert-MockCalled az -Times 0 -Exactly -Scope It -ParameterFilter { $args[2] -eq 'delete' } + } + + It 'does nothing when publishing never produced a repository' { + Mock Get-Content { @() } + & (Join-Path $scriptsPath 'Remove-CITestRepositories.ps1') + Assert-MockCalled az -Times 0 -Exactly -Scope It + } + + It 'does not delete a recorded package that was never published' { + Mock az { + $global:LASTEXITCODE = 0 + '["fixture-package"]' + } + & (Join-Path $scriptsPath 'Remove-CITestRepositories.ps1') + Assert-MockCalled az -Times 0 -Exactly -Scope It -ParameterFilter { $args[2] -eq 'delete' } + } + + It 'fails explicitly if cleanup cannot access ACR' { + Mock az { $global:LASTEXITCODE = 1 } + { & (Join-Path $scriptsPath 'Remove-CITestRepositories.ps1') } | Should -Throw 'Could not list ACR' + } +} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 000000000..a34e2564e --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,129 @@ +name: CI tests + +on: + workflow_call: + +jobs: + test: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + # Require environment review before granting PR code access to test credentials. + environment: ci-integration + strategy: + fail-fast: false + matrix: + include: + - name: Windows pwsh + os: windows-latest + shell: pwsh + azAuth: false + - name: Windows PowerShell + os: windows-latest + shell: powershell + azAuth: false + - name: Ubuntu pwsh + os: ubuntu-latest + shell: pwsh + azAuth: false + - name: macOS pwsh + os: macos-latest + shell: pwsh + azAuth: false + - name: Windows AzAuth + os: windows-latest + shell: pwsh + azAuth: true + defaults: + run: + shell: ${{ matrix.shell }} + env: + BUILD_SOURCESDIRECTORY: ${{ github.workspace }} + AZURE_CORE_OUTPUT: none + NUGET_CREDENTIALPROVIDER_SESSIONTOKENCACHE_ENABLED: 'false' + NUGET_CREDENTIALPROVIDER_MSAL_FILECACHE_ENABLED: 'false' + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + + - name: Download package from this run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: nupkg + path: artifacts/nupkg + + - name: Install test tools and DSC + shell: pwsh + run: | + .\.github\scripts\Initialize-CI.ps1 -ForTest ` + -UseAzAuth:([bool]::Parse($env:CI_AZ_AUTH)) + env: + CI_AZ_AUTH: ${{ matrix.azAuth }} + GH_TOKEN: ${{ github.token }} + + - name: Install built package + shell: pwsh + run: | + Import-Module .\buildtools.psd1 -Force + Install-ModulePackageForTest -PackagePath (Join-Path $PWD 'artifacts') -ErrorAction Stop + + - name: Check CI helpers + run: | + Import-Module Pester -RequiredVersion 4.10.1 -Force + $result = Invoke-Pester .\.github\scripts\tests -PassThru + if ($result.FailedCount -gt 0 -or $result.TotalCount -eq 0) { + throw 'CI helper tests failed.' + } + + - name: Validate Azure configuration + shell: pwsh + run: | + foreach ($name in 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID') { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Set environment variable $name in the GitHub ci-integration environment." + } + } + env: + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Sign in to Azure with OIDC + id: azure-login + uses: azure/login@8216e11d8cd9b42fe925c852af8e76311ff067ac # v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Execute functional tests + run: .\.github\scripts\Invoke-CITests.ps1 -Suite $env:CI_SUITE + env: + CI_SUITE: ${{ matrix.azAuth && 'AzAuth' || 'Full' }} + TENANTID: ${{ vars.AZURE_TENANT_ID }} + GITHUB_USERNAME: ${{ vars.CI_GITHUB_PACKAGES_USERNAME }} + ADO_USERNAME: ${{ vars.CI_ADO_USERNAME }} + MAPPED_ADO_PRIVATE_REPO_URL: ${{ vars.CI_ADO_PRIVATE_REPO_URL }} + MAPPED_GITHUB_PAT: ${{ !matrix.azAuth && secrets.CI_GITHUB_PACKAGES_PAT || '' }} + MAPPED_ADO_PUBLIC_PAT: ${{ !matrix.azAuth && secrets.CI_ADO_PUBLIC_PAT || '' }} + MAPPED_ADO_PRIVATE_PAT: ${{ !matrix.azAuth && secrets.CI_ADO_PRIVATE_PAT || '' }} + + - name: Delete test repositories from ACR + if: ${{ always() && steps.azure-login.outcome == 'success' }} + shell: pwsh + run: .\.github\scripts\Remove-CITestRepositories.ps1 + + - name: Upload Pester results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pester-${{ matrix.os }}-${{ matrix.shell }}-${{ matrix.azAuth }} + path: test/result.pester.xml + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..e1fbe7fa7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + DOTNET_NOLOGO: '1' + +jobs: + build: + name: Build package + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + + - name: Install build tools + run: .\.github\scripts\Initialize-CI.ps1 + + - name: Build and package module + run: | + .\build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework net472 + .\build.ps1 -Publish + $packagePath = Join-Path ([IO.Path]::GetTempPath()) 'packagebuild-local-repo' + $packages = @(Get-ChildItem $packagePath -Filter '*.nupkg') + if ($packages.Count -ne 1) { + throw "Expected exactly one built package; found $($packages.Count)." + } + "CI_PACKAGE_PATH=$($packages[0].FullName)" >> $env:GITHUB_ENV + + - name: Upload package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: nupkg + path: ${{ env.CI_PACKAGE_PATH }} + if-no-files-found: error + + - name: Upload module + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: Microsoft.PowerShell.PSResourceGet + path: out/Microsoft.PowerShell.PSResourceGet + if-no-files-found: error + + tests: + name: Tests + needs: build + permissions: + contents: read + id-token: write + uses: ./.github/workflows/ci-tests.yml diff --git a/README.md b/README.md index a0ca6c443..4e4c85a9e 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,88 @@ C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet c:\> PowerShell C:\> Import-Module C:\Repos\PSResourceGet\out\PSResourceGet\PSResourceGet.psd1 ``` + +### GitHub Actions CI + +`.github/workflows/ci.yml` builds and packages the module, then executes the existing +Pester 4 CI tests on Windows (PowerShell 7 and Windows PowerShell), Ubuntu, and +macOS. Every run (pull requests targeting `master`, pushes to `master`, and manual +runs on any branch) uses the same five-job matrix: the complete CI suite on all +four platforms plus the ACR-only suite with AzAuth on Windows. There is no +credential-free subset or event-based test exclusion. The existing `CI` tag and +`ManualValidationOnly` exclusion are preserved. Failed tests fail the job; NUnit +XML is retained as an artifact even on failure. Runs are not automatically +cancelled or replaced by newer runs. + +Before enabling authenticated runs: + +1. Create a GitHub environment named `ci-integration`. Set deployment branches and + tags to **No restriction** to support PR merge refs and manual runs on any + branch. Configure **required reviewers**, enable **Prevent self-review**, and + disable administrator bypass. Review the exact workflow, scripts, tests, and + source revision before approval: all checked-out code can access the test + credentials and Azure identity once the environment is approved. The previous + master-only restriction must be removed; `ci-public` is no longer used. +2. Configure a Microsoft Entra application/service principal with a GitHub OIDC + federated credential: issuer `https://token.actions.githubusercontent.com`, + subject `repo:PowerShell/PSResourceGet:environment:ci-integration`, audience + `api://AzureADTokenExchange`. No client secret is needed. +3. Add environment **variables** `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and + `AZURE_SUBSCRIPTION_ID` for that identity and subscription. Add + `CI_GITHUB_PACKAGES_USERNAME` (the GitHub PAT owner), `CI_ADO_USERNAME` (a nonempty + ADO credential username), and `CI_ADO_PRIVATE_REPO_URL` (the private test feed's + NuGet v3 index URL). +4. Add environment **secrets** `CI_GITHUB_PACKAGES_PAT`, `CI_ADO_PUBLIC_PAT`, and + `CI_ADO_PRIVATE_PAT`. Use a classic GitHub PAT with `read:packages` and access to + the PowerShell organization's `test_module` and `test_script` packages; authorize + organizational SSO if required. ADO PATs need Packaging Read & write and Feed + Publisher access to the corresponding test feeds. These replace the + `GithubTestingFeedCreds` variable group. Never put tokens in variables. +5. Grant the Azure identity registry-scoped `AcrPush` and `AcrDelete` on + `psresourcegettest`, and `AcrPull` on `psresourcegettestwildcard`, for registries in + legacy RBAC mode. For ABAC-enabled registries, use Container Registry Repository + Contributor on the main registry, Repository Reader on the wildcard registry, + and Repository Catalog Lister on both, with conditions allowing the fixture and + generated test repositories. Also grant registry-scoped `Reader` on + `psresourcegettest` for Azure CLI's management-plane registry lookup. + Ensure ARM-audience authentication is enabled. + Add the service principal to the `powershell-rel` Azure DevOps organization, + grant access to the `PSResourceGet` project, and grant Feed Reader on + `psrg-credprovidertest`. Azure RBAC alone does not grant Azure Artifacts access. +6. Enable GitHub Actions and allow the pinned `actions/checkout`, + `actions/setup-dotnet`, `actions/upload-artifact`, `actions/download-artifact`, + and `azure/login` actions. The workflow grants `id-token: write` only to + authenticated jobs; the default token otherwise only needs `contents: read`. + DSC downloads use the automatically supplied GitHub token, replacing the + `InstallDSC` variable group. No separate DSC token or Azure service connection + is needed. + +GitHub does not provide secrets or a writable OIDC token to fork pull-request +workflows (including Dependabot PRs); environment approval does not lift that +restriction. Those runs cannot complete authenticated tests and do not fall back +to a smaller suite. After reviewing the changes, a maintainer must create a branch +in this repository containing the reviewed revision and run CI there (a same-repo +PR or manual dispatch), then approve `ci-integration`. Never use +`pull_request_target` to check out and execute untrusted PR code with credentials. +The workflow fails when required credentials are unavailable rather than reporting +partial test coverage as success. + +The existing test feeds and ACR registries must retain their fixture packages and +be reachable from GitHub-hosted runners. The registry and public-feed URLs are +hard-coded in the tests; changing environment variables does not retarget them. +AzAuth uses the Azure CLI session established by `azure/login`. Other ACR tests +use a short-lived ARM token in the runner-local SecretStore; credential-provider +tests use a separate Azure DevOps-audience token. Neither token is printed or +uploaded. ACR cleanup runs even after test failures, but a terminated/timed-out +runner can leave generated repositories requiring manual cleanup. + +The Azure DevOps pipeline files are retained for transition and release consumers. +The external `PowerShell/compliance` stage is **not** ported by this test migration. +Keep the old compliance coverage until its owners approve a replacement. After +successful GitHub runs, replace Azure DevOps test branch-policy checks with the +five `Tests` checks and `Build package`. All five test jobs apply to PRs as well as +pushes and manual runs, and wait for the environment approval described above. + ## Module Support Lifecycle Microsoft.PowerShell.PSResourceGet follows the support lifecycle of the version of PowerShell that it ships in. For example, PSResourceGet 1.0.x shipped in PowerShell 7.4 which is an LTS release so it will be supported for 3 years. diff --git a/test/FindPSResourceTests/FindPSResourceGithubPackages.Tests.ps1 b/test/FindPSResourceTests/FindPSResourceGithubPackages.Tests.ps1 index 30512d7f7..d61414084 100644 --- a/test/FindPSResourceTests/FindPSResourceGithubPackages.Tests.ps1 +++ b/test/FindPSResourceTests/FindPSResourceGithubPackages.Tests.ps1 @@ -6,7 +6,6 @@ Import-Module $modPath -Force -Verbose Describe 'Test HTTP Find-PSResource for Github Packages Server' -tags 'CI' { - Get-ChildItem -Path env: | Out-String | Write-Verbose -Verbose BeforeAll{ $testModuleName = "test_module" $testScriptName = "test_script"