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 482a7be..1d051d2 100644 --- a/.github/actions/create-release-tag/action.yml +++ b/.github/actions/create-release-tag/action.yml @@ -1,9 +1,16 @@ 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 + github-token: + description: GitHub App or fine-grained token that creates the release tag. + required: true runs: using: composite steps: @@ -11,15 +18,23 @@ runs: shell: bash env: RELEASE_TAG: ${{ inputs.tag }} + RELEASE_COMMIT: ${{ inputs.commit }} + RELEASE_BOT_TOKEN: ${{ inputs.github-token }} 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 push origin "refs/tags/${RELEASE_TAG}" + git tag -a "${RELEASE_TAG}" "${expected_commit}" -m "${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 7aef313..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 - $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 - $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 -} +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 ($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 + } + + $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/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..6f99700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ concurrency: on: pull_request: branches: [master] - paths-ignore: &paths-ignore + paths-ignore: - "CHANGELOG.md" - "LICENSE" # AI assistant instruction files @@ -27,15 +27,17 @@ on: - ".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 +48,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 +65,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 +73,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 +87,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 +105,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 +116,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..666b80d 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,14 @@ 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.event == 'push' && 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 }} @@ -50,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: @@ -78,74 +73,139 @@ 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: 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.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]' + 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 }} + PREPARED_COMMIT_MESSAGE: ${{ github.event.workflow_run.head_commit.message }} run: | - $message = '${{ github.event.workflow_run.head_commit.message }}' + 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 = $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." + 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 }} + 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: 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 + $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) $expectedGalleryVersion; 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 }} @@ -159,7 +219,7 @@ jobs: - 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 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: token: ${{ secrets.HOMEPAGE_PAT }} repository: AtlassianPS/AtlassianPS.github.io 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..9350ca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,9 @@ 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. ## Build, Lint, Test (run from repo root) @@ -74,4 +77,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 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/AtlassianPS.Standards.build.ps1 b/AtlassianPS.Standards.build.ps1 index 87c2a99..f5af1a9 100644 --- a/AtlassianPS.Standards.build.ps1 +++ b/AtlassianPS.Standards.build.ps1 @@ -155,13 +155,29 @@ 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 '-.*$', '' + $expectedPrerelease = if ($script:BuildInfo.VersionToPublish -match '-(?