From 24bdc5ed93a994c455dbf0a96676fa691fb560a8 Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 20:53:53 +0200 Subject: [PATCH 1/8] fix(cd): harden release recovery Make post-merge releases recoverable from tagged CI-tested commits. Validate final packages before irreversible publish and close CI trigger, provenance, and action-pinning gaps. --- .github/actions/create-release-tag/action.yml | 17 ++- .../plan-merged-release.ps1 | 22 ++-- .github/actions/setup-powershell/action.yml | 2 +- .github/workflows/ci.yml | 40 ++---- .github/workflows/continuous_release.yml | 114 +++++++++++++----- .github/workflows/release_intent.yml | 2 +- AGENTS.md | 2 +- AtlassianPS.Standards.build.ps1 | 22 +++- .../Public/Test-ModulePackage.ps1 | 43 +++++-- CODEOWNERS | 10 +- README.md | 4 +- STATUS.md | 51 ++++++++ .../Public/BlueprintHelpers.Unit.Tests.ps1 | 50 +++++++- Tests/GitHubActions.Tests.ps1 | 66 ++++++++-- docs/BlueprintHelpers.md | 4 +- docs/ReleaseBlueprint.md | 16 +-- 16 files changed, 347 insertions(+), 118 deletions(-) create mode 100644 STATUS.md diff --git a/.github/actions/create-release-tag/action.yml b/.github/actions/create-release-tag/action.yml index 482a7be..efdcf55 100644 --- a/.github/actions/create-release-tag/action.yml +++ b/.github/actions/create-release-tag/action.yml @@ -1,9 +1,13 @@ name: Create Release Tag -description: Create and push an annotated release tag, skipping if it already exists. +description: Create and push an annotated release tag, or verify an existing tag identifies expected commit. inputs: tag: description: Annotated tag to create, for example v1.2.3. required: true + commit: + description: Commit the tag must identify. Defaults to current checkout commit. + required: false + default: HEAD runs: using: composite steps: @@ -11,15 +15,22 @@ runs: shell: bash env: RELEASE_TAG: ${{ inputs.tag }} + RELEASE_COMMIT: ${{ inputs.commit }} run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + expected_commit="$(git rev-parse "${RELEASE_COMMIT}^{commit}")" if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then - echo "Release tag ${RELEASE_TAG} already exists." + tag_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")" + if [ "${tag_commit}" != "${expected_commit}" ]; then + echo "::error::Release tag ${RELEASE_TAG} identifies ${tag_commit}, not expected ${expected_commit}." + exit 1 + fi + echo "Release tag ${RELEASE_TAG} already identifies expected commit ${expected_commit}." else - git tag -a "${RELEASE_TAG}" -m "${RELEASE_TAG}" + git tag -a "${RELEASE_TAG}" "${expected_commit}" -m "${RELEASE_TAG}" git push origin "refs/tags/${RELEASE_TAG}" fi diff --git a/.github/actions/plan-merged-release/plan-merged-release.ps1 b/.github/actions/plan-merged-release/plan-merged-release.ps1 index 7aef313..fbdd0fc 100644 --- a/.github/actions/plan-merged-release/plan-merged-release.ps1 +++ b/.github/actions/plan-merged-release/plan-merged-release.ps1 @@ -49,17 +49,17 @@ else { } $commitPullsRoute = 'repos/{0}/commits/{1}/pulls' -f $env:GITHUB_REPOSITORY, $env:COMMIT_SHA - $pullRequestJson = gh api $commitPullsRoute --jq '.[] | select(.merged_at != null) | { number, title, user: .user.login }' | - Select-Object -First 1 - - if (-not $pullRequestJson) { - Write-OutputValue -Name should_release -Value 'false' - Write-OutputValue -Name skip_reason -Value 'no associated merged pull request' - Write-Host "Skipping release: no associated merged pull request for commit '$env:COMMIT_SHA'." - return - } - - $pullRequest = $pullRequestJson | ConvertFrom-Json + $pullRequests = @( + gh api $commitPullsRoute | ConvertFrom-Json | Where-Object { + $_.merged_at -and $_.merge_commit_sha -eq $env:COMMIT_SHA + } + ) + + if ($pullRequests.Count -ne 1) { + throw "Expected exactly one merged pull request with merge commit '$env:COMMIT_SHA'; found $($pullRequests.Count)." + } + + $pullRequest = $pullRequests[0] $prNumber = [String]$pullRequest.number $prTitle = [String]$pullRequest.title $prAuthor = [String]$pullRequest.user diff --git a/.github/actions/setup-powershell/action.yml b/.github/actions/setup-powershell/action.yml index 7c89826..2a9550f 100644 --- a/.github/actions/setup-powershell/action.yml +++ b/.github/actions/setup-powershell/action.yml @@ -16,7 +16,7 @@ runs: using: composite steps: - name: Cache PowerShell modules - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | ~/.local/share/powershell/Modules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d47690b..614ded3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,35 +7,19 @@ concurrency: on: pull_request: branches: [master] - paths-ignore: &paths-ignore - - "CHANGELOG.md" - - "LICENSE" - # AI assistant instruction files - - "AGENTS.md" - - "CLAUDE.md" - - "GEMINI.md" - - ".cursor/**" - - ".github/copilot-instructions.md" - - ".github/ai-context/**" - - ".github/instructions/**" - # GitHub/editor meta files - - ".github/*.md" - - ".github/ISSUE_TEMPLATE/**" - - ".github/PULL_REQUEST_TEMPLATE/**" - - ".vscode/**" - - ".editorconfig" - - ".spelling" push: branches: [master] - paths-ignore: *paths-ignore workflow_dispatch: +permissions: + contents: read + jobs: lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-powershell - run: Invoke-Build -Task Lint @@ -46,14 +30,14 @@ jobs: needs: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-powershell - run: Invoke-Build -File ./AtlassianPS.Standards.build.ps1 -Task Build shell: pwsh - name: Upload release artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: Release path: ./Release/ @@ -63,7 +47,7 @@ jobs: needs: build runs-on: windows-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-powershell with: ps-version: "5" @@ -71,7 +55,7 @@ jobs: # so the Windows PowerShell module path is populated. skip-setup: "true" - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: Release path: ./Release/ @@ -85,7 +69,7 @@ jobs: - run: Invoke-Build -Task Test shell: powershell - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: Windows-PS5-Unit-Tests @@ -103,10 +87,10 @@ jobs: - { os: ubuntu-latest, name: "Ubuntu" } - { os: macos-latest, name: "macOS" } steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: ./.github/actions/setup-powershell - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: Release path: ./Release/ @@ -114,7 +98,7 @@ jobs: - run: Invoke-Build -Task Test shell: pwsh - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: ${{ matrix.name }}-Unit-Tests diff --git a/.github/workflows/continuous_release.yml b/.github/workflows/continuous_release.yml index 1d00c58..483264c 100644 --- a/.github/workflows/continuous_release.yml +++ b/.github/workflows/continuous_release.yml @@ -8,7 +8,7 @@ on: inputs: release_impact: description: Release impact for unreleased changes already on master - required: true + required: false type: choice options: - patch @@ -18,6 +18,10 @@ on: description: Optional prerelease label for unreleased changes already on master, for example alpha, beta, rc, or rc-2 required: false type: string + recovery_tag: + description: Existing release tag to recover after a partial publish, for example v1.2.3 + required: false + type: string concurrency: group: continuous-release @@ -32,13 +36,13 @@ jobs: prepare: name: Prepare release metadata if: >- - github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_dispatch' && inputs.recovery_tag == '') || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'master' && !startsWith(github.event.workflow_run.head_commit.message, 'Prepare v')) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && 'master' || github.event.workflow_run.head_sha }} @@ -84,84 +88,136 @@ jobs: publish: name: Publish tested release artifact if: >- - github.event_name == 'workflow_run' && + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'master' && startsWith(github.event.workflow_run.head_commit.message, 'Prepare v') && - github.event.workflow_run.head_commit.author.name == 'github-actions[bot]' + github.event.workflow_run.head_commit.author.name == 'github-actions[bot]') || + (github.event_name == 'workflow_dispatch' && inputs.recovery_tag != '') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || inputs.recovery_tag }} - name: Resolve prepared release id: prepared_release shell: pwsh + env: + RECOVERY_TAG: ${{ inputs.recovery_tag }} run: | + if ($env:RECOVERY_TAG) { + if ($env:RECOVERY_TAG -notmatch '^v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:-\d+)?)?$') { + throw "Recovery tag '$env:RECOVERY_TAG' is not a supported release tag." + } + $tagCommit = git rev-parse "$env:RECOVERY_TAG^{commit}" + git merge-base --is-ancestor $tagCommit origin/master + if ($LASTEXITCODE -ne 0) { + throw "Recovery tag '$env:RECOVERY_TAG' does not identify a commit reachable from master." + } + "release_tag=$env:RECOVERY_TAG" >> $env:GITHUB_OUTPUT + "release_commit=$tagCommit" >> $env:GITHUB_OUTPUT + return + } + $message = '${{ github.event.workflow_run.head_commit.message }}' if ($message -notmatch '^Prepare (?v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:-\d+)?)?) release$') { - throw "Commit message '$message' is not a release metadata commit." + throw "Commit message '$message' is not a release metadata commit." } "release_tag=$($Matches.tag)" >> $env:GITHUB_OUTPUT + "release_commit=${{ github.event.workflow_run.head_sha }}" >> $env:GITHUB_OUTPUT + + - name: Resolve tested CI artifact + id: ci_run + shell: pwsh + env: + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + RELEASE_COMMIT: ${{ steps.prepared_release.outputs.release_commit }} + run: | + if ($env:WORKFLOW_RUN_ID) { + "run_id=$env:WORKFLOW_RUN_ID" >> $env:GITHUB_OUTPUT + return + } + + $runId = gh api "repos/$env:GITHUB_REPOSITORY/actions/runs?head_sha=$env:RELEASE_COMMIT&status=completed&per_page=100" --jq '.workflow_runs[] | select(.name == "CI" and .conclusion == "success") | .id' | + Select-Object -First 1 + if (-not $runId) { + throw "No successful CI run was found for recovery commit '$env:RELEASE_COMMIT'." + } + "run_id=$runId" >> $env:GITHUB_OUTPUT - name: Download tested release artifact - uses: dawidd6/action-download-artifact@v21 + uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21 with: - run_id: ${{ github.event.workflow_run.id }} + run_id: ${{ steps.ci_run.outputs.run_id }} name: Release path: ./Release/ if_no_artifact_found: fail - uses: ./.github/actions/setup-powershell - - uses: ./.github/actions/create-release-tag - with: - tag: ${{ steps.prepared_release.outputs.release_tag }} - - - uses: ./.github/actions/resolve-release-tag - id: release_ref - with: - tag: ${{ steps.prepared_release.outputs.release_tag }} - - uses: ./.github/actions/build-release-notes id: release_notes with: - release-version: ${{ steps.release_ref.outputs.release_tag }} + release-version: ${{ steps.prepared_release.outputs.release_tag }} module-path: ./AtlassianPS.Standards/AtlassianPS.Standards.psd1 - name: Stamp and verify release artifact - run: Invoke-Build -Task SetVersion -VersionToPublish ${{ steps.release_ref.outputs.release_tag }} -VerifyPublishedRelease + env: + RECOVERY_TAG: ${{ inputs.recovery_tag }} + run: | + if ($env:RECOVERY_TAG) { + Invoke-Build -Task SetVersion -VersionToPublish ${{ steps.prepared_release.outputs.release_tag }} + } + else { + Invoke-Build -Task SetVersion -VersionToPublish ${{ steps.prepared_release.outputs.release_tag }} -VerifyPublishedRelease + } shell: pwsh - name: Package release artifact run: Invoke-Build -Task Package shell: pwsh + - name: Verify release artifact + run: Invoke-Build -Task VerifyReleaseArtifact -VersionToPublish ${{ steps.prepared_release.outputs.release_tag }} + shell: pwsh + + - uses: ./.github/actions/create-release-tag + with: + tag: ${{ steps.prepared_release.outputs.release_tag }} + commit: ${{ steps.prepared_release.outputs.release_commit }} + - name: Publish tested module artifact - run: Publish-Module -Path ./Release/AtlassianPS.Standards -NuGetApiKey ${{ secrets.PSGALLERY_API_KEY }} -ErrorAction Stop + run: | + $manifest = Test-ModuleManifest -Path ./Release/AtlassianPS.Standards/AtlassianPS.Standards.psd1 -ErrorAction Stop + $published = Find-Module -Name $manifest.Name -RequiredVersion $manifest.Version -AllowPrerelease -ErrorAction SilentlyContinue + if ($published) { + Write-Host "PSGallery already contains $($manifest.Name) $($manifest.Version); skipping immutable publish." + return + } + Publish-Module -Path ./Release/AtlassianPS.Standards -NuGetApiKey ${{ secrets.PSGALLERY_API_KEY }} -ErrorAction Stop shell: pwsh - name: Create GitHub release and upload asset - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - tag_name: ${{ steps.release_ref.outputs.release_tag }} - name: ${{ steps.release_ref.outputs.release_tag }} + tag_name: ${{ steps.prepared_release.outputs.release_tag }} + name: ${{ steps.prepared_release.outputs.release_tag }} body_path: ${{ steps.release_notes.outputs.release_notes_path }} files: ./Release/AtlassianPS.Standards.zip fail_on_unmatched_files: true draft: false - prerelease: ${{ contains(steps.release_ref.outputs.release_tag, '-alpha') || contains(steps.release_ref.outputs.release_tag, '-beta') || contains(steps.release_ref.outputs.release_tag, '-rc') }} + prerelease: ${{ contains(steps.prepared_release.outputs.release_tag, '-alpha') || contains(steps.prepared_release.outputs.release_tag, '-beta') || contains(steps.prepared_release.outputs.release_tag, '-rc') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Notify homepage to update submodule - if: ${{ !contains(steps.release_ref.outputs.release_tag, '-alpha') && !contains(steps.release_ref.outputs.release_tag, '-beta') && !contains(steps.release_ref.outputs.release_tag, '-rc') }} - uses: peter-evans/repository-dispatch@v4 + if: ${{ !contains(steps.prepared_release.outputs.release_tag, '-alpha') && !contains(steps.prepared_release.outputs.release_tag, '-beta') && !contains(steps.prepared_release.outputs.release_tag, '-rc') }} + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: token: ${{ secrets.HOMEPAGE_PAT }} repository: AtlassianPS/AtlassianPS.github.io event-type: module-release - client-payload: '{"module": "AtlassianPS.Standards", "version": "${{ steps.release_ref.outputs.release_tag }}"}' + client-payload: '{"module": "AtlassianPS.Standards", "version": "${{ steps.prepared_release.outputs.release_tag }}"}' diff --git a/.github/workflows/release_intent.yml b/.github/workflows/release_intent.yml index b50ccf7..0603e4e 100644 --- a/.github/workflows/release_intent.yml +++ b/.github/workflows/release_intent.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout trusted base - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: ref: ${{ github.event.pull_request.base.ref }} diff --git a/AGENTS.md b/AGENTS.md index 88b4c00..9c0822f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,4 +74,4 @@ Before finalizing, always run the full pipeline: `Invoke-Build -Task Lint, Build ## CI/CD Notes - `.github/workflows/ci.yml` is the required quality gate for runtime/code changes. -- Instruction-only changes can be skipped by CI path filters; run local validation and report exact command outcomes. +- CI runs for every pull request and `master` push so every merged release-labelled change reaches release planning. diff --git a/AtlassianPS.Standards.build.ps1 b/AtlassianPS.Standards.build.ps1 index 87c2a99..8a8bb4f 100644 --- a/AtlassianPS.Standards.build.ps1 +++ b/AtlassianPS.Standards.build.ps1 @@ -155,13 +155,27 @@ Task SetVersion { } # Synopsis: Compress the built module into the publishable release artifact -Task Package { +Task Package { $script:PackagePath = New-AtlassianPSModulePackage ` -BuildOutputPath $env:BHBuildOutput ` -ModuleName $env:BHProjectName -} - -Task TestPublish Build, Package, { +} + +Task VerifyReleaseArtifact Package, { + if (-not $script:BuildInfo.VersionToPublish) { + throw 'VersionToPublish is required for VerifyReleaseArtifact. Use -VersionToPublish .' + } + + $expectedCore = $script:BuildInfo.VersionToPublish -replace '-.*$', '' + $null = Test-AtlassianPSModulePackage ` + -BuildOutputPath $env:BHBuildOutput ` + -ModuleName $env:BHProjectName ` + -PackagePath $script:PackagePath ` + -ExpectedVersion $expectedCore ` + -RequireReleaseNotes +} + +Task TestPublish Build, Package, { $null = Test-AtlassianPSModulePackage ` -BuildOutputPath $env:BHBuildOutput ` -ModuleName $env:BHProjectName ` diff --git a/AtlassianPS.Standards/Public/Test-ModulePackage.ps1 b/AtlassianPS.Standards/Public/Test-ModulePackage.ps1 index ef5fe69..ac5afeb 100644 --- a/AtlassianPS.Standards/Public/Test-ModulePackage.ps1 +++ b/AtlassianPS.Standards/Public/Test-ModulePackage.ps1 @@ -15,8 +15,14 @@ .PARAMETER ModuleName Name of the module directory and manifest to validate. - .PARAMETER PackagePath - Optional explicit package archive path. Defaults to /.zip. + .PARAMETER PackagePath + Optional explicit package archive path. Defaults to /.zip. + + .PARAMETER ExpectedVersion + Optional expected numeric module version. + + .PARAMETER RequireReleaseNotes + Require non-empty PrivateData.PSData.ReleaseNotes in both release manifest and package. .OUTPUTS PSCustomObject with ModulePath, ManifestPath, PackagePath, Name, and Version. @@ -37,8 +43,14 @@ [ValidateNotNullOrEmpty()] [String]$ModuleName, - [Parameter()] - [String]$PackagePath + [Parameter()] + [String]$PackagePath, + + [Parameter()] + [String]$ExpectedVersion, + + [Parameter()] + [Switch]$RequireReleaseNotes ) $releaseModulePath = Join-Path -Path $BuildOutputPath -ChildPath $ModuleName @@ -61,9 +73,16 @@ if ($manifest.Name -ne $ModuleName) { throw "Release manifest name '$($manifest.Name)' does not match '$ModuleName'." } - if ($null -eq $manifest.Version) { - throw 'Release manifest version could not be resolved.' - } + if ($null -eq $manifest.Version) { + throw 'Release manifest version could not be resolved.' + } + if ($ExpectedVersion -and $manifest.Version.ToString() -ne $ExpectedVersion) { + throw "Release manifest version '$($manifest.Version)' does not match expected '$ExpectedVersion'." + } + $manifestData = Import-PowerShellDataFile -LiteralPath $releaseManifestPath + if ($RequireReleaseNotes -and [String]::IsNullOrWhiteSpace($manifestData.PrivateData.PSData.ReleaseNotes)) { + throw 'Release manifest release notes are empty.' + } $packageValidationRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "AtlassianPS-PackageValidation-$([Guid]::NewGuid().ToString('N'))" try { @@ -78,9 +97,13 @@ if ($packagedManifest.Name -ne $ModuleName) { throw "Packaged manifest name '$($packagedManifest.Name)' does not match '$ModuleName'." } - if ($packagedManifest.Version -ne $manifest.Version) { - throw "Packaged manifest version '$($packagedManifest.Version)' does not match release manifest version '$($manifest.Version)'." - } + if ($packagedManifest.Version -ne $manifest.Version) { + throw "Packaged manifest version '$($packagedManifest.Version)' does not match release manifest version '$($manifest.Version)'." + } + $packagedManifestData = Import-PowerShellDataFile -LiteralPath $packagedManifestPath + if ($RequireReleaseNotes -and [String]::IsNullOrWhiteSpace($packagedManifestData.PrivateData.PSData.ReleaseNotes)) { + throw 'Packaged manifest release notes are empty.' + } } catch { throw "Release package validation failed for '$PackagePath': $($_.Exception.Message)" diff --git a/CODEOWNERS b/CODEOWNERS index a98ac83..ad2d819 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,6 +1,8 @@ -.github/workflows @atlassianps/ci-managers -JiraPS.build.ps1 @atlassianps/ci-managers -PPSScriptAnalyzerSettings.psd1 @atlassianps/ci-managers -Tools/ @atlassianps/ci-managers +.github/workflows/ @atlassianps/ci-managers +.github/actions/ @atlassianps/ci-managers +AtlassianPS.Standards.build.ps1 @atlassianps/ci-managers +AtlassianPS.Standards/AtlassianPS.Standards.psd1 @atlassianps/ci-managers +Tools/ @atlassianps/ci-managers +docs/ReleaseBlueprint.md @atlassianps/ci-managers * @atlassianps/maintainers diff --git a/README.md b/README.md index a3186f2..9eb51ba 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Invoke-Build -Task Lint, Build, Test ## Release Label-based CD runs after release-labelled pull requests merge to `master`. -It computes the next semantic version from the merged PR's `release:*` label, prepares `CHANGELOG.md`, stamps the source manifest, commits that release metadata to `master`, validates the release metadata, creates an annotated tag, publishes to PowerShell Gallery, creates the GitHub release from the same changelog section, and notifies the website to update its module submodule. -The release-preparation commit also stamps the source module manifest with the exact released version and release notes so the tagged repository state matches the published package metadata. +It computes the next semantic version from the merged PR's `release:*` label, prepares `CHANGELOG.md`, stamps the source manifest version, commits that release metadata to `master`, validates the final package, creates an annotated tag, publishes to PowerShell Gallery, creates the GitHub release from the same changelog section, and notifies the website to update its module submodule. +The release-preparation commit stamps the exact source version. Release notes are stamped into, then validated in, the final package before publishing. The workflow should use `ATLASSIANPS_RELEASE_BOT_TOKEN` when pushing the release metadata commit and tag if branch protection does not allow the default `GITHUB_TOKEN` to push to `master`. The continuous release workflow will: diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..23a7001 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,51 @@ +# Continuous Delivery Status + +## Goal + +Automated, safe release after reviewed PR merge. `release:none` is only normal skip path. + +## Current State + +✅ Label validation exists in trusted `pull_request_target` workflow. + +✅ Continuous release creates metadata commit, waits for CI, tags, publishes PSGallery, creates GitHub release, notifies website. + +✅ Latest source baseline is `v0.1.12` at `b95fd4d`. + +⚠️ `v0.1.12` annotated tag exists; PSGallery package and GitHub release do not. Existing workflow cannot resume same version. + +❌ `master` has no branch protection or ruleset. Direct pushes bypass review, CI, release intent, and CODEOWNERS. + +❌ CI path filters can suppress `workflow_run`; labelled documentation or metadata PRs then never release. + +❌ Publish job modifies package after CI without validating final package. + +✅ Third-party workflow Actions are SHA-pinned. Repository SHA pinning is required; default workflow token permission is read-only. + +## Implementation Plan + +1. ✅ Removed CI path filters. Every merged PR triggers release planning. +2. ✅ Added final archive validation after manifest stamp and packaging, before tag/publish. +3. ✅ Added `recovery_tag` dispatch. Recovery verifies tagged master commit and successful CI artifact, then safely skips only existing PSGallery version. +4. ✅ Made merged-PR resolution deterministic: exact `merge_commit_sha`, exactly one match. +5. ✅ Added regression tests for planner ambiguity and final package version/notes validation. +6. ✅ Pinned third-party Actions; CI token now read-only. +7. ⏳ Configure GitHub `master` ruleset and `v*` tag protection. Required checks: `CI Result`, `Release Intent`; one approval; stale-review dismissal; CODEOWNERS; no force push/deletion. Blocked only on selecting release bot bypass identity: GitHub App preferred, or existing `ATLASSIANPS_RELEASE_BOT_TOKEN` owner. +8. ⏳ Repair stalled `v0.1.12` with `recovery_tag: v0.1.12` after merge. PSGallery publish is irreversible; inspect exact release state first. + +## Definition Of Done + +- Reviewed, labelled PR merges trigger one release or explicit `release:none` skip. +- Final PSGallery package has CI-equivalent validation and matching tag/changelog/version/notes. +- Safe rerun completes partial release without duplicate immutable package or tag drift. +- `master` requires approval, CI Result, Release Intent, and CODEOWNERS review. +- Third-party Actions immutable-pinned; tokens least-privilege. +- Focused tests, full build/test, workflow lint, and release-state preflight pass. + +## Verified + +✅ `actionlint` passed all workflows. + +✅ Focused pipeline/package tests: 25 passed. + +✅ Full `Invoke-Build -Task Lint, Build, Test`: 81 passed, 0 failed. diff --git a/Tests/Functions/Public/BlueprintHelpers.Unit.Tests.ps1 b/Tests/Functions/Public/BlueprintHelpers.Unit.Tests.ps1 index 51c17d0..e4e8cc5 100644 --- a/Tests/Functions/Public/BlueprintHelpers.Unit.Tests.ps1 +++ b/Tests/Functions/Public/BlueprintHelpers.Unit.Tests.ps1 @@ -62,7 +62,7 @@ Describe 'Import-DotEnvFile' { } Describe 'Test-ModulePackage' { - It 'validates a release module directory, manifest, and package' { + It 'validates a release module directory, manifest, and package' { $buildOutput = Join-Path -Path $TestDrive -ChildPath 'Release' $moduleName = 'PackageValidation' $modulePath = Join-Path -Path $buildOutput -ChildPath $moduleName @@ -74,8 +74,52 @@ Describe 'Test-ModulePackage' { $result = Test-AtlassianPSModulePackage -BuildOutputPath $buildOutput -ModuleName $moduleName $result.Name | Should -Be $moduleName - $result.Version | Should -Be ([Version]'1.2.3') - } + $result.Version | Should -Be ([Version]'1.2.3') + } + + It 'requires matching version and release notes when requested' { + $buildOutput = Join-Path -Path $TestDrive -ChildPath 'Release-metadata' + $moduleName = 'PackageMetadata' + $modulePath = Join-Path -Path $buildOutput -ChildPath $moduleName + $null = New-Item -Path $modulePath -ItemType Directory -Force + $manifestPath = Join-Path -Path $modulePath -ChildPath "$moduleName.psd1" + Set-Content -LiteralPath $manifestPath -Value @" +@{ + RootModule = '$moduleName.psm1' + ModuleVersion = '1.2.3' + GUID = 'b558bd8c-dc02-4ff2-96b7-4d2c61d9d103' + PrivateData = @{ + PSData = @{ + ReleaseNotes = 'Release notes' + } + } +} +"@ + Set-Content -LiteralPath (Join-Path -Path $modulePath -ChildPath "$moduleName.psm1") -Value '' + Compress-Archive -Path $modulePath -DestinationPath (Join-Path -Path $buildOutput -ChildPath "$moduleName.zip") + + $result = Test-AtlassianPSModulePackage ` + -BuildOutputPath $buildOutput ` + -ModuleName $moduleName ` + -ExpectedVersion '1.2.3' ` + -RequireReleaseNotes + + $result.Version | Should -Be ([Version]'1.2.3') + } + + It 'rejects a mismatched expected version' { + $buildOutput = Join-Path -Path $TestDrive -ChildPath 'Release-version-mismatch' + $moduleName = 'VersionMismatch' + $modulePath = Join-Path -Path $buildOutput -ChildPath $moduleName + $null = New-Item -Path $modulePath -ItemType Directory -Force + New-ModuleManifest -Path (Join-Path -Path $modulePath -ChildPath "$moduleName.psd1") -RootModule "$moduleName.psm1" -ModuleVersion '1.2.3' + Set-Content -LiteralPath (Join-Path -Path $modulePath -ChildPath "$moduleName.psm1") -Value '' + Compress-Archive -Path $modulePath -DestinationPath (Join-Path -Path $buildOutput -ChildPath "$moduleName.zip") + + { + Test-AtlassianPSModulePackage -BuildOutputPath $buildOutput -ModuleName $moduleName -ExpectedVersion '1.2.4' + } | Should -Throw -ExpectedMessage '*does not match expected*' + } It 'throws when the release package is missing' { $buildOutput = Join-Path -Path $TestDrive -ChildPath 'Release-missing-package' diff --git a/Tests/GitHubActions.Tests.ps1 b/Tests/GitHubActions.Tests.ps1 index b53b304..a08708d 100644 --- a/Tests/GitHubActions.Tests.ps1 +++ b/Tests/GitHubActions.Tests.ps1 @@ -14,7 +14,7 @@ Describe 'GitHub Actions' -Tag 'Lint', 'Unit' { $route = @($arguments | Where-Object { $_ -like 'repos/*' } | Select-Object -First 1) if ($route -like 'repos/*/commits/*/pulls') { - '{"number":42,"title":"Fix release notes","user":"tester"}' + '{"number":42,"title":"Fix release notes","user":"tester","merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"abc123"}' return } @@ -73,13 +73,13 @@ Describe 'GitHub Actions' -Tag 'Lint', 'Unit' { $output | Should -Match ([Regex]::Escape('fragment_content=* Fix release notes (#42, @tester)')) } - It 'plan-merged-release skips release:none merged PRs' { + It 'plan-merged-release skips release:none merged PRs' { function gh { $arguments = [String[]]$args $route = @($arguments | Where-Object { $_ -like 'repos/*' } | Select-Object -First 1) if ($route -like 'repos/*/commits/*/pulls') { - '{"number":43,"title":"Update docs","user":"tester"}' + '{"number":43,"title":"Update docs","user":"tester","merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"def456"}' return } @@ -114,7 +114,39 @@ Describe 'GitHub Actions' -Tag 'Lint', 'Unit' { $output = Get-Content -LiteralPath $outputPath -Raw $output | Should -Match 'should_release=false' $output | Should -Match 'skip_reason=release:none' - } + } + + It 'plan-merged-release rejects ambiguous merged pull request associations' { + function gh { + $arguments = [String[]]$args + $route = @($arguments | Where-Object { $_ -like 'repos/*' } | Select-Object -First 1) + + if ($route -like 'repos/*/commits/*/pulls') { + '{"number":43,"title":"First","user":"tester","merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"abc123"}' + '{"number":44,"title":"Second","user":"tester","merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"abc123"}' + return + } + + throw "Unexpected gh invocation: $($arguments -join ' ')" + } + + $outputPath = Join-Path -Path $TestDrive -ChildPath 'ambiguous-github-output.txt' + $previousRepository = $env:GITHUB_REPOSITORY + $previousCommitSha = $env:COMMIT_SHA + $previousOutput = $env:GITHUB_OUTPUT + try { + $env:GITHUB_REPOSITORY = 'AtlassianPS/AtlassianPS.Standards' + $env:COMMIT_SHA = 'abc123' + $env:GITHUB_OUTPUT = $outputPath + + { & $script:planMergedReleaseScriptPath } | Should -Throw -ExpectedMessage 'Expected exactly one merged pull request*' + } + finally { + $env:GITHUB_REPOSITORY = $previousRepository + $env:COMMIT_SHA = $previousCommitSha + $env:GITHUB_OUTPUT = $previousOutput + } + } It 'plan-merged-release computes a manual release without generating a PR fragment' { function gh { @@ -216,7 +248,7 @@ Describe 'GitHub Actions' -Tag 'Lint', 'Unit' { $route = @($arguments | Where-Object { $_ -like 'repos/*' } | Select-Object -First 1) if ($route -like 'repos/*/commits/*/pulls') { - '{"number":44,"title":"Change defaults","user":"tester"}' + '{"number":44,"title":"Change defaults","user":"tester","merged_at":"2026-01-01T00:00:00Z","merge_commit_sha":"ghi789"}' return } @@ -271,19 +303,29 @@ Describe 'GitHub Actions' -Tag 'Lint', 'Unit' { # Module-domain work lives in Invoke-Build tasks; deployment plumbing lives in composite # actions. Neither manifest stamping nor packaging appears inline in the workflow. $workflow | Should -Match 'Invoke-Build -Task SetSourceVersion' - $workflow | Should -Match 'Invoke-Build -Task SetVersion .*-VerifyPublishedRelease' - $workflow | Should -Match 'Invoke-Build -Task Package' + $workflow | Should -Match 'Invoke-Build -Task SetVersion .*-VerifyPublishedRelease' + $workflow | Should -Match 'Invoke-Build -Task Package' + $workflow | Should -Match 'Invoke-Build -Task VerifyReleaseArtifact' $workflow | Should -Match 'uses: \./\.github/actions/commit-release-metadata' $workflow | Should -Match 'uses: \./\.github/actions/create-release-tag' $workflow | Should -Not -Match 'Set-AtlassianPSModuleManifestVersion' $workflow | Should -Not -Match 'Get-AtlassianPSReleaseNotesFromChangelog' $workflow | Should -Not -Match 'Compress-Archive' - # The artifact is stamped and verified before it is published to immutable PSGallery. - $verifyIndex = $workflow.IndexOf('-VerifyPublishedRelease') - $publishIndex = $workflow.IndexOf('Publish-Module') - $verifyIndex | Should -BeGreaterThan -1 - $publishIndex | Should -BeGreaterThan $verifyIndex + # Final package validation and tag creation precede immutable PSGallery publish. + $verifyIndex = $workflow.IndexOf('Invoke-Build -Task VerifyReleaseArtifact') + $tagIndex = $workflow.IndexOf('uses: ./.github/actions/create-release-tag') + $publishIndex = $workflow.IndexOf('Publish-Module') + $verifyIndex | Should -BeGreaterThan -1 + $publishIndex | Should -BeGreaterThan $verifyIndex + $tagIndex | Should -BeGreaterThan $verifyIndex + $publishIndex | Should -BeGreaterThan $tagIndex + + # Recovery reuses only a tagged master commit with successful CI artifact provenance. + $workflow | Should -Match 'recovery_tag:' + $workflow | Should -Match 'No successful CI run was found for recovery commit' + $workflow | Should -Match 'skipping immutable publish' + $workflow | Should -Match 'RECOVERY_TAG' } It 'keeps publishing secrets and publish tasks out of the build script' { diff --git a/docs/BlueprintHelpers.md b/docs/BlueprintHelpers.md index 86c35e5..d6ece57 100644 --- a/docs/BlueprintHelpers.md +++ b/docs/BlueprintHelpers.md @@ -104,8 +104,8 @@ Task SetVersion { Release automation should fold pending changelog entries and custom fragments into the next version section, then delete the consumed fragments. Use the `prepare-release-changelog` composite action instead of exporting another module helper for GitHub-only release mechanics. In the continuous release workflow, commit the resulting `CHANGELOG.md` update and `.changelog` deletions directly to `master` after a release-labelled PR merges. -Commit the source module manifest version and release notes in that same release metadata commit so repository readers do not see drift between the tag, changelog, and manifest metadata. -For manual release preparation, commit the same files before tagging the release. +Commit the source module manifest version in the release metadata commit. Stamp release notes only into final release artifact, then validate package before publishing; source manifest keeps release notes empty. +For manual release preparation, commit source version and changelog before tagging the release. ```yaml - uses: AtlassianPS/AtlassianPS.Standards/.github/actions/prepare-release-changelog@ diff --git a/docs/ReleaseBlueprint.md b/docs/ReleaseBlueprint.md index fbdd48f..6017e1a 100644 --- a/docs/ReleaseBlueprint.md +++ b/docs/ReleaseBlueprint.md @@ -42,12 +42,13 @@ After CI succeeds on a normal merged pull request with `release:patch`, `release 8. Commit the release metadata changes directly to `master`. 9. Let CI build and test the bot-authored release metadata commit (the built artifact inherits the stamped version). 10. Download the CI `Release` artifact from that exact commit. -11. Create an annotated tag on the tested release metadata commit. -12. Build release notes from the committed `CHANGELOG.md` section. -13. Run `Invoke-Build -Task SetVersion ... -VerifyPublishedRelease` to populate release notes into the tested artifact and verify its version, without rebuilding the package. -14. Publish the verified module artifact to PSGallery. -15. Create the GitHub release with the same release notes body. -16. Notify the website to update its module submodule. +11. Build release notes from the committed `CHANGELOG.md` section. +12. Run `Invoke-Build -Task SetVersion ... -VerifyPublishedRelease` to populate release notes into the tested artifact and verify its version, without rebuilding the package. +13. Package and validate the final artifact, including manifest version, release notes, and archive contents. +14. Create an annotated tag on the tested release metadata commit. +15. Publish the verified module artifact to PSGallery. +16. Create the GitHub release with the same release notes body. +17. Notify the website to update its module submodule. `release:none` merges should stop after planning and must not publish. The workflow should be serialized with concurrency so multiple release-labelled merges do not race the next-version calculation. @@ -64,7 +65,8 @@ The generated tag and changelog section use forms like `vX.Y.Z-alpha`, `vX.Y.Z-b Do not keep a separate tag-triggered release workflow unless it is intentionally idempotent across already-created tags, PSGallery packages, GitHub releases, uploaded assets, and website notifications. The default AtlassianPS release path has one publishing workflow: `continuous_release.yml`. -If a release fails after publishing an immutable PSGallery package, repair the failed downstream artifact directly, for example by creating the missing GitHub release or rerunning the website dispatch, instead of rerunning a workflow that calls `Publish-Module` again. +If a release fails after the metadata commit, rerun `continuous_release.yml` with `recovery_tag` set to the existing release tag. Recovery requires that tag to identify a commit reachable from `master`, locates successful CI for that commit, rebuilds and validates its package, and skips PSGallery only when exact module version already exists. It then reconciles GitHub release asset and website notification. +Do not use recovery for a tag that lacks successful CI or points outside `master`; investigate and repair source state first. ## Required Shared Actions From e9f7fcd1551f2b5a329cec2e4793a352af19203e Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 20:55:18 +0200 Subject: [PATCH 2/8] docs(cd): record SHA enforcement rollout --- STATUS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/STATUS.md b/STATUS.md index 23a7001..7919fdd 100644 --- a/STATUS.md +++ b/STATUS.md @@ -20,7 +20,9 @@ Automated, safe release after reviewed PR merge. `release:none` is only normal s ❌ Publish job modifies package after CI without validating final package. -✅ Third-party workflow Actions are SHA-pinned. Repository SHA pinning is required; default workflow token permission is read-only. +✅ Third-party workflow Actions are SHA-pinned. Default workflow token permission is read-only. + +⚠️ Repository SHA enforcement is temporarily disabled. Enabling it before pinned workflows reach `master` blocked base-branch `Release Intent`. Re-enable immediately after this PR merges. ## Implementation Plan @@ -29,7 +31,7 @@ Automated, safe release after reviewed PR merge. `release:none` is only normal s 3. ✅ Added `recovery_tag` dispatch. Recovery verifies tagged master commit and successful CI artifact, then safely skips only existing PSGallery version. 4. ✅ Made merged-PR resolution deterministic: exact `merge_commit_sha`, exactly one match. 5. ✅ Added regression tests for planner ambiguity and final package version/notes validation. -6. ✅ Pinned third-party Actions; CI token now read-only. +6. ✅ Pinned third-party Actions; CI token now read-only. ⏳ Re-enable repository SHA enforcement after merge. 7. ⏳ Configure GitHub `master` ruleset and `v*` tag protection. Required checks: `CI Result`, `Release Intent`; one approval; stale-review dismissal; CODEOWNERS; no force push/deletion. Blocked only on selecting release bot bypass identity: GitHub App preferred, or existing `ATLASSIANPS_RELEASE_BOT_TOKEN` owner. 8. ⏳ Repair stalled `v0.1.12` with `recovery_tag: v0.1.12` after merge. PSGallery publish is irreversible; inspect exact release state first. From f11b7231c100b3df988baf16c96fc09537b0df3f Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 21:56:34 +0200 Subject: [PATCH 3/8] fix(ci): update Pester runner compatibility --- STATUS.md | 2 ++ Tools/build.requirements.psd1 | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/STATUS.md b/STATUS.md index 7919fdd..7229043 100644 --- a/STATUS.md +++ b/STATUS.md @@ -51,3 +51,5 @@ Automated, safe release after reviewed PR merge. `release:none` is only normal s ✅ Focused pipeline/package tests: 25 passed. ✅ Full `Invoke-Build -Task Lint, Build, Test`: 81 passed, 0 failed. + +⚠️ PR #39 first CI run exposed Pester 5.7.1 incompatibility with current hosted runners. Pin updated to Pester 5.9.0; CI cache key changes with dependency lockfile. diff --git a/Tools/build.requirements.psd1 b/Tools/build.requirements.psd1 index 95a4d46..55c4d3a 100644 --- a/Tools/build.requirements.psd1 +++ b/Tools/build.requirements.psd1 @@ -1,4 +1,4 @@ @( @{ ModuleName = "InvokeBuild"; RequiredVersion = "5.14.23" } - @{ ModuleName = "Pester"; RequiredVersion = "5.7.1" } + @{ ModuleName = "Pester"; RequiredVersion = "5.9.0" } ) From 270e67e90813c85fa10c790226caa21791996404 Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 21:59:17 +0200 Subject: [PATCH 4/8] fix(ci): keep PR path filters --- .github/workflows/ci.yml | 18 +++++++++++++ AGENTS.md | 2 +- STATUS.md | 55 ---------------------------------------- 3 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 STATUS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614ded3..6f99700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,24 @@ concurrency: on: pull_request: branches: [master] + paths-ignore: + - "CHANGELOG.md" + - "LICENSE" + # AI assistant instruction files + - "AGENTS.md" + - "CLAUDE.md" + - "GEMINI.md" + - ".cursor/**" + - ".github/copilot-instructions.md" + - ".github/ai-context/**" + - ".github/instructions/**" + # GitHub/editor meta files + - ".github/*.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/PULL_REQUEST_TEMPLATE/**" + - ".vscode/**" + - ".editorconfig" + - ".spelling" push: branches: [master] workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index 9c0822f..bbf3b66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,4 +74,4 @@ Before finalizing, always run the full pipeline: `Invoke-Build -Task Lint, Build ## CI/CD Notes - `.github/workflows/ci.yml` is the required quality gate for runtime/code changes. -- CI runs for every pull request and `master` push so every merged release-labelled change reaches release planning. +- CI path filters may skip pull request validation for documentation and metadata-only changes. CI always runs for `master` pushes so every merged release-labelled change reaches release planning. diff --git a/STATUS.md b/STATUS.md deleted file mode 100644 index 7229043..0000000 --- a/STATUS.md +++ /dev/null @@ -1,55 +0,0 @@ -# Continuous Delivery Status - -## Goal - -Automated, safe release after reviewed PR merge. `release:none` is only normal skip path. - -## Current State - -✅ Label validation exists in trusted `pull_request_target` workflow. - -✅ Continuous release creates metadata commit, waits for CI, tags, publishes PSGallery, creates GitHub release, notifies website. - -✅ Latest source baseline is `v0.1.12` at `b95fd4d`. - -⚠️ `v0.1.12` annotated tag exists; PSGallery package and GitHub release do not. Existing workflow cannot resume same version. - -❌ `master` has no branch protection or ruleset. Direct pushes bypass review, CI, release intent, and CODEOWNERS. - -❌ CI path filters can suppress `workflow_run`; labelled documentation or metadata PRs then never release. - -❌ Publish job modifies package after CI without validating final package. - -✅ Third-party workflow Actions are SHA-pinned. Default workflow token permission is read-only. - -⚠️ Repository SHA enforcement is temporarily disabled. Enabling it before pinned workflows reach `master` blocked base-branch `Release Intent`. Re-enable immediately after this PR merges. - -## Implementation Plan - -1. ✅ Removed CI path filters. Every merged PR triggers release planning. -2. ✅ Added final archive validation after manifest stamp and packaging, before tag/publish. -3. ✅ Added `recovery_tag` dispatch. Recovery verifies tagged master commit and successful CI artifact, then safely skips only existing PSGallery version. -4. ✅ Made merged-PR resolution deterministic: exact `merge_commit_sha`, exactly one match. -5. ✅ Added regression tests for planner ambiguity and final package version/notes validation. -6. ✅ Pinned third-party Actions; CI token now read-only. ⏳ Re-enable repository SHA enforcement after merge. -7. ⏳ Configure GitHub `master` ruleset and `v*` tag protection. Required checks: `CI Result`, `Release Intent`; one approval; stale-review dismissal; CODEOWNERS; no force push/deletion. Blocked only on selecting release bot bypass identity: GitHub App preferred, or existing `ATLASSIANPS_RELEASE_BOT_TOKEN` owner. -8. ⏳ Repair stalled `v0.1.12` with `recovery_tag: v0.1.12` after merge. PSGallery publish is irreversible; inspect exact release state first. - -## Definition Of Done - -- Reviewed, labelled PR merges trigger one release or explicit `release:none` skip. -- Final PSGallery package has CI-equivalent validation and matching tag/changelog/version/notes. -- Safe rerun completes partial release without duplicate immutable package or tag drift. -- `master` requires approval, CI Result, Release Intent, and CODEOWNERS review. -- Third-party Actions immutable-pinned; tokens least-privilege. -- Focused tests, full build/test, workflow lint, and release-state preflight pass. - -## Verified - -✅ `actionlint` passed all workflows. - -✅ Focused pipeline/package tests: 25 passed. - -✅ Full `Invoke-Build -Task Lint, Build, Test`: 81 passed, 0 failed. - -⚠️ PR #39 first CI run exposed Pester 5.7.1 incompatibility with current hosted runners. Pin updated to Pester 5.9.0; CI cache key changes with dependency lockfile. From 9bfb62ac0ef4a2e54e8750b4840590a95788687b Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 22:04:09 +0200 Subject: [PATCH 5/8] docs(cd): add recovery runbook --- AGENTS.md | 1 + docs/ReleaseBlueprint.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bbf3b66..4c36f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ If guidance conflicts, follow this file first. - Pull requests should declare release intent with exactly one `release:*` label; user-facing changes also need a `changelog:*` label or a valid `.changelog/...md` fragment. - Do not ask contributors to choose the final release version in normal PRs; release preparation batches merged intent later. - Keep release notes sourced from one `CHANGELOG.md` section for both GitHub releases and PSGallery manifest `PrivateData.PSData.ReleaseNotes`. +- For a partial release with existing tag, follow `docs/ReleaseBlueprint.md` "Recovery Runbook". Do not delete/recreate tag or republish PSGallery version. - When changing release behavior, update `docs/ReleaseBlueprint.md`, `docs/BlueprintHelpers.md`, tests, and these agent instructions together. ## Build, Lint, Test (run from repo root) diff --git a/docs/ReleaseBlueprint.md b/docs/ReleaseBlueprint.md index 6017e1a..eaa5308 100644 --- a/docs/ReleaseBlueprint.md +++ b/docs/ReleaseBlueprint.md @@ -67,6 +67,20 @@ Do not keep a separate tag-triggered release workflow unless it is intentionally The default AtlassianPS release path has one publishing workflow: `continuous_release.yml`. If a release fails after the metadata commit, rerun `continuous_release.yml` with `recovery_tag` set to the existing release tag. Recovery requires that tag to identify a commit reachable from `master`, locates successful CI for that commit, rebuilds and validates its package, and skips PSGallery only when exact module version already exists. It then reconciles GitHub release asset and website notification. Do not use recovery for a tag that lacks successful CI or points outside `master`; investigate and repair source state first. + +### Recovery Runbook + +Use recovery only for a partial release: an existing release-preparation commit and tag, but one or more publish stages did not finish. It is not a replacement for the normal manual release path. + +1. Inspect the release tag, its commit, the matching `master` CI run, PSGallery, GitHub release, release asset, and website dispatch state. +2. Confirm the tag is annotated and its commit is reachable from `origin/master`. +3. Confirm successful CI produced the `Release` artifact for that exact commit. +4. Confirm `CHANGELOG.md` has a non-empty section matching the tag and source manifest has matching numeric version. +5. Dispatch `Continuous Release` from `master` with only `recovery_tag` set to that tag, for example `v1.2.3`. Leave `release_impact` and `prerelease` empty. +6. Monitor the run. It rebuilds release notes, validates package version/notes/archive, verifies or creates the tag, publishes only when PSGallery lacks that exact version, then creates or updates GitHub release asset and sends stable-release website notification. +7. Verify PSGallery version, annotated tag target, GitHub release body and asset, and website update. Record any intentionally skipped downstream stage. + +Never delete, retag, or republish an existing PSGallery version to recover a release. If provenance checks fail, stop and repair the release metadata through a normal reviewed change. ## Required Shared Actions From 2018a840b52bb3d99d42bb6669bb6e6bf0405291 Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 22:04:56 +0200 Subject: [PATCH 6/8] fix(ci): import locked Pester before tests --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f99700..529e052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,12 @@ jobs: ./Tools/setup.ps1 Invoke-Build -Task ShowDebugInfo - - run: Invoke-Build -Task Test + - name: Test + run: | + $requirements = & { . ./Tools/build.requirements.psd1 } + $pesterVersion = ($requirements | Where-Object ModuleName -eq 'Pester').RequiredVersion + Import-Module Pester -RequiredVersion $pesterVersion -Force -Global + Invoke-Build -Task Test shell: powershell - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -113,7 +118,12 @@ jobs: name: Release path: ./Release/ - - run: Invoke-Build -Task Test + - name: Test + run: | + $requirements = & { . ./Tools/build.requirements.psd1 } + $pesterVersion = ($requirements | Where-Object ModuleName -eq 'Pester').RequiredVersion + Import-Module Pester -RequiredVersion $pesterVersion -Force -Global + Invoke-Build -Task Test shell: pwsh - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From adf588ab89e994183880feab4b78338eff0d9ea1 Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 22:08:19 +0200 Subject: [PATCH 7/8] fix(test): expose Pester commands globally --- .github/workflows/ci.yml | 14 ++------------ .../Private/Import-PesterVersion.ps1 | 4 +++- .../Public/BuildOrchestration.Unit.Tests.ps1 | 18 ++++++++++++++++-- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 529e052..6f99700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,12 +84,7 @@ jobs: ./Tools/setup.ps1 Invoke-Build -Task ShowDebugInfo - - name: Test - run: | - $requirements = & { . ./Tools/build.requirements.psd1 } - $pesterVersion = ($requirements | Where-Object ModuleName -eq 'Pester').RequiredVersion - Import-Module Pester -RequiredVersion $pesterVersion -Force -Global - Invoke-Build -Task Test + - run: Invoke-Build -Task Test shell: powershell - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -118,12 +113,7 @@ jobs: name: Release path: ./Release/ - - name: Test - run: | - $requirements = & { . ./Tools/build.requirements.psd1 } - $pesterVersion = ($requirements | Where-Object ModuleName -eq 'Pester').RequiredVersion - Import-Module Pester -RequiredVersion $pesterVersion -Force -Global - Invoke-Build -Task Test + - run: Invoke-Build -Task Test shell: pwsh - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/AtlassianPS.Standards/Private/Import-PesterVersion.ps1 b/AtlassianPS.Standards/Private/Import-PesterVersion.ps1 index cafc851..912def2 100644 --- a/AtlassianPS.Standards/Private/Import-PesterVersion.ps1 +++ b/AtlassianPS.Standards/Private/Import-PesterVersion.ps1 @@ -15,7 +15,9 @@ if ($loadedPester) { Get-Module -Name 'Pester' | Remove-Module -Force -ErrorAction SilentlyContinue } - Import-Module -Name 'Pester' -RequiredVersion $pesterVersionToUse -ErrorAction Stop + # Pester test scripts execute outside this module scope. Load commands globally so + # they remain available after this helper returns on every PowerShell platform. + Import-Module -Name 'Pester' -RequiredVersion $pesterVersionToUse -Global -ErrorAction Stop } return $pesterVersionToUse diff --git a/Tests/Functions/Public/BuildOrchestration.Unit.Tests.ps1 b/Tests/Functions/Public/BuildOrchestration.Unit.Tests.ps1 index 2405268..5246399 100644 --- a/Tests/Functions/Public/BuildOrchestration.Unit.Tests.ps1 +++ b/Tests/Functions/Public/BuildOrchestration.Unit.Tests.ps1 @@ -5,8 +5,22 @@ BeforeAll { $script:moduleToTest = Initialize-TestEnvironment } -Describe 'Invoke-ModuleTests' { - It 'merges tag filters and clears excluded paths for Integration runs' { +Describe 'Invoke-ModuleTests' { + It 'imports selected Pester version globally for test scripts' { + InModuleScope AtlassianPS.Standards { + Mock -CommandName Get-UsablePesterVersion -MockWith { [Version]'5.9.0' } + Mock -CommandName Get-Module -MockWith { $null } -ParameterFilter { $Name -eq 'Pester' } + Mock -CommandName Import-Module -MockWith {} + + $null = Import-PesterVersion -MinimumVersion ([Version]'5.7.0') -MaximumVersion ([Version]'5.999.0') + + Should -Invoke -CommandName Import-Module -Times 1 -Exactly -ParameterFilter { + $Name -eq 'Pester' -and $RequiredVersion -eq [Version]'5.9.0' -and $Global -and $ErrorAction -eq 'Stop' + } + } + } + + It 'merges tag filters and clears excluded paths for Integration runs' { $testsPath = Join-Path -Path $TestDrive -ChildPath 'tests' $null = New-Item -Path $testsPath -ItemType Directory -Force From 635b3b5f93974ec727345a847419ad1b46270935 Mon Sep 17 00:00:00 2001 From: Oliver Lipkau Date: Sun, 19 Jul 2026 22:57:47 +0200 Subject: [PATCH 8/8] fix(cd): reconcile durable release intent Batch untagged merged PRs so GitHub Actions event coalescing cannot drop release intent. Require release automation token for metadata and tag pushes because GITHUB_TOKEN cannot trigger follow-up CI. --- .../commit-release-metadata/action.yml | 24 +- .github/actions/create-release-tag/action.yml | 6 +- .../actions/plan-merged-release/action.yml | 4 +- .../plan-merged-release.ps1 | 144 ++++++----- .github/workflows/continuous_release.yml | 42 +-- AGENTS.md | 2 + AtlassianPS.Standards.build.ps1 | 2 + .../Private/Import-PesterVersion.ps1 | 16 +- .../Public/Test-ModulePackage.ps1 | 15 +- README.md | 2 +- .../Public/BlueprintHelpers.Unit.Tests.ps1 | 22 ++ Tests/GitHubActions.Tests.ps1 | 244 ++++++++++++++---- docs/ReleaseBlueprint.md | 115 +++++---- 13 files changed, 419 insertions(+), 219 deletions(-) diff --git a/.github/actions/commit-release-metadata/action.yml b/.github/actions/commit-release-metadata/action.yml index 0a5bee8..8b3303b 100644 --- a/.github/actions/commit-release-metadata/action.yml +++ b/.github/actions/commit-release-metadata/action.yml @@ -8,12 +8,9 @@ inputs: description: Path to the module manifest to include in the commit. required: true github-token: - description: Default GITHUB_TOKEN, used when no release bot token is configured. - required: true release-bot-token: - description: Optional token used when branch protection blocks GITHUB_TOKEN pushes. - required: false - default: '' + description: GitHub App or fine-grained token that pushes metadata and triggers CI. + required: true branch: description: Branch to push the release metadata commit to. required: false @@ -26,20 +23,14 @@ runs: env: RELEASE_TAG: ${{ inputs.release-tag }} MANIFEST_PATH: ${{ inputs.manifest-path }} - GITHUB_TOKEN: ${{ inputs.github-token }} RELEASE_BOT_TOKEN: ${{ inputs.release-bot-token }} TARGET_BRANCH: ${{ inputs.branch }} run: | set -euo pipefail - push_token="${RELEASE_BOT_TOKEN:-${GITHUB_TOKEN:-}}" - if [ -z "${push_token}" ]; then - echo "::error::No token available for pushing release metadata." - exit 1 - fi - if [ -z "${RELEASE_BOT_TOKEN:-}" ]; then - echo "::notice::ATLASSIANPS_RELEASE_BOT_TOKEN is not configured. Falling back to GITHUB_TOKEN." + echo "::error::A GitHub App or fine-grained release bot token is required to push metadata and trigger CI." + exit 1 fi if git diff --quiet -- CHANGELOG.md .changelog "${MANIFEST_PATH}"; then @@ -52,9 +43,4 @@ runs: git add CHANGELOG.md .changelog "${MANIFEST_PATH}" git commit -m "Prepare ${RELEASE_TAG} release" - if ! git push "https://x-access-token:${push_token}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${TARGET_BRANCH}"; then - if [ -z "${RELEASE_BOT_TOKEN:-}" ]; then - echo "::error::Push with GITHUB_TOKEN failed. Configure ATLASSIANPS_RELEASE_BOT_TOKEN when branch protection prevents direct pushes." - fi - exit 1 - fi + git push "https://x-access-token:${RELEASE_BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${TARGET_BRANCH}" diff --git a/.github/actions/create-release-tag/action.yml b/.github/actions/create-release-tag/action.yml index efdcf55..1d051d2 100644 --- a/.github/actions/create-release-tag/action.yml +++ b/.github/actions/create-release-tag/action.yml @@ -8,6 +8,9 @@ inputs: description: Commit the tag must identify. Defaults to current checkout commit. required: false default: HEAD + github-token: + description: GitHub App or fine-grained token that creates the release tag. + required: true runs: using: composite steps: @@ -16,6 +19,7 @@ runs: env: RELEASE_TAG: ${{ inputs.tag }} RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_BOT_TOKEN: ${{ inputs.github-token }} run: | set -euo pipefail @@ -32,5 +36,5 @@ runs: echo "Release tag ${RELEASE_TAG} already identifies expected commit ${expected_commit}." else git tag -a "${RELEASE_TAG}" "${expected_commit}" -m "${RELEASE_TAG}" - git push origin "refs/tags/${RELEASE_TAG}" + git push "https://x-access-token:${RELEASE_BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" fi diff --git a/.github/actions/plan-merged-release/action.yml b/.github/actions/plan-merged-release/action.yml index 6737dd2..2027314 100644 --- a/.github/actions/plan-merged-release/action.yml +++ b/.github/actions/plan-merged-release/action.yml @@ -1,11 +1,11 @@ name: Plan Merged Release -description: Resolve release intent from the pull request associated with a merged commit and compute the next release tag. +description: Reconcile merged pull request intent since the latest release tag and compute the next release tag. inputs: github-token: description: Token used to read merged pull request metadata. required: false commit-sha: - description: Merge commit SHA to inspect. Defaults to github.sha. + description: Master commit SHA through which release intent is reconciled. Defaults to github.sha. required: false changelog-directory: description: Directory containing changelog fragments. diff --git a/.github/actions/plan-merged-release/plan-merged-release.ps1 b/.github/actions/plan-merged-release/plan-merged-release.ps1 index fbdd0fc..b34b121 100644 --- a/.github/actions/plan-merged-release/plan-merged-release.ps1 +++ b/.github/actions/plan-merged-release/plan-merged-release.ps1 @@ -43,75 +43,89 @@ if ($isManualRelease) { $releaseImpact = $manualReleaseImpact Write-OutputValue -Name release_impact -Value $releaseImpact } -else { - if (-not $env:COMMIT_SHA) { - throw 'plan-merged-release requires COMMIT_SHA. Run it from a push workflow or pass commit-sha.' - } - - $commitPullsRoute = 'repos/{0}/commits/{1}/pulls' -f $env:GITHUB_REPOSITORY, $env:COMMIT_SHA - $pullRequests = @( - gh api $commitPullsRoute | ConvertFrom-Json | Where-Object { - $_.merged_at -and $_.merge_commit_sha -eq $env:COMMIT_SHA +else { + if (-not $env:COMMIT_SHA) { + throw 'plan-merged-release requires COMMIT_SHA. Run it from a push workflow or pass commit-sha.' + } + + $changelogDirectory = if ($env:CHANGELOG_DIRECTORY) { $env:CHANGELOG_DIRECTORY.TrimEnd('/') } else { '.changelog' } + $stableTags = @(git tag --merged $env:COMMIT_SHA --list 'v[0-9]*' | Where-Object { $_ -match '^v\d+\.\d+\.\d+$' }) + $latestStableTag = $stableTags | Sort-Object { [Version]($_.Substring(1)) } -Descending | Select-Object -First 1 + $commits = if ($latestStableTag) { + @(git rev-list "$latestStableTag..$env:COMMIT_SHA") + } + else { + @(git rev-list $env:COMMIT_SHA) + } + + $pullRequestsByNumber = @{} + foreach ($commit in $commits) { + $route = 'repos/{0}/commits/{1}/pulls' -f $env:GITHUB_REPOSITORY, $commit + foreach ($pullRequest in @(gh api $route | ConvertFrom-Json | Where-Object { $_.merged_at })) { + $pullRequestsByNumber[[String]$pullRequest.number] = $pullRequest } - ) + } - if ($pullRequests.Count -ne 1) { - throw "Expected exactly one merged pull request with merge commit '$env:COMMIT_SHA'; found $($pullRequests.Count)." + if ($pullRequestsByNumber.Count -eq 0) { + Write-OutputValue -Name should_release -Value 'false' + Write-OutputValue -Name skip_reason -Value 'no merged pull requests since latest release tag' + Write-Host "Skipping release: no merged pull requests found since '$latestStableTag'." + return } - $pullRequest = $pullRequests[0] - $prNumber = [String]$pullRequest.number - $prTitle = [String]$pullRequest.title - $prAuthor = [String]$pullRequest.user - - $labelsRoute = 'repos/{0}/issues/{1}/labels' -f $env:GITHUB_REPOSITORY, $prNumber - $labels = @(gh api $labelsRoute --paginate --jq '.[].name') - Write-OutputValue -Name pr_number -Value $prNumber - Write-OutputValue -Name pr_title -Value $prTitle - Write-OutputValue -Name pr_author -Value $prAuthor - - $changelogDirectory = if ($env:CHANGELOG_DIRECTORY) { $env:CHANGELOG_DIRECTORY.TrimEnd('/') } else { '.changelog' } - $fragmentPattern = '^{0}/{1}\.(?patch|minor|major)\.(?added|changed|fixed|removed|deprecated|security|breaking)\.md$' -f [Regex]::Escape($changelogDirectory), $prNumber - $fragmentFiles = @( - if (Test-Path -LiteralPath $changelogDirectory -PathType Container) { - Get-ChildItem -LiteralPath $changelogDirectory -Filter "$prNumber.*.md" | - ForEach-Object { Join-Path -Path $changelogDirectory -ChildPath $_.Name } - } - ) - $matchingFragments = @( - foreach ($path in $fragmentFiles) { - $normalizedPath = $path -replace '\\', '/' - $match = [Regex]::Match($normalizedPath, $fragmentPattern) - if ($match.Success) { - [PSCustomObject]@{ - Path = $normalizedPath - Impact = $match.Groups['impact'].Value - Type = $match.Groups['type'].Value - } - } - } - ) - foreach ($path in @($fragmentFiles | Where-Object { -not [Regex]::IsMatch(($_ -replace '\\', '/'), $fragmentPattern) })) { - throw "Changelog fragment '$path' must be named '$changelogDirectory/$prNumber...md'." - } - $intentState = Resolve-ReleaseIntentState -Labels $labels -Fragments $matchingFragments -HasChangelogFilesForNone ($fragmentFiles.Count -gt 0) -Context "merged PR #$prNumber" - if (-not $intentState.IsValid) { - throw ($intentState.Messages -join [Environment]::NewLine) - } - - $releaseImpact = $intentState.ReleaseImpact - Write-OutputValue -Name release_impact -Value $releaseImpact - if ($releaseImpact -eq 'none') { - Write-OutputValue -Name should_release -Value 'false' - Write-OutputValue -Name skip_reason -Value 'release:none' - Write-Host "Skipping release: merged PR #$prNumber has release:none." - return - } - - $fragment = $intentState.Fragment - $changelogType = $intentState.ChangelogType - Write-OutputValue -Name changelog_type -Value $changelogType -} + $impactRank = @{ none = 0; patch = 1; minor = 2; major = 3 } + $selectedImpact = 'none' + foreach ($pullRequest in @($pullRequestsByNumber.Values | Sort-Object { [Int]$_.number })) { + $prNumber = [String]$pullRequest.number + $prTitle = [String]$pullRequest.title + $prAuthor = [String]$pullRequest.user.login + $labelsRoute = 'repos/{0}/issues/{1}/labels' -f $env:GITHUB_REPOSITORY, $prNumber + $labels = @(gh api $labelsRoute --paginate --jq '.[].name') + $fragmentPattern = '^{0}/{1}\.(?patch|minor|major)\.(?added|changed|fixed|removed|deprecated|security|breaking)\.md$' -f [Regex]::Escape($changelogDirectory), $prNumber + $fragmentFiles = @( + if (Test-Path -LiteralPath $changelogDirectory -PathType Container) { + Get-ChildItem -LiteralPath $changelogDirectory -Filter "$prNumber.*.md" | + ForEach-Object { Join-Path -Path $changelogDirectory -ChildPath $_.Name } + } + ) + $matchingFragments = @( + foreach ($path in $fragmentFiles) { + $normalizedPath = $path -replace '\\', '/' + $match = [Regex]::Match($normalizedPath, $fragmentPattern) + if ($match.Success) { + [PSCustomObject]@{ Path = $normalizedPath; Impact = $match.Groups['impact'].Value; Type = $match.Groups['type'].Value } + } + } + ) + foreach ($path in @($fragmentFiles | Where-Object { -not [Regex]::IsMatch(($_ -replace '\\', '/'), $fragmentPattern) })) { + throw "Changelog fragment '$path' must be named '$changelogDirectory/$prNumber...md'." + } + $intentState = Resolve-ReleaseIntentState -Labels $labels -Fragments $matchingFragments -HasChangelogFilesForNone ($fragmentFiles.Count -gt 0) -Context "merged PR #$prNumber" + if (-not $intentState.IsValid) { + throw ($intentState.Messages -join [Environment]::NewLine) + } + if ($intentState.ReleaseImpact -eq 'none') { + continue + } + if ($impactRank[$intentState.ReleaseImpact] -gt $impactRank[$selectedImpact]) { + $selectedImpact = $intentState.ReleaseImpact + } + if (-not $intentState.Fragment) { + $fragmentPath = Join-Path -Path $changelogDirectory -ChildPath ('{0}.{1}.{2}.md' -f $prNumber, $intentState.ReleaseImpact, $intentState.ChangelogType) + New-Item -Path (Split-Path -Path $fragmentPath -Parent) -ItemType Directory -Force | Out-Null + ('* {0} (#{1}, @{2})' -f ($prTitle -replace "`r`n|`r|`n", ' '), $prNumber, $prAuthor) | Set-Content -LiteralPath $fragmentPath -Encoding utf8 + } + } + + $releaseImpact = $selectedImpact + Write-OutputValue -Name release_impact -Value $releaseImpact + if ($releaseImpact -eq 'none') { + Write-OutputValue -Name should_release -Value 'false' + Write-OutputValue -Name skip_reason -Value 'release:none' + Write-Host 'Skipping release: all merged pull requests since latest release tag have release:none.' + return + } +} $versions = @( git tag --list 'v[0-9]*' | diff --git a/.github/workflows/continuous_release.yml b/.github/workflows/continuous_release.yml index 483264c..666b80d 100644 --- a/.github/workflows/continuous_release.yml +++ b/.github/workflows/continuous_release.yml @@ -38,6 +38,7 @@ jobs: if: >- (github.event_name == 'workflow_dispatch' && inputs.recovery_tag == '') || (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master' && !startsWith(github.event.workflow_run.head_commit.message, 'Prepare v')) runs-on: ubuntu-latest @@ -54,16 +55,6 @@ jobs: release-impact: ${{ github.event_name == 'workflow_dispatch' && inputs.release_impact || '' }} prerelease-label: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || '' }} - - name: Create generated changelog fragment - if: steps.plan.outputs.should_release == 'true' && steps.plan.outputs.fragment_path != '' - shell: pwsh - env: - FRAGMENT_PATH: ${{ steps.plan.outputs.fragment_path }} - FRAGMENT_CONTENT: ${{ steps.plan.outputs.fragment_content }} - run: | - New-Item -Path (Split-Path -Path $env:FRAGMENT_PATH -Parent) -ItemType Directory -Force | Out-Null - $env:FRAGMENT_CONTENT | Set-Content -LiteralPath $env:FRAGMENT_PATH -Encoding utf8 - - uses: ./.github/actions/prepare-release-changelog if: steps.plan.outputs.should_release == 'true' with: @@ -82,7 +73,6 @@ jobs: with: release-tag: ${{ steps.plan.outputs.release_tag }} manifest-path: AtlassianPS.Standards/AtlassianPS.Standards.psd1 - github-token: ${{ github.token }} release-bot-token: ${{ secrets.ATLASSIANPS_RELEASE_BOT_TOKEN }} publish: @@ -90,6 +80,7 @@ jobs: if: >- (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master' && startsWith(github.event.workflow_run.head_commit.message, 'Prepare v') && github.event.workflow_run.head_commit.author.name == 'github-actions[bot]') || @@ -106,6 +97,7 @@ jobs: shell: pwsh env: RECOVERY_TAG: ${{ inputs.recovery_tag }} + PREPARED_COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }} run: | if ($env:RECOVERY_TAG) { if ($env:RECOVERY_TAG -notmatch '^v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:-\d+)?)?$') { @@ -121,7 +113,7 @@ jobs: return } - $message = '${{ github.event.workflow_run.head_commit.message }}' + $message = $env:PREPARED_COMMIT_MESSAGE if ($message -notmatch '^Prepare (?v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)(?:-\d+)?)?) release$') { throw "Commit message '$message' is not a release metadata commit." } @@ -188,13 +180,25 @@ jobs: with: tag: ${{ steps.prepared_release.outputs.release_tag }} commit: ${{ steps.prepared_release.outputs.release_commit }} + github-token: ${{ secrets.ATLASSIANPS_RELEASE_BOT_TOKEN }} + + - uses: ./.github/actions/resolve-release-tag + id: release_ref + with: + tag: ${{ steps.prepared_release.outputs.release_tag }} - name: Publish tested module artifact run: | $manifest = Test-ModuleManifest -Path ./Release/AtlassianPS.Standards/AtlassianPS.Standards.psd1 -ErrorAction Stop - $published = Find-Module -Name $manifest.Name -RequiredVersion $manifest.Version -AllowPrerelease -ErrorAction SilentlyContinue + $prerelease = [String]$manifest.PrivateData.PSData.Prerelease + $expectedGalleryVersion = if ($prerelease) { "$($manifest.Version)-$prerelease" } else { [String]$manifest.Version } + $published = Find-Module -Name $manifest.Name -AllVersions -AllowPrerelease -ErrorAction Stop | + Where-Object { + $_.Version.ToString() -eq $expectedGalleryVersion + } | + Select-Object -First 1 if ($published) { - Write-Host "PSGallery already contains $($manifest.Name) $($manifest.Version); skipping immutable publish." + Write-Host "PSGallery already contains $($manifest.Name) $expectedGalleryVersion; skipping immutable publish." return } Publish-Module -Path ./Release/AtlassianPS.Standards -NuGetApiKey ${{ secrets.PSGALLERY_API_KEY }} -ErrorAction Stop @@ -203,21 +207,21 @@ jobs: - name: Create GitHub release and upload asset uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - tag_name: ${{ steps.prepared_release.outputs.release_tag }} - name: ${{ steps.prepared_release.outputs.release_tag }} + tag_name: ${{ steps.release_ref.outputs.release_tag }} + name: ${{ steps.release_ref.outputs.release_tag }} body_path: ${{ steps.release_notes.outputs.release_notes_path }} files: ./Release/AtlassianPS.Standards.zip fail_on_unmatched_files: true draft: false - prerelease: ${{ contains(steps.prepared_release.outputs.release_tag, '-alpha') || contains(steps.prepared_release.outputs.release_tag, '-beta') || contains(steps.prepared_release.outputs.release_tag, '-rc') }} + prerelease: ${{ contains(steps.release_ref.outputs.release_tag, '-alpha') || contains(steps.release_ref.outputs.release_tag, '-beta') || contains(steps.release_ref.outputs.release_tag, '-rc') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Notify homepage to update submodule - if: ${{ !contains(steps.prepared_release.outputs.release_tag, '-alpha') && !contains(steps.prepared_release.outputs.release_tag, '-beta') && !contains(steps.prepared_release.outputs.release_tag, '-rc') }} + if: ${{ !contains(steps.release_ref.outputs.release_tag, '-alpha') && !contains(steps.release_ref.outputs.release_tag, '-beta') && !contains(steps.release_ref.outputs.release_tag, '-rc') }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: token: ${{ secrets.HOMEPAGE_PAT }} repository: AtlassianPS/AtlassianPS.github.io event-type: module-release - client-payload: '{"module": "AtlassianPS.Standards", "version": "${{ steps.prepared_release.outputs.release_tag }}"}' + client-payload: '{"module": "AtlassianPS.Standards", "version": "${{ steps.release_ref.outputs.release_tag }}"}' diff --git a/AGENTS.md b/AGENTS.md index 4c36f25..9350ca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ If guidance conflicts, follow this file first. - Pull requests should declare release intent with exactly one `release:*` label; user-facing changes also need a `changelog:*` label or a valid `.changelog/...md` fragment. - Do not ask contributors to choose the final release version in normal PRs; release preparation batches merged intent later. - Keep release notes sourced from one `CHANGELOG.md` section for both GitHub releases and PSGallery manifest `PrivateData.PSData.ReleaseNotes`. +- `ATLASSIANPS_RELEASE_BOT_TOKEN` must be a GitHub App or fine-grained token. `GITHUB_TOKEN` cannot start follow-up CI after metadata push. +- Do not enable release publishing on an unprotected `master`; direct pushes can bypass review and imitate release metadata. - For a partial release with existing tag, follow `docs/ReleaseBlueprint.md` "Recovery Runbook". Do not delete/recreate tag or republish PSGallery version. - When changing release behavior, update `docs/ReleaseBlueprint.md`, `docs/BlueprintHelpers.md`, tests, and these agent instructions together. diff --git a/AtlassianPS.Standards.build.ps1 b/AtlassianPS.Standards.build.ps1 index 8a8bb4f..f5af1a9 100644 --- a/AtlassianPS.Standards.build.ps1 +++ b/AtlassianPS.Standards.build.ps1 @@ -167,11 +167,13 @@ Task VerifyReleaseArtifact Package, { } $expectedCore = $script:BuildInfo.VersionToPublish -replace '-.*$', '' + $expectedPrerelease = if ($script:BuildInfo.VersionToPublish -match '-(?