Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 5 additions & 19 deletions .github/actions/commit-release-metadata/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}"
23 changes: 19 additions & 4 deletions .github/actions/create-release-tag/action.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
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:
- name: Create annotated release tag
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
4 changes: 2 additions & 2 deletions .github/actions/plan-merged-release/action.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
152 changes: 83 additions & 69 deletions .github/actions/plan-merged-release/plan-merged-release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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}\.(?<impact>patch|minor|major)\.(?<type>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.<patch|minor|major>.<added|changed|fixed|removed|deprecated|security|breaking>.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}\.(?<impact>patch|minor|major)\.(?<type>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.<patch|minor|major>.<added|changed|fixed|removed|deprecated|security|breaking>.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]*' |
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/setup-powershell/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ concurrency:
on:
pull_request:
branches: [master]
paths-ignore: &paths-ignore
paths-ignore:
- "CHANGELOG.md"
- "LICENSE"
# AI assistant instruction files
Expand All @@ -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
Expand All @@ -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/
Expand All @@ -63,15 +65,15 @@ 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"
# Setup is run below in the powershell (PS 5.1) shell instead of pwsh,
# 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/
Expand All @@ -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
Expand All @@ -103,18 +105,18 @@ 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/

- 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
Expand Down
Loading