Release Build #47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Build | |
| on: | |
| pull_request: | |
| types: [closed] | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Release tag to build (e.g. v0.1.7)" | |
| required: false | |
| type: string | |
| version_number: | |
| description: "Version number for compilation (e.g. 0.1.7)" | |
| required: false | |
| type: string | |
| notes: | |
| description: "Release notes override" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| actions: write | |
| checks: write | |
| jobs: | |
| prepare: | |
| name: Prepare release (check files, version, notes) | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_release: ${{ steps.override.outputs.should_release || steps.check_files.outputs.should_release }} | |
| version: ${{ steps.override.outputs.version || steps.next_version.outputs.version }} | |
| version_number: ${{ steps.override.outputs.version_number || steps.next_version.outputs.version_number }} | |
| notes: ${{ steps.override.outputs.notes || steps.parse_commit.outputs.notes }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Override outputs for manual runs | |
| if: github.event_name == 'workflow_dispatch' | |
| id: override | |
| shell: pwsh | |
| run: | | |
| Write-Output "should_release=true" >> $env:GITHUB_OUTPUT | |
| if (-not [string]::IsNullOrWhiteSpace("${{ inputs.version }}")) { | |
| Write-Output "version=${{ inputs.version }}" >> $env:GITHUB_OUTPUT | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace("${{ inputs.version_number }}")) { | |
| Write-Output "version_number=${{ inputs.version_number }}" >> $env:GITHUB_OUTPUT | |
| } elseif (-not [string]::IsNullOrWhiteSpace("${{ inputs.version }}")) { | |
| $verNum = "${{ inputs.version }}" | |
| if ($verNum.StartsWith("v")) { $verNum = $verNum.Substring(1) } | |
| Write-Output "version_number=$verNum" >> $env:GITHUB_OUTPUT | |
| } | |
| if (-not [string]::IsNullOrWhiteSpace("${{ inputs.notes }}")) { | |
| Write-Output "notes=${{ inputs.notes }}" >> $env:GITHUB_OUTPUT | |
| } | |
| - name: Resolve last merged PR for manual runs | |
| if: github.event_name == 'workflow_dispatch' | |
| id: resolve_pr | |
| shell: pwsh | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| $repo = "${{ github.repository }}" | |
| $headers = @{"Authorization" = "token $env:GITHUB_TOKEN"; "Accept" = "application/vnd.github.v3+json"} | |
| $prs = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=50" -Headers $headers -UseBasicParsing | |
| $merged = $prs | Where-Object { $_.merged_at -ne $null } | Select-Object -First 1 | |
| if (-not $merged) { | |
| Write-Error "No merged PR found for base=main" | |
| exit 1 | |
| } | |
| Write-Output "base_sha=$($merged.base.sha)" >> $env:GITHUB_OUTPUT | |
| Write-Output "head_sha=$($merged.head.sha)" >> $env:GITHUB_OUTPUT | |
| $body = $merged.body | |
| if ([string]::IsNullOrWhiteSpace($body)) { $body = "" } | |
| $body = $body -replace "`r?`n", " " | |
| Write-Output "merge_body=$body" >> $env:GITHUB_OUTPUT | |
| - name: Check if source files changed | |
| if: github.event_name != 'workflow_dispatch' | |
| id: check_files | |
| shell: pwsh | |
| run: | | |
| Write-Output "DEBUG: Starting check_files" | |
| Write-Output "DEBUG: PR Event Data - Base: ${{ github.event.pull_request.base.sha }} - Head: ${{ github.event.pull_request.head.sha }}" | |
| $changedFiles = git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} | |
| Write-Output "DEBUG: Changed files found:" | |
| Write-Output $changedFiles | |
| $sourceChanged = $false | |
| foreach ($file in $changedFiles) { | |
| if ($file -match '\.(cpp|h|hpp|c|cc|cxx)$') { | |
| $sourceChanged = $true | |
| Write-Output "DEBUG: Source file changed: $file" | |
| break | |
| } | |
| } | |
| Write-Output "DEBUG: sourceChanged is $sourceChanged" | |
| if ($sourceChanged) { | |
| Write-Output "should_release=true" >> $env:GITHUB_OUTPUT | |
| Write-Output "DEBUG: Setting should_release=true" | |
| } else { | |
| Write-Output "should_release=false" >> $env:GITHUB_OUTPUT | |
| Write-Output "DEBUG: Setting should_release=false" | |
| } | |
| - name: Get latest release tag | |
| if: github.event_name != 'workflow_dispatch' && steps.check_files.outputs.should_release == 'true' | |
| id: get_latest_tag | |
| shell: pwsh | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| Write-Output "DEBUG: Starting get_latest_tag" | |
| $repo = "${{ github.repository }}" | |
| Write-Output "DEBUG: Repo is $repo" | |
| $headers = @{"Authorization" = "token $env:GITHUB_TOKEN"; "Accept" = "application/vnd.github.v3+json"} | |
| # Try GitHub Releases API first (includes prereleases) | |
| try { | |
| Write-Output "DEBUG: Fetching releases from API..." | |
| $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers $headers -UseBasicParsing | |
| Write-Output "DEBUG: Fetched $($releases.Count) releases" | |
| } catch { | |
| Write-Output "DEBUG: Failed to fetch releases from API: $_" | |
| $releases = @() | |
| } | |
| $candidates = @() | |
| foreach ($r in $releases) { | |
| Write-Output "DEBUG: Checking release tag: $($r.tag_name)" | |
| if ($r.tag_name -match 'v?(\d+)\.(\d+)\.(\d+)') { | |
| $candidates += [PSCustomObject]@{ Tag = $r.tag_name; Major = [int]$Matches[1]; Minor = [int]$Matches[2]; Patch = [int]$Matches[3] } | |
| } | |
| } | |
| if ($candidates.Count -gt 0) { | |
| Write-Output "DEBUG: Found valid candidates in releases." | |
| $sorted = $candidates | Sort-Object -Property @{Expression={$_.Major};Descending=$true}, @{Expression={$_.Minor};Descending=$true}, @{Expression={$_.Patch};Descending=$true} | |
| $latestTag = $sorted[0].Tag | |
| Write-Output "DEBUG: Selected latest tag from API: $latestTag" | |
| Write-Output "latest_tag=$latestTag" >> $env:GITHUB_OUTPUT | |
| exit 0 | |
| } | |
| # Fallback: ensure tags are fetched and inspect local tags | |
| Write-Output "DEBUG: Fallback to local git tags" | |
| git fetch --tags --prune || true | |
| $local = git tag -l "v[0-9]*.[0-9]*.[0-9]*" | |
| Write-Output "DEBUG: Local tags found: $local" | |
| if ($local) { | |
| $sortedTags = $local | ForEach-Object { | |
| if ($_ -match 'v?(\d+)\.(\d+)\.(\d+)') { | |
| [PSCustomObject]@{ Tag = $_; Major = [int]$Matches[1]; Minor = [int]$Matches[2]; Patch = [int]$Matches[3] } | |
| } | |
| } | Sort-Object -Property @{Expression={$_.Major};Descending=$true}, @{Expression={$_.Minor};Descending=$true}, @{Expression={$_.Patch};Descending=$true} | |
| if ($sortedTags.Count -gt 0) { | |
| $latestTag = $sortedTags[0].Tag | |
| Write-Output "DEBUG: Selected latest tag from local tags: $latestTag" | |
| Write-Output "latest_tag=$latestTag" >> $env:GITHUB_OUTPUT | |
| } else { | |
| Write-Output "DEBUG: No valid semantic version tags found locally. Defaulting to v0.0.0" | |
| Write-Output "latest_tag=v0.0.0" >> $env:GITHUB_OUTPUT | |
| } | |
| } else { | |
| Write-Output "DEBUG: No local tags found. Defaulting to v0.0.0" | |
| Write-Output "latest_tag=v0.0.0" >> $env:GITHUB_OUTPUT | |
| } | |
| - name: Determine next version | |
| if: github.event_name != 'workflow_dispatch' && steps.check_files.outputs.should_release == 'true' | |
| id: next_version | |
| shell: pwsh | |
| run: | | |
| $latestTag = "${{ steps.get_latest_tag.outputs.latest_tag }}" | |
| Write-Output "DEBUG: Starting next_version. Input latestTag: $latestTag" | |
| if ($latestTag -match 'v?(\d+)\.(\d+)\.(\d+)') { | |
| $major = [int]$Matches[1] | |
| $minor = [int]$Matches[2] | |
| $patch = [int]$Matches[3] | |
| $patch = $patch + 1 | |
| $newVersion = "v$major.$minor.$patch" | |
| Write-Output "DEBUG: Calculated new version: $newVersion" | |
| Write-Output "version=$newVersion" >> $env:GITHUB_OUTPUT | |
| Write-Output "version_number=$major.$minor.$patch" >> $env:GITHUB_OUTPUT | |
| } else { | |
| Write-Error "Could not parse version from tag: $latestTag" | |
| exit 1 | |
| } | |
| - name: Parse commit message for release notes | |
| if: (github.event_name == 'workflow_dispatch' && steps.override.outputs.should_release == 'true') || (github.event_name != 'workflow_dispatch' && steps.check_files.outputs.should_release == 'true') | |
| id: parse_commit | |
| shell: pwsh | |
| run: | | |
| Write-Output "DEBUG: Starting parse_commit" | |
| # Get all commits from the PR (base to head) | |
| if ("${{ github.event_name }}" -eq "workflow_dispatch") { | |
| $baseSha = "${{ steps.resolve_pr.outputs.base_sha }}" | |
| $headSha = "${{ steps.resolve_pr.outputs.head_sha }}" | |
| } else { | |
| $baseSha = "${{ github.event.pull_request.base.sha }}" | |
| $headSha = "${{ github.event.pull_request.head.sha }}" | |
| } | |
| Write-Output "DEBUG: Base SHA: $baseSha" | |
| Write-Output "DEBUG: Head SHA: $headSha" | |
| # Get the merge commit (HEAD) message body for personal release notes | |
| if ("${{ github.event_name }}" -eq "workflow_dispatch") { | |
| $mergeCommitBody = "${{ steps.resolve_pr.outputs.merge_body }}" | |
| } else { | |
| $mergeCommitBody = git log -1 --pretty=format:"%b" HEAD | |
| } | |
| Write-Output "DEBUG: Merge commit body: $mergeCommitBody" | |
| # Get all commit messages in the PR | |
| $commits = @(git log --pretty=format:"%h|%s|%b" "$baseSha..$headSha") | |
| Write-Output "DEBUG: Found $($commits.Count) commits in PR" | |
| # Initialize categorized lists | |
| $features = @() | |
| $fixes = @() | |
| $performance = @() | |
| $refactors = @() | |
| $docs = @() | |
| $tests = @() | |
| $chores = @() | |
| $builds = @() | |
| $ci = @() | |
| $style = @() | |
| $reverts = @() | |
| $others = @() | |
| # Parse each commit | |
| foreach ($line in $commits) { | |
| if ([string]::IsNullOrWhiteSpace($line)) { continue } | |
| $parts = $line -split '\|', 3 | |
| if ($parts.Count -lt 2) { continue } | |
| $commitHash = $parts[0] | |
| $subject = $parts[1] | |
| $body = if ($parts.Count -eq 3) { $parts[2] } else { "" } | |
| Write-Output "DEBUG: Processing commit $commitHash : $subject" | |
| # Parse conventional commit format | |
| if ($subject -match '^(fix|feat|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:\s*(.+)$') { | |
| $type = $Matches[1] | |
| $scope = if ($Matches[2]) { $Matches[2] } else { "" } | |
| $description = $Matches[3] | |
| $entry = "- $description ($commitHash)" | |
| switch ($type) { | |
| 'feat' { $features += $entry } | |
| 'fix' { $fixes += $entry } | |
| 'perf' { $performance += $entry } | |
| 'refactor' { $refactors += $entry } | |
| 'docs' { $docs += $entry } | |
| 'test' { $tests += $entry } | |
| 'chore' { $chores += $entry } | |
| 'build' { $builds += $entry } | |
| 'ci' { $ci += $entry } | |
| 'style' { $style += $entry } | |
| 'revert' { $reverts += $entry } | |
| default { $others += "- $subject ($commitHash)" } | |
| } | |
| } else { | |
| # Non-conventional commit | |
| $others += "- $subject ($commitHash)" | |
| } | |
| } | |
| # Build release notes | |
| $releaseNotes = "" | |
| # Add merge commit body at the top if it exists | |
| if (-not [string]::IsNullOrWhiteSpace($mergeCommitBody)) { | |
| $releaseNotes += "$mergeCommitBody`n`n---`n`n" | |
| } | |
| $releaseNotes += "## Changes`n`n" | |
| if ($features.Count -gt 0) { | |
| $releaseNotes += "### ✨ Features`n" | |
| $releaseNotes += ($features -join "`n") + "`n`n" | |
| } | |
| if ($fixes.Count -gt 0) { | |
| $releaseNotes += "### 🐛 Fixes`n" | |
| $releaseNotes += ($fixes -join "`n") + "`n`n" | |
| } | |
| if ($performance.Count -gt 0) { | |
| $releaseNotes += "### ⚡ Performance`n" | |
| $releaseNotes += ($performance -join "`n") + "`n`n" | |
| } | |
| if ($refactors.Count -gt 0) { | |
| $releaseNotes += "### ♻️ Refactoring`n" | |
| $releaseNotes += ($refactors -join "`n") + "`n`n" | |
| } | |
| if ($docs.Count -gt 0) { | |
| $releaseNotes += "### 📝 Documentation`n" | |
| $releaseNotes += ($docs -join "`n") + "`n`n" | |
| } | |
| if ($tests.Count -gt 0) { | |
| $releaseNotes += "### ✅ Tests`n" | |
| $releaseNotes += ($tests -join "`n") + "`n`n" | |
| } | |
| if ($builds.Count -gt 0) { | |
| $releaseNotes += "### 📦 Build`n" | |
| $releaseNotes += ($builds -join "`n") + "`n`n" | |
| } | |
| if ($chores.Count -gt 0) { | |
| $releaseNotes += "### 🔧 Chores`n" | |
| $releaseNotes += ($chores -join "`n") + "`n`n" | |
| } | |
| if ($ci.Count -gt 0) { | |
| $releaseNotes += "### 🔄 CI/CD`n" | |
| $releaseNotes += ($ci -join "`n") + "`n`n" | |
| } | |
| if ($style.Count -gt 0) { | |
| $releaseNotes += "### 💄 Style`n" | |
| $releaseNotes += ($style -join "`n") + "`n`n" | |
| } | |
| if ($reverts.Count -gt 0) { | |
| $releaseNotes += "### ⏪ Reverts`n" | |
| $releaseNotes += ($reverts -join "`n") + "`n`n" | |
| } | |
| if ($others.Count -gt 0) { | |
| $releaseNotes += "### 📋 Other Changes`n" | |
| $releaseNotes += ($others -join "`n") + "`n`n" | |
| } | |
| Write-Output "DEBUG: releaseNotes: $releaseNotes" | |
| $releaseNotes += "---`n*Based on commits from $baseSha to $headSha*" | |
| $delimiter = "EOF_$(Get-Random)" | |
| Write-Output "DEBUG: Writing notes to output using delimiter $delimiter" | |
| Write-Output "notes<<$delimiter" >> $env:GITHUB_OUTPUT | |
| Write-Output $releaseNotes >> $env:GITHUB_OUTPUT | |
| Write-Output $delimiter >> $env:GITHUB_OUTPUT | |
| build: | |
| name: Build matrix for multiple Windows architectures | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: windows-latest | |
| strategy: | |
| matrix: | |
| arch: [x64, x86, arm64] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Compile for ${{ matrix.arch }} | |
| shell: pwsh | |
| run: | | |
| # Set version as environment variable | |
| $env:VERSION_NUMBER = "${{ needs.prepare.outputs.version }}" | |
| # Find and initialize MSVC | |
| $vsPath = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" ` | |
| -latest -products * ` | |
| -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` | |
| -property installationPath | |
| $vcvarsPath = Join-Path $vsPath "VC\Auxiliary\Build\vcvarsall.bat" | |
| $targetArch = "${{ matrix.arch }}" | |
| $vcvarsArch = $targetArch | |
| if ($targetArch -eq "x86") { $vcvarsArch = "x64_x86" } | |
| if ($targetArch -eq "arm64") { $vcvarsArch = "x64_arm64" } | |
| # Use cmd to call vcvarsall and then cl | |
| $outName = "win-witr-${{ matrix.arch }}.exe" | |
| $verTag = "${{ needs.prepare.outputs.version }}" | |
| cmd /c "`"$vcvarsPath`" $vcvarsArch && cl /O2 /Ot /GL /std:c++20 /EHsc main.cpp /DUNICODE /D_UNICODE /DVERSION_NUMBER=$verTag /Fe:$outName" | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Host "Build failed with exit code $LASTEXITCODE" | |
| Exit $LASTEXITCODE | |
| } | |
| - name: Upload build artifact for ${{ matrix.arch }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: win-witr-${{ matrix.arch }} | |
| path: win-witr-${{ matrix.arch }}.exe | |
| create-release: | |
| name: Create GitHub Release with all artifacts | |
| needs: [prepare, build] | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository for tagging | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download all build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: List artifacts | |
| run: ls -R artifacts | |
| - name: Flatten artifact directory structure | |
| run: | | |
| set -e | |
| echo "Flattening artifact directory structure..." | |
| find artifacts -name "*.exe" -type f -exec mv -n {} artifacts/ \; | |
| echo "Removing empty subdirectories..." | |
| find artifacts -mindepth 1 -type d -delete || echo "Warning: Failed to delete some subdirectories in artifacts/ - this may indicate remaining files or permission issues" | |
| echo "Flattened artifacts directory:" | |
| ls -la artifacts/ | |
| - name: Ensure release tag exists and push | |
| if: needs.prepare.outputs.version != '' | |
| shell: bash | |
| run: | | |
| set -e | |
| echo "DEBUG: Ensure Release Tag Exists" | |
| echo "DEBUG: received version: '${{ needs.prepare.outputs.version }}'" | |
| echo "DEBUG: received sha: $(git rev-parse HEAD)" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| TAG="${{ needs.prepare.outputs.version }}" | |
| echo "Preparing tag: $TAG" | |
| if [ -z "$TAG" ]; then | |
| echo "No tag provided; cannot create release" | |
| exit 1 | |
| fi | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| echo "DEBUG: Tag $TAG already exists locally" | |
| else | |
| # Explicitly tag the HEAD sha to match the checked out state | |
| HEAD_SHA=$(git rev-parse HEAD) | |
| echo "DEBUG: Creating tag $TAG on $HEAD_SHA" | |
| git tag -a "$TAG" -m "Release $TAG" "$HEAD_SHA" | |
| echo "DEBUG: Created local tag $TAG" | |
| fi | |
| # Push tag (skip errors if it already exists on remote) | |
| echo "DEBUG: Pushing tag $TAG" | |
| git push origin "$TAG" || echo "Tag push failed or already exists on remote" | |
| - name: Create Release using GH CLI | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| TAG: ${{ needs.prepare.outputs.version }} | |
| NOTES: ${{ needs.prepare.outputs.notes }} | |
| run: | | |
| TITLE="win-witr $TAG" | |
| echo "Checking if release '$TAG' exists..." | |
| if gh release view "$TAG" > /dev/null 2>&1; then | |
| echo "Release '$TAG' found. Deleting to recreate..." | |
| gh release delete "$TAG" -y | |
| else | |
| echo "Release '$TAG' does not exist." | |
| fi | |
| echo "Locating artifacts..." | |
| # Find the files anywhere in the current directory, robust to structure changes | |
| FILE_X64=$(find . -type f -name "win-witr-x64.exe" | head -n 1) | |
| FILE_X86=$(find . -type f -name "win-witr-x86.exe" | head -n 1) | |
| FILE_ARM64=$(find . -type f -name "win-witr-arm64.exe" | head -n 1) | |
| echo "Found x64 artifact: $FILE_X64" | |
| echo "Found x86 artifact: $FILE_X86" | |
| echo "Found arm64 artifact: $FILE_ARM64" | |
| if [[ -z "$FILE_X64" || -z "$FILE_X86" || -z "$FILE_ARM64" ]]; then | |
| echo "Error: Could not locate all expected artifacts." | |
| echo "Searching for any .exe files:" | |
| find . -name "*.exe" | |
| exit 1 | |
| fi | |
| echo "Creating release '$TAG'..." | |
| gh release create "$TAG" \ | |
| "$FILE_X64" \ | |
| "$FILE_X86" \ | |
| "$FILE_ARM64" \ | |
| --title "$TITLE" \ | |
| --notes "$NOTES" \ | |
| --prerelease |