Skip to content

Commit fdf1a35

Browse files
authored
Add automatic versioned releases (#111)
Add the first Elemental automatic release pipeline while reusing the existing build/test/package mechanics. ## Versioning - add `version.cmake` as the semantic version/stage source (`1.0.0`, `dev`) - resolve prerelease build numbers from the highest existing numeric release tag, so the normalized historical `dev.1`…`dev.5` sequence naturally continues at `dev.6` - keep the build counter monotonic across `dev`, `alpha`, `beta`, and `rc` - fail if the resolved tag already exists instead of moving/reusing release identity - require `.github/release-notes/<version>.md` before a stable release can proceed ## Build / CI topology - keep `Build CI` as the required pull-request build/test workflow - stop running that same matrix separately on pushes to `main` - add an optional `build_number` input to the existing reusable `build.yml` - inject the resolved version into the existing public header inside each ephemeral build checkout before configure/build/install - preserve the current `cmake --install` staging and archive layout - validate release-version resolution and prerelease-note generation on pull requests without publishing ## Releases - add a serialized `Release` workflow for pushes to `main` - run the existing build/test matrix once and reuse its artifacts for release publication - publish Elemental, ElementalTools, and sample binary archives as durable GitHub Release assets; test archives remain CI-only - create `v<version>` on the exact `main` SHA - mark `dev` / `alpha` / `beta` / `rc` releases as prereleases ## Release notes - adapt the WoW Stream Overlay PR-body/direct-commit release-note generator to Elemental and `beta` - same-stage prereleases describe changes since the previous release - stage transitions summarize the broader release cycle since the latest stable tag - stable releases use the versioned editorial file from `.github/release-notes/<version>.md` ## Validation - Build CI #480: Windows x64, macOS arm64, Linux x64 build/install/package/tests all passed after the cross-platform version-injection fix - Build CI #482 on current head: all build/test jobs passed - `validate-release` resolves the next version as `1.0.0-dev.6` and generates the prerelease notes preview successfully - CI caught and fixed a Windows CRLF issue in the header rewrite and a PowerShell-native-command issue in the release-tag existence probe Sample asset versioning remains intentionally out of scope. This PR only makes the already-built binary/sample packages durable release assets. Do not merge without Thomas's explicit approval.
1 parent a24af9b commit fdf1a35

7 files changed

Lines changed: 614 additions & 6 deletions

File tree

.github/scripts/apply-version.ps1

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
param(
2+
[Parameter(Mandatory = $false)]
3+
[string]$BuildNumber = "local",
4+
5+
[Parameter(Mandatory = $false)]
6+
[string]$VersionFile = "version.cmake",
7+
8+
[Parameter(Mandatory = $false)]
9+
[string]$HeaderPath = "src/Elemental/Elemental.h"
10+
)
11+
12+
$ErrorActionPreference = "Stop"
13+
14+
function Get-CMakeValue
15+
{
16+
param(
17+
[string]$Content,
18+
[string]$Name
19+
)
20+
21+
$pattern = '(?m)^\s*set\(\s*' + [Regex]::Escape($Name) + '\s+"(?<value>[^"]*)"\s*\)\s*$'
22+
$match = [Regex]::Match($Content, $pattern)
23+
24+
if (!$match.Success)
25+
{
26+
throw "Unable to read $Name from $VersionFile."
27+
}
28+
29+
return $match.Groups["value"].Value
30+
}
31+
32+
$versionContent = Get-Content -Path $VersionFile -Raw
33+
$major = Get-CMakeValue $versionContent "ELEM_VERSION_MAJOR"
34+
$minor = Get-CMakeValue $versionContent "ELEM_VERSION_MINOR"
35+
$patch = Get-CMakeValue $versionContent "ELEM_VERSION_PATCH"
36+
$stage = Get-CMakeValue $versionContent "ELEM_VERSION_STAGE"
37+
38+
if ($major -notmatch '^\d+$' -or $minor -notmatch '^\d+$' -or $patch -notmatch '^\d+$')
39+
{
40+
throw "Elemental semantic version components must be numeric."
41+
}
42+
43+
if (![string]::IsNullOrWhiteSpace($stage) -and $stage -notmatch '^(dev|alpha|beta|rc)$')
44+
{
45+
throw "Unsupported Elemental release stage: $stage"
46+
}
47+
48+
$productVersion = "$major.$minor.$patch"
49+
50+
if ([string]::IsNullOrWhiteSpace($stage))
51+
{
52+
$version = $productVersion
53+
}
54+
else
55+
{
56+
if ($BuildNumber -notmatch '^(local|\d+)$')
57+
{
58+
throw "Prerelease build number must be 'local' or numeric: $BuildNumber"
59+
}
60+
61+
$version = "$productVersion-$stage.$BuildNumber"
62+
}
63+
64+
$header = Get-Content -Path $HeaderPath -Raw
65+
$versionCommentPattern = '(?m)^// Version: [^\r\n]*(?=\r?$)'
66+
$versionMacroPattern = '(?m)^#define ELEM_VERSION_LABEL "[^"\r\n]*"(?=\r?$)'
67+
68+
if ([Regex]::Matches($header, $versionCommentPattern).Count -ne 1)
69+
{
70+
throw "Expected exactly one version comment in $HeaderPath."
71+
}
72+
73+
if ([Regex]::Matches($header, $versionMacroPattern).Count -ne 1)
74+
{
75+
throw "Expected exactly one ELEM_VERSION_LABEL definition in $HeaderPath."
76+
}
77+
78+
$header = [Regex]::Replace($header, $versionCommentPattern, "// Version: $version")
79+
$header = [Regex]::Replace($header, $versionMacroPattern, "#define ELEM_VERSION_LABEL `"$version`"")
80+
81+
[System.IO.File]::WriteAllText(
82+
$HeaderPath,
83+
$header,
84+
[System.Text.UTF8Encoding]::new($false)
85+
)
86+
87+
Write-Host "Elemental build version: $version"

.github/scripts/release-notes.ps1

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string]$Version,
4+
5+
[Parameter(Mandatory = $true)]
6+
[string]$Repository,
7+
8+
[Parameter(Mandatory = $true)]
9+
[string]$OutputPath
10+
)
11+
12+
$ErrorActionPreference = "Stop"
13+
14+
if ($Version -notmatch '^(?<product>\d+\.\d+\.\d+)(?:-(?<stage>dev|alpha|beta|rc)\.(?<build>\d+))?$')
15+
{
16+
throw "Unsupported release version: $Version"
17+
}
18+
19+
$productVersion = $Matches["product"]
20+
$stage = $Matches["stage"]
21+
22+
function Get-PreviousTag
23+
{
24+
param(
25+
[string]$Revision
26+
)
27+
28+
$tag = & git describe --tags --abbrev=0 $Revision 2>$null
29+
30+
if ($LASTEXITCODE -ne 0)
31+
{
32+
return $null
33+
}
34+
35+
return ($tag | Select-Object -First 1).Trim()
36+
}
37+
38+
function Get-LatestStableTag
39+
{
40+
$tags = @(
41+
& git tag --merged HEAD --sort=-version:refname --list "v*" |
42+
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' }
43+
)
44+
45+
if ($tags.Count -eq 0)
46+
{
47+
return $null
48+
}
49+
50+
return $tags[0].Trim()
51+
}
52+
53+
$previousTag = Get-PreviousTag "HEAD^"
54+
$baseTag = $null
55+
$rangeDescription = $null
56+
$sameStageAsPreviousRelease = $false
57+
58+
if (![string]::IsNullOrWhiteSpace($stage) -and $null -ne $previousTag -and
59+
$previousTag -match '^v(?<product>\d+\.\d+\.\d+)-(?<stage>dev|alpha|beta|rc)\.\d+$')
60+
{
61+
$sameStageAsPreviousRelease =
62+
$Matches["product"] -eq $productVersion -and
63+
$Matches["stage"] -eq $stage
64+
}
65+
66+
if ($sameStageAsPreviousRelease)
67+
{
68+
$baseTag = $previousTag
69+
$rangeDescription = "Includes changes since $baseTag."
70+
}
71+
else
72+
{
73+
$baseTag = Get-LatestStableTag
74+
75+
$rangeDescription = if ($null -eq $baseTag)
76+
{
77+
"Includes all changes in the $productVersion release cycle since the beginning of the project."
78+
}
79+
else
80+
{
81+
"Includes all changes in the $productVersion release cycle since $baseTag."
82+
}
83+
}
84+
85+
$commitArguments = if ($null -eq $baseTag)
86+
{
87+
@("rev-list", "--reverse", "HEAD")
88+
}
89+
else
90+
{
91+
@("rev-list", "--reverse", "$baseTag..HEAD")
92+
}
93+
94+
$commits = @(& git @commitArguments)
95+
96+
if ($LASTEXITCODE -ne 0)
97+
{
98+
throw "Unable to determine commits for the release notes."
99+
}
100+
101+
$pullRequests = @{}
102+
$directCommits = [System.Collections.Generic.List[object]]::new()
103+
104+
foreach ($commit in $commits)
105+
{
106+
$sha = $commit.Trim()
107+
108+
if ([string]::IsNullOrWhiteSpace($sha))
109+
{
110+
continue
111+
}
112+
113+
$json = (& gh api "repos/$Repository/commits/$sha/pulls" | Out-String)
114+
115+
if ($LASTEXITCODE -ne 0)
116+
{
117+
throw "Unable to find pull requests associated with commit $sha."
118+
}
119+
120+
$associatedPullRequests = @(
121+
$json | ConvertFrom-Json |
122+
Where-Object { $null -ne $_.merged_at -and $_.base.ref -eq "main" }
123+
)
124+
125+
if ($associatedPullRequests.Count -gt 0)
126+
{
127+
foreach ($pullRequest in $associatedPullRequests)
128+
{
129+
$pullRequests[$pullRequest.number.ToString()] = $pullRequest
130+
}
131+
132+
continue
133+
}
134+
135+
$subject = (& git show -s --format=%s $sha | Out-String).Trim()
136+
$shortSha = (& git rev-parse --short $sha | Out-String).Trim()
137+
138+
$directCommits.Add([pscustomobject]@{
139+
Sha = $shortSha
140+
Subject = $subject
141+
})
142+
}
143+
144+
$orderedPullRequests = @(
145+
$pullRequests.Values |
146+
Sort-Object @{ Expression = { [DateTime]$_.merged_at } }, @{ Expression = { [int]$_.number } }
147+
)
148+
149+
$releaseUrl = "https://github.com/$Repository/releases/tag/v$Version"
150+
$notes = [System.Collections.Generic.List[string]]::new()
151+
$notes.Add("# Elemental [**$Version**]($releaseUrl)")
152+
$notes.Add("")
153+
$notes.Add($rangeDescription)
154+
$notes.Add("")
155+
156+
if ($orderedPullRequests.Count -gt 0)
157+
{
158+
$notes.Add("## Pull requests")
159+
$notes.Add("")
160+
161+
foreach ($pullRequest in $orderedPullRequests)
162+
{
163+
$notes.Add("### [#$($pullRequest.number)]($($pullRequest.html_url)) — $($pullRequest.title)")
164+
$notes.Add("")
165+
166+
if ([string]::IsNullOrWhiteSpace($pullRequest.body))
167+
{
168+
$notes.Add("_No description provided._")
169+
}
170+
else
171+
{
172+
$notes.Add($pullRequest.body.Trim())
173+
}
174+
175+
$notes.Add("")
176+
}
177+
}
178+
179+
if ($directCommits.Count -gt 0)
180+
{
181+
$notes.Add("## Direct commits")
182+
$notes.Add("")
183+
184+
foreach ($commit in $directCommits)
185+
{
186+
$notes.Add("- ``$($commit.Sha)`` $($commit.Subject)")
187+
}
188+
189+
$notes.Add("")
190+
}
191+
192+
if ($orderedPullRequests.Count -eq 0 -and $directCommits.Count -eq 0)
193+
{
194+
$notes.Add("No changes were found for this release range.")
195+
$notes.Add("")
196+
}
197+
198+
$parentDirectory = Split-Path -Parent $OutputPath
199+
200+
if (![string]::IsNullOrWhiteSpace($parentDirectory))
201+
{
202+
New-Item -ItemType Directory -Path $parentDirectory -Force | Out-Null
203+
}
204+
205+
$notes | Set-Content -Path $OutputPath -Encoding utf8

0 commit comments

Comments
 (0)