diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..faa2511 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install test dependencies + shell: pwsh + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + + - name: Analyze controller + shell: pwsh + run: Invoke-ScriptAnalyzer dependency-updates -Recurse -Severity Warning,Error -EnableExit + + - name: Test controller + shell: pwsh + run: Invoke-Pester dependency-updates/Tests -CI diff --git a/.github/workflows/update-powershell-dependencies.yml b/.github/workflows/update-powershell-dependencies.yml new file mode 100644 index 0000000..6bde44f --- /dev/null +++ b/.github/workflows/update-powershell-dependencies.yml @@ -0,0 +1,122 @@ +name: PowerShell dependency updates + +on: + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + inputs: + repository: + description: Repository name from dependency-updates/targets.json, or all + required: true + default: all + type: string + allow_major_version_upgrade: + description: Allow dependencies to cross major-version boundaries + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: powershell-dependency-updates + cancel-in-progress: false + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.targets.outputs.targets }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - id: targets + name: Select repositories + shell: pwsh + env: + REQUESTED_REPOSITORY: ${{ inputs.repository }} + run: | + $targets = @(Get-Content dependency-updates/targets.json -Raw | ConvertFrom-Json) + $requestedRepository = $env:REQUESTED_REPOSITORY + if ([string]::IsNullOrWhiteSpace($requestedRepository)) { + $requestedRepository = 'all' + } + + if ($requestedRepository -ne 'all') { + $targets = @($targets | Where-Object repository -EQ $requestedRepository) + } + + if ($targets.Count -eq 0) { + throw "Repository '$requestedRepository' is not configured in dependency-updates/targets.json." + } + + $matrix = ConvertTo-Json -InputObject @($targets) -Compress + "targets=$matrix" >> $env:GITHUB_OUTPUT + + update: + needs: plan + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON(needs.plan.outputs.targets) }} + steps: + - name: Create repository token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.DEPENDENCY_UPDATE_APP_ID }} + private-key: ${{ secrets.DEPENDENCY_UPDATE_APP_PRIVATE_KEY }} + owner: AtlassianPS + repositories: ${{ matrix.target.repository }} + permission-contents: write + permission-issues: write + permission-pull-requests: write + permission-workflows: write + + - name: Check out controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: controller + + - name: Check out target repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: AtlassianPS/${{ matrix.target.repository }} + token: ${{ steps.app-token.outputs.token }} + path: target + fetch-depth: 0 + persist-credentials: false + + - name: Update dependencies + shell: pwsh + env: + ALLOW_MAJOR_VERSION_UPGRADE: ${{ inputs.allow_major_version_upgrade }} + GITHUB_TOKEN: ${{ github.token }} + run: | + $parameters = @{ + RepositoryPath = './target' + ModuleName = '${{ matrix.target.module }}' + } + if ($env:ALLOW_MAJOR_VERSION_UPGRADE -eq 'true') { + $parameters.AllowMajorVersionUpgrade = $true + } + + ./controller/dependency-updates/Update-RepositoryDependencies.ps1 @parameters + + - name: Create or refresh pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + path: target + branch: automation/update-powershell-dependencies + delete-branch: true + commit-message: 'chore(deps): update PowerShell dependencies' + title: 'chore(deps): update PowerShell dependencies' + body: | + Updates PowerShell dependencies and synchronized AtlassianPS.Standards references. + + Generated by the centralized dependency workflow in `AtlassianPS/.github`. + labels: ${{ matrix.target.labels }} diff --git a/dependency-updates/README.md b/dependency-updates/README.md new file mode 100644 index 0000000..6d0feef --- /dev/null +++ b/dependency-updates/README.md @@ -0,0 +1,32 @@ +# Central dependency updates + +The organization `.github` repository owns dependency-update orchestration. +Target repositories contain dependency declarations and consistency tests, but no updater script. + +The scheduled workflow reads `targets.json`, checks out each repository, and invokes the update +engine published by `AtlassianPS.Standards`. +It creates or refreshes `automation/update-powershell-dependencies` when files change. +The controller and workflow contract are validated by the repository's `CI` workflow. + +## GitHub App + +Configure an organization GitHub App with access only to the repositories in `targets.json`. +It needs these repository permissions: + +- Contents: read and write +- Issues: read and write +- Pull requests: read and write +- Workflows: read and write + +Expose its credentials to this repository as organization secrets: + +- `DEPENDENCY_UPDATE_APP_ID` +- `DEPENDENCY_UPDATE_APP_PRIVATE_KEY` + +The workflow requests a short-lived token for one target repository at a time. + +## Manual runs + +Run **PowerShell dependency updates** from the Actions page. +Use `all` to process every configured repository or provide one repository name from `targets.json`. +Scheduled runs preserve dependency major versions; manual runs can explicitly allow major upgrades. diff --git a/dependency-updates/Tests/Update-RepositoryDependencies.Tests.ps1 b/dependency-updates/Tests/Update-RepositoryDependencies.Tests.ps1 new file mode 100644 index 0000000..9f69544 --- /dev/null +++ b/dependency-updates/Tests/Update-RepositoryDependencies.Tests.ps1 @@ -0,0 +1,121 @@ +#requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.7'; MaximumVersion = '5.999' } + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '../Update-RepositoryDependencies.ps1' + . $scriptPath -RepositoryPath $TestDrive -ModuleName TestModule + + function New-TestRepository { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This helper only creates isolated Pester TestDrive fixtures.' + )] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [String]$Path + ) + + $null = New-Item -ItemType Directory -Path (Join-Path $Path 'Tools') -Force + $null = New-Item -ItemType Directory -Path (Join-Path $Path 'TestModule') -Force + $null = New-Item -ItemType Directory -Path (Join-Path $Path '.github/workflows') -Force + + @" +@( + @{ ModuleName = 'AtlassianPS.Standards'; RequiredVersion = '0.3.1'; MaximumVersion = '0.3.1' } +) +"@ | Set-Content -LiteralPath (Join-Path $Path 'Tools/build.requirements.psd1') + "@{ RequiredModules = @() }" | + Set-Content -LiteralPath (Join-Path $Path 'TestModule/TestModule.psd1') + } +} + +Describe 'Update-RepositoryDependencies' { + It 'fails before mutation when required dependency files are missing' { + $repositoryPath = Join-Path $TestDrive 'missing-files' + $null = New-Item -ItemType Directory -Path $repositoryPath + + { Invoke-RepositoryDependencyUpdate -RepositoryPath $repositoryPath -ModuleName TestModule -StandardsVersion '0.3.1' } | + Should -Throw '*Required dependency file was not found*' + } + + It 'does not initialize dependency tooling under WhatIf' { + $repositoryPath = Join-Path $TestDrive 'what-if' + New-TestRepository -Path $repositoryPath + Mock Get-PSRepository { throw 'Should not be called' } + + Invoke-RepositoryDependencyUpdate ` + -RepositoryPath $repositoryPath ` + -ModuleName TestModule ` + -StandardsVersion '0.3.1' ` + -WhatIf + + Should -Invoke Get-PSRepository -Times 0 + } + + It 'uses the default Standards version from the script entry point' { + $repositoryPath = Join-Path $TestDrive 'entry-point' + New-TestRepository -Path $repositoryPath + + { + & $scriptPath -RepositoryPath $repositoryPath -ModuleName TestModule -WhatIf + } | Should -Not -Throw + } + + It 'passes the declared files and major-upgrade choice to Standards' { + $repositoryPath = Join-Path $TestDrive 'delegation' + New-TestRepository -Path $repositoryPath + Set-Content ` + -LiteralPath (Join-Path $repositoryPath '.github/workflows/ci.yml') ` + -Value 'uses: AtlassianPS/AtlassianPS.Standards/.github/workflows/module_ci.yml@0000000000000000000000000000000000000000 # v0.3.0' + + Mock Get-PSRepository { [PSCustomObject]@{ InstallationPolicy = 'Trusted' } } + Mock Get-Module { [PSCustomObject]@{ Version = [Version]'0.3.1' } } + Mock Import-Module + Mock Invoke-StandardsDependencyUpdate { [PSCustomObject]@{ Changed = $true } } + Mock Invoke-RestMethod { [PSCustomObject]@{ sha = 'a' * 40 } } + + $result = Invoke-RepositoryDependencyUpdate ` + -RepositoryPath $repositoryPath ` + -ModuleName TestModule ` + -StandardsVersion '0.3.1' ` + -AllowMajorVersionUpgrade + + $result.Changed | Should -BeTrue + Should -Invoke Invoke-StandardsDependencyUpdate -Times 1 -ParameterFilter { + $Parameters.BuildRequirementsPath -eq (Join-Path $repositoryPath 'Tools/build.requirements.psd1') -and + $Parameters.ManifestPath -eq (Join-Path $repositoryPath 'TestModule/TestModule.psd1') -and + $Parameters.AllowMajorVersionUpgrade + } + } + + It 'updates yml and yaml references while preserving encoding and newlines' { + $repositoryPath = Join-Path $TestDrive 'workflow-files' + New-TestRepository -Path $repositoryPath + $workflowRoot = Join-Path $repositoryPath '.github/workflows' + $oldReference = 'uses: AtlassianPS/AtlassianPS.Standards/.github/workflows/module_ci.yml@0000000000000000000000000000000000000000 # v0.3.0' + $newSha = 'b' * 40 + + $bomPath = Join-Path $workflowRoot 'ci.yml' + $plainPath = Join-Path $workflowRoot 'release.yaml' + [System.IO.File]::WriteAllText($bomPath, "$oldReference`r`n", [System.Text.UTF8Encoding]::new($true)) + [System.IO.File]::WriteAllText($plainPath, "$oldReference`n", [System.Text.UTF8Encoding]::new($false)) + Mock Invoke-RestMethod { [PSCustomObject]@{ sha = $newSha } } + + Sync-StandardsWorkflowReference -ProjectRoot $repositoryPath -Version '0.3.1' + + $bomBytes = [System.IO.File]::ReadAllBytes($bomPath) + $bomBytes[0..2] | Should -Be @(0xEF, 0xBB, 0xBF) + [System.Text.Encoding]::UTF8.GetString($bomBytes) | Should -Match "# v0\.3\.1`r`n$" + (Get-Content -LiteralPath $plainPath -Raw) | Should -Match "# v0\.3\.1`n$" + [System.IO.File]::ReadAllBytes($plainPath)[0] | Should -Not -Be 0xEF + } + + It 'fails when GitHub does not return a commit SHA' { + $repositoryPath = Join-Path $TestDrive 'invalid-sha' + New-TestRepository -Path $repositoryPath + Mock Invoke-RestMethod { [PSCustomObject]@{ sha = 'invalid' } } + + { Sync-StandardsWorkflowReference -ProjectRoot $repositoryPath -Version '0.3.1' } | + Should -Throw '*valid commit*' + } +} diff --git a/dependency-updates/Update-RepositoryDependencies.ps1 b/dependency-updates/Update-RepositoryDependencies.ps1 new file mode 100644 index 0000000..a861be8 --- /dev/null +++ b/dependency-updates/Update-RepositoryDependencies.ps1 @@ -0,0 +1,204 @@ +#requires -Version 7.2 + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [String]$RepositoryPath, + + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9.-]+$')] + [String]$ModuleName, + + [Parameter()] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [String]$StandardsVersion = '0.3.1', + + [Parameter()] + [Switch]$AllowMajorVersionUpgrade +) + +$ErrorActionPreference = 'Stop' + +function Get-FileTextState { + param( + [Parameter(Mandatory)] + [String]$Path + ) + + $resolvedPath = (Resolve-Path -LiteralPath $Path).ProviderPath + $bytes = [System.IO.File]::ReadAllBytes($resolvedPath) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { + $text = $text.Substring(1) + } + + [PSCustomObject]@{ + Path = $resolvedPath + Text = $text + HasBom = $hasBom + NewLine = if ($text -match "`r`n") { "`r`n" } else { "`n" } + } +} + +function Set-FileTextState { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'The parent script gates all writes with ShouldProcess.' + )] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [PSCustomObject]$State, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [String]$Text + ) + + $normalizedText = $Text -replace "`r?`n", $State.NewLine + if (-not $normalizedText.EndsWith($State.NewLine)) { + $normalizedText += $State.NewLine + } + + $encoding = [System.Text.UTF8Encoding]::new($State.HasBom) + [System.IO.File]::WriteAllText($State.Path, $normalizedText, $encoding) +} + +function Sync-StandardsWorkflowReference { + param( + [Parameter(Mandatory)] + [String]$ProjectRoot, + + [Parameter(Mandatory)] + [String]$Version + ) + + $headers = @{ Accept = 'application/vnd.github+json' } + if ($env:GITHUB_TOKEN) { + $headers.Authorization = "Bearer $env:GITHUB_TOKEN" + } + + $release = Invoke-RestMethod ` + -Uri "https://api.github.com/repos/AtlassianPS/AtlassianPS.Standards/commits/v$Version" ` + -Headers $headers ` + -ErrorAction Stop + $commit = [String]$release.sha + if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "GitHub did not return a valid commit for AtlassianPS.Standards v$Version." + } + + $workflowRoot = Join-Path $ProjectRoot '.github/workflows' + $workflows = Get-ChildItem -LiteralPath $workflowRoot -File -ErrorAction SilentlyContinue | + Where-Object Extension -In @('.yml', '.yaml') + foreach ($workflow in $workflows) { + $state = Get-FileTextState -Path $workflow.FullName + $updatedText = [regex]::Replace( + $state.Text, + '(?AtlassianPS/AtlassianPS\.Standards/\.github/(?:actions/[^@\s]+|workflows/(?:module_ci|module_release)\.yml)@)[0-9a-f]{40}(?\s+#\s+v)\d+\.\d+\.\d+', + ('${prefix}' + $commit + '${suffix}' + $Version) + ) + + if ($updatedText -ne $state.Text) { + Set-FileTextState -State $state -Text $updatedText + } + } +} + +function Invoke-StandardsDependencyUpdate { + param( + [Parameter(Mandatory)] + [Hashtable]$Parameters + ) + + AtlassianPS.Standards\Update-AtlassianPSDependencyReference @Parameters +} + +function Invoke-RepositoryDependencyUpdate { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [String]$RepositoryPath, + + [Parameter(Mandatory)] + [String]$ModuleName, + + [Parameter(Mandatory)] + [String]$StandardsVersion, + + [Parameter()] + [Switch]$AllowMajorVersionUpgrade + ) + + $resolvedRepositoryPath = (Resolve-Path -LiteralPath $RepositoryPath).ProviderPath + $buildRequirementsPath = Join-Path $resolvedRepositoryPath 'Tools/build.requirements.psd1' + $manifestPath = Join-Path $resolvedRepositoryPath "$ModuleName/$ModuleName.psd1" + + foreach ($requiredPath in @($buildRequirementsPath, $manifestPath)) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "Required dependency file was not found: '$requiredPath'." + } + } + + if (-not $PSCmdlet.ShouldProcess($resolvedRepositoryPath, 'Update PowerShell dependency references')) { + return + } + + $gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if (-not $gallery) { + Register-PSRepository -Default -ErrorAction Stop + $gallery = Get-PSRepository -Name PSGallery -ErrorAction Stop + } + + if ($gallery.InstallationPolicy -ne 'Trusted') { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop + } + + $installedStandards = Get-Module -ListAvailable -Name AtlassianPS.Standards | + Where-Object Version -EQ ([Version]$StandardsVersion) | + Select-Object -First 1 + + if (-not $installedStandards) { + Install-Module ` + -Name AtlassianPS.Standards ` + -RequiredVersion $StandardsVersion ` + -Repository PSGallery ` + -Scope CurrentUser ` + -AllowClobber ` + -Force ` + -ErrorAction Stop + } + + Import-Module AtlassianPS.Standards -RequiredVersion $StandardsVersion -Force -ErrorAction Stop + + $updateParameters = @{ + BuildRequirementsPath = $buildRequirementsPath + ManifestPath = $manifestPath + AllowMajorVersionUpgrade = $AllowMajorVersionUpgrade + ErrorAction = 'Stop' + } + + $result = Invoke-StandardsDependencyUpdate -Parameters $updateParameters + + $requirements = Import-PowerShellDataFile -LiteralPath $buildRequirementsPath + $standardsRequirement = $requirements | + Where-Object ModuleName -EQ 'AtlassianPS.Standards' | + Select-Object -First 1 + $updatedStandardsVersion = [String]$standardsRequirement.RequiredVersion + if (-not $updatedStandardsVersion) { + throw "AtlassianPS.Standards is missing from '$buildRequirementsPath'." + } + + Sync-StandardsWorkflowReference ` + -ProjectRoot $resolvedRepositoryPath ` + -Version $updatedStandardsVersion + + $result +} + +if ($MyInvocation.InvocationName -ne '.') { + $invokeParameters = @{} + $PSBoundParameters + $invokeParameters.StandardsVersion = $StandardsVersion + Invoke-RepositoryDependencyUpdate @invokeParameters +} diff --git a/dependency-updates/targets.json b/dependency-updates/targets.json new file mode 100644 index 0000000..eb21310 --- /dev/null +++ b/dependency-updates/targets.json @@ -0,0 +1,22 @@ +[ + { + "repository": "AtlassianPS.Configuration", + "module": "AtlassianPS.Configuration", + "labels": "dependencies,release:none" + }, + { + "repository": "ConfluencePS", + "module": "ConfluencePS", + "labels": "dependencies,release:none" + }, + { + "repository": "JiraAgilePS", + "module": "JiraAgilePS", + "labels": "dependencies,release:none" + }, + { + "repository": "JiraPS", + "module": "JiraPS", + "labels": "dependencies" + } +]