diff --git a/.azure-pipelines/ci-build.yml b/.azure-pipelines/ci-build.yml index 9f55a86f88..835d60c9fd 100644 --- a/.azure-pipelines/ci-build.yml +++ b/.azure-pipelines/ci-build.yml @@ -10,7 +10,7 @@ parameters: default: true - name: Pack type: boolean - default: false + default: true - name: Sign type: boolean default: true @@ -27,7 +27,9 @@ variables: BuildAgent: ${{ parameters.BuildAgent }} GitUserEmail: "GraphTooling@service.microsoft.com" GitUserName: "Microsoft Graph DevX Tooling" + ACR_SERVICE_CONNECTION: 'ACR Images Push Service Connection' REGISTRY: 'msgraphprodregistry.azurecr.io' + REGISTRY_NAME: 'msgraphprodregistry' IMAGE_NAME: 'public/microsoftgraph/powershell' trigger: @@ -74,6 +76,32 @@ extends: artifactName: 'drop' publishLocation: 'Container' steps: + - checkout: self + fetchDepth: 2 + - task: PowerShell@2 + name: DetectAcrChanges + displayName: Detect ACR publishing changes + inputs: + targetType: inline + pwsh: true + script: | + . $(System.DefaultWorkingDirectory)/tools/AcrPipelineHelpers.ps1 + $changedPaths = @(git diff --name-only HEAD^1 HEAD) + if ($LASTEXITCODE -ne 0) { + throw "Failed to determine changed paths with exit code $LASTEXITCODE." + } + $shouldPublish = Test-AcrPublishPath -Path $changedPaths + Write-Host "Changed paths:`n$($changedPaths -join "`n")" + Write-Host "##vso[task.setvariable variable=ShouldPublishToAcr;isOutput=true]$($shouldPublish.ToString().ToLowerInvariant())" + - task: PowerShell@2 + displayName: Set CI module prerelease version + inputs: + targetType: inline + pwsh: true + script: | + . $(System.DefaultWorkingDirectory)/tools/AcrPipelineHelpers.ps1 + $prerelease = Set-CiModulePrerelease -MetadataPath '$(System.DefaultWorkingDirectory)/config/ModuleMetadata.json' -BuildId '$(Build.BuildId)' + Write-Host "Building PowerShell packages with prerelease suffix '$prerelease'." - script: | git submodule update --init --recursive - template: .azure-pipelines/common-templates/install-tools.yml@self @@ -105,4 +133,23 @@ extends: FolderPath: "$(Build.ArtifactStagingDirectory)" Pattern: "Microsoft.Graph*.nupkg" - - template: .azure-pipelines/common-templates/security-post-checks.yml@self \ No newline at end of file + - template: .azure-pipelines/common-templates/security-post-checks.yml@self + - ${{ if and(eq(parameters.Pack, true), eq(parameters.Sign, true)) }}: + - stage: Deploy_to_ACR + displayName: Deploy PowerShell packages to ACR + dependsOn: stage + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(variables['Build.Reason'], 'PullRequest'), eq(dependencies.stage.outputs['MsGraphPsSdkCiBuild.DetectAcrChanges.ShouldPublishToAcr'], 'true')) + jobs: + - job: DeployPowerShellPackagesToAcr + displayName: Deploy PowerShell packages to ACR + templateContext: + inputs: + - input: pipelineArtifact + artifactName: drop + targetPath: '$(System.DefaultWorkingDirectory)/drop' + steps: + - template: .azure-pipelines/common-templates/publish-psresources-acr.yml@self + parameters: + AzureSubscription: $(ACR_SERVICE_CONNECTION) + Registry: $(REGISTRY) + RegistryName: $(REGISTRY_NAME) \ No newline at end of file diff --git a/.azure-pipelines/common-templates/publish-psresources-acr.yml b/.azure-pipelines/common-templates/publish-psresources-acr.yml new file mode 100644 index 0000000000..8635405333 --- /dev/null +++ b/.azure-pipelines/common-templates/publish-psresources-acr.yml @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +parameters: + - name: AzureSubscription + type: string + - name: Registry + type: string + - name: RegistryName + type: string + +steps: + - task: AzurePowerShell@5 + displayName: 'Publish PowerShell packages to ACR' + inputs: + azureSubscription: ${{ parameters.AzureSubscription }} + ScriptType: InlineScript + azurePowerShellVersion: LatestVersion + pwsh: true + Inline: | + $ErrorActionPreference = 'Stop' + $minimumPsResourceGetVersion = [version]'1.1.1' + $installedModule = Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet | + Where-Object Version -GE $minimumPsResourceGetVersion | + Sort-Object Version -Descending | + Select-Object -First 1 + + if ($null -eq $installedModule) { + Install-Module -Name Microsoft.PowerShell.PSResourceGet -MinimumVersion $minimumPsResourceGetVersion -Scope CurrentUser -Force -AllowClobber + } + Import-Module Microsoft.PowerShell.PSResourceGet -MinimumVersion $minimumPsResourceGetVersion -Force + + $repositoryName = '${{ parameters.RegistryName }}' + $existingRepository = Get-PSResourceRepository -Name $repositoryName -ErrorAction SilentlyContinue + if ($null -ne $existingRepository) { + Unregister-PSResourceRepository -Name $repositoryName + } + Register-PSResourceRepository -Name $repositoryName -Uri 'https://${{ parameters.Registry }}' + + Add-Type -AssemblyName System.IO.Compression.FileSystem + function Get-PackageMetadata { + param([Parameter(Mandatory)][string] $PackagePath) + + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $nuspecEntry = $archive.Entries | Where-Object FullName -Like '*.nuspec' | Select-Object -First 1 + if ($null -eq $nuspecEntry) { + throw "Package '$PackagePath' doesn't contain a nuspec file." + } + + $reader = [System.IO.StreamReader]::new($nuspecEntry.Open()) + try { + [xml] $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + return [pscustomobject]@{ + Id = [string]$nuspec.package.metadata.id + Version = [string]$nuspec.package.metadata.version + Path = $PackagePath + } + } + finally { + $archive.Dispose() + } + } + + $artifactPath = '$(System.DefaultWorkingDirectory)/drop' + $packages = @(Get-ChildItem -Path $artifactPath -Recurse -File -Filter 'Microsoft.Graph*.nupkg' | + ForEach-Object { Get-PackageMetadata -PackagePath $_.FullName }) + if ($packages.Count -eq 0) { + throw "No Microsoft Graph PowerShell packages were found under '$artifactPath'." + } + + $packages = $packages | Sort-Object @{ + Expression = { + if ($_.Id -eq 'Microsoft.Graph.Authentication') { 0 } + elseif ($_.Id -in @('Microsoft.Graph', 'Microsoft.Graph.Beta')) { 2 } + else { 1 } + } + }, Id + + foreach ($package in $packages) { + $existingPackage = Find-PSResource -Name $package.Id -Version $package.Version -Repository $repositoryName -ErrorAction SilentlyContinue + if ($null -ne $existingPackage) { + Write-Host "$($package.Id) $($package.Version) already exists in $repositoryName; continuing the idempotent deployment." + continue + } + + Write-Host "Publishing $($package.Id) $($package.Version) to $repositoryName." + Publish-PSResource -NupkgPath $package.Path -Repository $repositoryName -ErrorAction Stop + } + + foreach ($package in $packages) { + $foundPackage = $null + for ($attempt = 1; $attempt -le 6 -and $null -eq $foundPackage; $attempt++) { + $foundPackage = Find-PSResource -Name $package.Id -Version $package.Version -Repository $repositoryName -ErrorAction SilentlyContinue + if ($null -eq $foundPackage -and $attempt -lt 6) { + Start-Sleep -Seconds 10 + } + } + if ($null -eq $foundPackage) { + throw "Published package '$($package.Id)' version '$($package.Version)' couldn't be found in ACR." + } + } diff --git a/.azure-pipelines/sdk-release.yml b/.azure-pipelines/sdk-release.yml index 6b7e8a0b8b..4f8e382761 100644 --- a/.azure-pipelines/sdk-release.yml +++ b/.azure-pipelines/sdk-release.yml @@ -27,7 +27,9 @@ variables: BuildAgent: ${{ parameters.BuildAgent }} GitUserEmail: "GraphTooling@service.microsoft.com" GitUserName: "Microsoft Graph DevX Tooling" + ACR_SERVICE_CONNECTION: 'ACR Images Push Service Connection' REGISTRY: 'msgraphprodregistry.azurecr.io' + REGISTRY_NAME: 'msgraphprodregistry' IMAGE_NAME: 'public/microsoftgraph/powershell' PREVIEW_BRANCH: 'refs/heads/main' # Updated to target your branch @@ -158,6 +160,26 @@ extends: nuGetFeedType: external publishFeedCredentials: 'microsoftgraph PowerShell Gallery connection' + - ${{ if and(eq(parameters.Pack, true), eq(parameters.Sign, true)) }}: + - stage: Deploy_to_ACR + displayName: Deploy PowerShell packages to ACR + dependsOn: stage + condition: succeeded() + jobs: + - job: DeployPowerShellPackagesToAcr + displayName: Deploy PowerShell packages to ACR + templateContext: + inputs: + - input: pipelineArtifact + artifactName: drop + targetPath: '$(System.DefaultWorkingDirectory)/drop' + steps: + - template: .azure-pipelines/common-templates/publish-psresources-acr.yml@self + parameters: + AzureSubscription: $(ACR_SERVICE_CONNECTION) + Registry: $(REGISTRY) + RegistryName: $(REGISTRY_NAME) + - stage: PushDockerImageToRegistry condition: and(or(startsWith(variables['Build.SourceBranch'], 'refs/tags/v'), eq(variables['Build.SourceBranch'], variables['PREVIEW_BRANCH'])), not(contains(variables['Build.SourceBranch'], '-preview'))) dependsOn: stage diff --git a/tools/AcrPipelineHelpers.ps1 b/tools/AcrPipelineHelpers.ps1 new file mode 100644 index 0000000000..0141196c04 --- /dev/null +++ b/tools/AcrPipelineHelpers.ps1 @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +function Set-CiModulePrerelease { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MetadataPath, + + [Parameter(Mandatory)] + [ValidatePattern('^\d+$')] + [string] $BuildId + ) + + $metadata = Get-Content -Path $MetadataPath -Raw | ConvertFrom-Json -AsHashtable + $prerelease = "ci$BuildId" + + foreach ($versionMetadata in $metadata.versions.Values) { + $versionMetadata.prerelease = $prerelease + } + + $metadata | ConvertTo-Json -Depth 100 | Set-Content -Path $MetadataPath -Encoding utf8 + return $prerelease +} + +function Test-AcrPublishPath { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $Path + ) + + begin { + $isRelevant = $false + $patterns = @( + '^src/Authentication(?:/|$)', + '^config(?:/|$)', + '^autorest\.powershell(?:/|$)', + '^openApiDocs(?:/|$)' + ) + } + + process { + foreach ($item in $Path) { + $normalizedPath = $item.Replace('\', '/') + if ($patterns.Where({ $normalizedPath -match $_ }, 'First').Count -gt 0) { + $isRelevant = $true + } + } + } + + end { + return $isRelevant + } +} diff --git a/tools/Tests/AcrPipelineHelpers.Tests.ps1 b/tools/Tests/AcrPipelineHelpers.Tests.ps1 new file mode 100644 index 0000000000..49f6ec035e --- /dev/null +++ b/tools/Tests/AcrPipelineHelpers.Tests.ps1 @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeAll { + . (Join-Path $PSScriptRoot '..\AcrPipelineHelpers.ps1') +} + +Describe 'Set-CiModulePrerelease' { + It 'sets every module prerelease value to the build-specific suffix' { + $metadataPath = Join-Path $TestDrive 'ModuleMetadata.json' + @{ + versions = @{ + authentication = @{ version = '2.39.0'; prerelease = '' } + beta = @{ version = '2.39.0'; prerelease = '' } + 'v1.0' = @{ version = '2.39.0'; prerelease = '' } + } + } | ConvertTo-Json -Depth 10 | Set-Content -Path $metadataPath + + $prerelease = Set-CiModulePrerelease -MetadataPath $metadataPath -BuildId '12345' + $prerelease | Should -Be 'ci12345' + + $metadata = Get-Content -Path $metadataPath -Raw | ConvertFrom-Json + $metadata.versions.authentication.prerelease | Should -Be 'ci12345' + $metadata.versions.beta.prerelease | Should -Be 'ci12345' + $metadata.versions.'v1.0'.prerelease | Should -Be 'ci12345' + + $manifestPath = Join-Path $TestDrive 'TestModule.psd1' + Set-Content -Path (Join-Path $TestDrive 'TestModule.psm1') -Value '' + New-ModuleManifest -Path $manifestPath -RootModule 'TestModule.psm1' -ModuleVersion '1.0.0' + { Update-ModuleManifest -Path $manifestPath -Prerelease $prerelease } | Should -Not -Throw + } +} + +Describe 'Test-AcrPublishPath' { + It 'matches relevant source and generation inputs' -ForEach @( + 'src/Authentication/Authentication/Microsoft.Graph.Authentication.psd1' + 'SRC\AUTHENTICATION\Authentication.Core\Authentication.cs' + 'config/ModuleMetadata.json' + 'autorest.powershell/packages/autorest.powershell/package.json' + 'openApiDocs/v1.0/Users.yml' + ) { + Test-AcrPublishPath -Path $_ | Should -BeTrue + } + + It 'does not match unrelated paths' -ForEach @( + 'docs/readme.md' + 'samples/1-Users.ps1' + 'src/Users/v1.0/readme.md' + '.azure-pipelines/ci-build.yml' + ) { + Test-AcrPublishPath -Path $_ | Should -BeFalse + } + + It 'returns true when any changed path is relevant' { + Test-AcrPublishPath -Path @('docs/readme.md', 'config/ModulesMapping.jsonc') | Should -BeTrue + } +}