diff --git a/.github/scripts/apply-version.ps1 b/.github/scripts/apply-version.ps1 new file mode 100644 index 00000000..b9b45b73 --- /dev/null +++ b/.github/scripts/apply-version.ps1 @@ -0,0 +1,87 @@ +param( + [Parameter(Mandatory = $false)] + [string]$BuildNumber = "local", + + [Parameter(Mandatory = $false)] + [string]$VersionFile = "version.cmake", + + [Parameter(Mandatory = $false)] + [string]$HeaderPath = "src/Elemental/Elemental.h" +) + +$ErrorActionPreference = "Stop" + +function Get-CMakeValue +{ + param( + [string]$Content, + [string]$Name + ) + + $pattern = '(?m)^\s*set\(\s*' + [Regex]::Escape($Name) + '\s+"(?[^"]*)"\s*\)\s*$' + $match = [Regex]::Match($Content, $pattern) + + if (!$match.Success) + { + throw "Unable to read $Name from $VersionFile." + } + + return $match.Groups["value"].Value +} + +$versionContent = Get-Content -Path $VersionFile -Raw +$major = Get-CMakeValue $versionContent "ELEM_VERSION_MAJOR" +$minor = Get-CMakeValue $versionContent "ELEM_VERSION_MINOR" +$patch = Get-CMakeValue $versionContent "ELEM_VERSION_PATCH" +$stage = Get-CMakeValue $versionContent "ELEM_VERSION_STAGE" + +if ($major -notmatch '^\d+$' -or $minor -notmatch '^\d+$' -or $patch -notmatch '^\d+$') +{ + throw "Elemental semantic version components must be numeric." +} + +if (![string]::IsNullOrWhiteSpace($stage) -and $stage -notmatch '^(dev|alpha|beta|rc)$') +{ + throw "Unsupported Elemental release stage: $stage" +} + +$productVersion = "$major.$minor.$patch" + +if ([string]::IsNullOrWhiteSpace($stage)) +{ + $version = $productVersion +} +else +{ + if ($BuildNumber -notmatch '^(local|\d+)$') + { + throw "Prerelease build number must be 'local' or numeric: $BuildNumber" + } + + $version = "$productVersion-$stage.$BuildNumber" +} + +$header = Get-Content -Path $HeaderPath -Raw +$versionCommentPattern = '(?m)^// Version: [^\r\n]*(?=\r?$)' +$versionMacroPattern = '(?m)^#define ELEM_VERSION_LABEL "[^"\r\n]*"(?=\r?$)' + +if ([Regex]::Matches($header, $versionCommentPattern).Count -ne 1) +{ + throw "Expected exactly one version comment in $HeaderPath." +} + +if ([Regex]::Matches($header, $versionMacroPattern).Count -ne 1) +{ + throw "Expected exactly one ELEM_VERSION_LABEL definition in $HeaderPath." +} + +$header = [Regex]::Replace($header, $versionCommentPattern, "// Version: $version") +$header = [Regex]::Replace($header, $versionMacroPattern, "#define ELEM_VERSION_LABEL `"$version`"") + +[System.IO.File]::WriteAllText( + $HeaderPath, + $header, + [System.Text.UTF8Encoding]::new($false) +) + +Write-Host "Elemental build version: $version" diff --git a/.github/scripts/release-notes.ps1 b/.github/scripts/release-notes.ps1 new file mode 100644 index 00000000..4ae680d2 --- /dev/null +++ b/.github/scripts/release-notes.ps1 @@ -0,0 +1,205 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Version, + + [Parameter(Mandatory = $true)] + [string]$Repository, + + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +$ErrorActionPreference = "Stop" + +if ($Version -notmatch '^(?\d+\.\d+\.\d+)(?:-(?dev|alpha|beta|rc)\.(?\d+))?$') +{ + throw "Unsupported release version: $Version" +} + +$productVersion = $Matches["product"] +$stage = $Matches["stage"] + +function Get-PreviousTag +{ + param( + [string]$Revision + ) + + $tag = & git describe --tags --abbrev=0 $Revision 2>$null + + if ($LASTEXITCODE -ne 0) + { + return $null + } + + return ($tag | Select-Object -First 1).Trim() +} + +function Get-LatestStableTag +{ + $tags = @( + & git tag --merged HEAD --sort=-version:refname --list "v*" | + Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } + ) + + if ($tags.Count -eq 0) + { + return $null + } + + return $tags[0].Trim() +} + +$previousTag = Get-PreviousTag "HEAD^" +$baseTag = $null +$rangeDescription = $null +$sameStageAsPreviousRelease = $false + +if (![string]::IsNullOrWhiteSpace($stage) -and $null -ne $previousTag -and + $previousTag -match '^v(?\d+\.\d+\.\d+)-(?dev|alpha|beta|rc)\.\d+$') +{ + $sameStageAsPreviousRelease = + $Matches["product"] -eq $productVersion -and + $Matches["stage"] -eq $stage +} + +if ($sameStageAsPreviousRelease) +{ + $baseTag = $previousTag + $rangeDescription = "Includes changes since $baseTag." +} +else +{ + $baseTag = Get-LatestStableTag + + $rangeDescription = if ($null -eq $baseTag) + { + "Includes all changes in the $productVersion release cycle since the beginning of the project." + } + else + { + "Includes all changes in the $productVersion release cycle since $baseTag." + } +} + +$commitArguments = if ($null -eq $baseTag) +{ + @("rev-list", "--reverse", "HEAD") +} +else +{ + @("rev-list", "--reverse", "$baseTag..HEAD") +} + +$commits = @(& git @commitArguments) + +if ($LASTEXITCODE -ne 0) +{ + throw "Unable to determine commits for the release notes." +} + +$pullRequests = @{} +$directCommits = [System.Collections.Generic.List[object]]::new() + +foreach ($commit in $commits) +{ + $sha = $commit.Trim() + + if ([string]::IsNullOrWhiteSpace($sha)) + { + continue + } + + $json = (& gh api "repos/$Repository/commits/$sha/pulls" | Out-String) + + if ($LASTEXITCODE -ne 0) + { + throw "Unable to find pull requests associated with commit $sha." + } + + $associatedPullRequests = @( + $json | ConvertFrom-Json | + Where-Object { $null -ne $_.merged_at -and $_.base.ref -eq "main" } + ) + + if ($associatedPullRequests.Count -gt 0) + { + foreach ($pullRequest in $associatedPullRequests) + { + $pullRequests[$pullRequest.number.ToString()] = $pullRequest + } + + continue + } + + $subject = (& git show -s --format=%s $sha | Out-String).Trim() + $shortSha = (& git rev-parse --short $sha | Out-String).Trim() + + $directCommits.Add([pscustomobject]@{ + Sha = $shortSha + Subject = $subject + }) +} + +$orderedPullRequests = @( + $pullRequests.Values | + Sort-Object @{ Expression = { [DateTime]$_.merged_at } }, @{ Expression = { [int]$_.number } } +) + +$releaseUrl = "https://github.com/$Repository/releases/tag/v$Version" +$notes = [System.Collections.Generic.List[string]]::new() +$notes.Add("# Elemental [**$Version**]($releaseUrl)") +$notes.Add("") +$notes.Add($rangeDescription) +$notes.Add("") + +if ($orderedPullRequests.Count -gt 0) +{ + $notes.Add("## Pull requests") + $notes.Add("") + + foreach ($pullRequest in $orderedPullRequests) + { + $notes.Add("### [#$($pullRequest.number)]($($pullRequest.html_url)) — $($pullRequest.title)") + $notes.Add("") + + if ([string]::IsNullOrWhiteSpace($pullRequest.body)) + { + $notes.Add("_No description provided._") + } + else + { + $notes.Add($pullRequest.body.Trim()) + } + + $notes.Add("") + } +} + +if ($directCommits.Count -gt 0) +{ + $notes.Add("## Direct commits") + $notes.Add("") + + foreach ($commit in $directCommits) + { + $notes.Add("- ``$($commit.Sha)`` $($commit.Subject)") + } + + $notes.Add("") +} + +if ($orderedPullRequests.Count -eq 0 -and $directCommits.Count -eq 0) +{ + $notes.Add("No changes were found for this release range.") + $notes.Add("") +} + +$parentDirectory = Split-Path -Parent $OutputPath + +if (![string]::IsNullOrWhiteSpace($parentDirectory)) +{ + New-Item -ItemType Directory -Path $parentDirectory -Force | Out-Null +} + +$notes | Set-Content -Path $OutputPath -Encoding utf8 diff --git a/.github/scripts/resolve-version.ps1 b/.github/scripts/resolve-version.ps1 new file mode 100644 index 00000000..3ae64dfd --- /dev/null +++ b/.github/scripts/resolve-version.ps1 @@ -0,0 +1,108 @@ +param( + [Parameter(Mandatory = $false)] + [string]$VersionFile = "version.cmake" +) + +$ErrorActionPreference = "Stop" + +function Get-CMakeValue +{ + param( + [string]$Content, + [string]$Name + ) + + $pattern = '(?m)^\s*set\(\s*' + [Regex]::Escape($Name) + '\s+"(?[^"]*)"\s*\)\s*$' + $match = [Regex]::Match($Content, $pattern) + + if (!$match.Success) + { + throw "Unable to read $Name from $VersionFile." + } + + return $match.Groups["value"].Value +} + +$versionContent = Get-Content -Path $VersionFile -Raw +$major = Get-CMakeValue $versionContent "ELEM_VERSION_MAJOR" +$minor = Get-CMakeValue $versionContent "ELEM_VERSION_MINOR" +$patch = Get-CMakeValue $versionContent "ELEM_VERSION_PATCH" +$stage = Get-CMakeValue $versionContent "ELEM_VERSION_STAGE" + +if ($major -notmatch '^\d+$' -or $minor -notmatch '^\d+$' -or $patch -notmatch '^\d+$') +{ + throw "Elemental semantic version components must be numeric." +} + +if (![string]::IsNullOrWhiteSpace($stage) -and $stage -notmatch '^(dev|alpha|beta|rc)$') +{ + throw "Unsupported Elemental release stage: $stage" +} + +$tags = @(& git tag --merged HEAD --list "v*") + +if ($LASTEXITCODE -ne 0) +{ + throw "Unable to inspect existing Elemental release tags." +} + +$productVersion = "$major.$minor.$patch" +$isPrerelease = ![string]::IsNullOrWhiteSpace($stage) + +if ($isPrerelease) +{ + $buildNumbers = @( + $tags | + ForEach-Object { + if ($_ -match '^v\d+\.\d+\.\d+-(?:dev|alpha|beta|rc)\.(?\d+)$') + { + [int64]$Matches["build"] + } + } + ) + + $buildNumber = if ($buildNumbers.Count -eq 0) + { + 1 + } + else + { + ([int64]($buildNumbers | Measure-Object -Maximum).Maximum) + 1 + } + + $version = "$productVersion-$stage.$buildNumber" +} +else +{ + $buildNumber = 0 + $version = $productVersion + + $stableNotesPath = ".github/release-notes/$version.md" + + if (!(Test-Path -Path $stableNotesPath)) + { + throw "Stable release $version requires editorial notes at $stableNotesPath." + } +} + +$tag = "v$version" + +if ($tags -contains $tag) +{ + throw "Release identity already exists: $tag" +} + +if ([string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) +{ + Write-Host "version=$version" + Write-Host "tag=$tag" + Write-Host "build_number=$buildNumber" + Write-Host "prerelease=$($isPrerelease.ToString().ToLowerInvariant())" +} +else +{ + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "build_number=$buildNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "prerelease=$($isPrerelease.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 +} diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 37bb2721..d2f56627 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -1,12 +1,34 @@ name: Build CI on: - push: - branches: - - main pull_request: jobs: + validate_release: + runs-on: ubuntu-latest + name: validate-release + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Resolve next release version + id: version + shell: pwsh + run: ./.github/scripts/resolve-version.ps1 + + - name: Generate release notes preview + if: steps.version.outputs.prerelease == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + & ./.github/scripts/release-notes.ps1 ` + -Version "${{ steps.version.outputs.version }}" ` + -Repository "${{ github.repository }}" ` + -OutputPath (Join-Path $env:RUNNER_TEMP "release-notes.md") + build: strategy: fail-fast: false @@ -36,12 +58,13 @@ jobs: if: always() runs-on: ubuntu-latest name: CI Build Check - needs: [build, run_tests] + needs: [validate_release, build, run_tests] steps: - - name: Check build and test results + - name: Check build, test and release validation results if: >- ${{ - needs.build.result != 'success' + needs.validate_release.result != 'success' + || needs.build.result != 'success' || needs.run_tests.result != 'success' }} run: exit 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09058670..2c824059 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,10 @@ on: architecture: required: true type: string + build_number: + required: false + type: string + default: local jobs: build: @@ -21,6 +25,10 @@ jobs: with: submodules: recursive + - name: Apply build version + shell: pwsh + run: ./.github/scripts/apply-version.ps1 -BuildNumber "${{ inputs.build_number }}" + - name: Setup Dependencies (Windows) if: inputs.platform == 'win' uses: TheMrMilchmann/setup-msvc-dev@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..56010821 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,173 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: read + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + version: + name: Resolve version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + build_number: ${{ steps.version.outputs.build_number }} + prerelease: ${{ steps.version.outputs.prerelease }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Resolve release version + id: version + shell: pwsh + run: ./.github/scripts/resolve-version.ps1 + + build: + needs: [version] + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(vars.Platforms) }} + uses: ./.github/workflows/build.yml + name: build + secrets: inherit + with: + platform: ${{ matrix.platform }} + architecture: ${{ matrix.architecture }} + build_number: ${{ needs.version.outputs.build_number }} + + run_tests: + needs: [build] + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(vars.Platforms) }} + uses: ./.github/workflows/run-tests.yml + name: run-tests + secrets: inherit + with: + platform: ${{ matrix.platform }} + architecture: ${{ matrix.architecture }} + + release: + name: Publish release + needs: [version, build, run_tests] + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + pattern: "*" + path: ${{ runner.temp }}/artifacts + merge-multiple: true + + - name: Generate release notes + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $notesPath = Join-Path $env:RUNNER_TEMP "release-notes.md" + $version = "${{ needs.version.outputs.version }}" + + if ("${{ needs.version.outputs.prerelease }}" -eq "true") + { + & ./.github/scripts/release-notes.ps1 ` + -Version $version ` + -Repository "${{ github.repository }}" ` + -OutputPath $notesPath + } + else + { + $stableNotesPath = ".github/release-notes/$version.md" + + if (!(Test-Path -Path $stableNotesPath)) + { + throw "Stable release notes are missing: $stableNotesPath" + } + + Copy-Item -Path $stableNotesPath -Destination $notesPath + } + + - name: Verify release identity is unused + shell: pwsh + run: | + $tag = "${{ needs.version.outputs.tag }}" + $existingTag = (& git ls-remote --tags origin "refs/tags/$tag" | Out-String).Trim() + + if (![string]::IsNullOrWhiteSpace($existingTag)) + { + throw "Release identity already exists: $tag" + } + + - name: Create tag and GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $version = "${{ needs.version.outputs.version }}" + $tag = "${{ needs.version.outputs.tag }}" + $artifactPath = Join-Path $env:RUNNER_TEMP "artifacts" + $notesPath = Join-Path $env:RUNNER_TEMP "release-notes.md" + + $assets = @( + Get-ChildItem -Path $artifactPath -File | + Where-Object { $_.Name -match '^(elemental(?:-tools)?|samples)\.' } | + Sort-Object Name + ) + + if ($assets.Count -eq 0) + { + throw "No release assets were found." + } + + $arguments = @( + "release", + "create", + $tag + ) + + foreach ($asset in $assets) + { + $arguments += $asset.FullName + } + + $arguments += @( + "--repo", + "${{ github.repository }}", + "--title", + "Elemental $version", + "--notes-file", + $notesPath, + "--target", + "${{ github.sha }}" + ) + + if ("${{ needs.version.outputs.prerelease }}" -eq "true") + { + $arguments += "--prerelease" + } + + & gh @arguments + + if ($LASTEXITCODE -ne 0) + { + exit $LASTEXITCODE + } diff --git a/version.cmake b/version.cmake new file mode 100644 index 00000000..0bddab40 --- /dev/null +++ b/version.cmake @@ -0,0 +1,4 @@ +set(ELEM_VERSION_MAJOR "1") +set(ELEM_VERSION_MINOR "0") +set(ELEM_VERSION_PATCH "0") +set(ELEM_VERSION_STAGE "dev")