|
13 | 13 | permissions: |
14 | 14 | contents: write |
15 | 15 |
|
| 16 | +concurrency: |
| 17 | + group: release-${{ github.ref }} |
| 18 | + cancel-in-progress: false |
| 19 | + |
16 | 20 | jobs: |
17 | 21 | build: |
18 | 22 | runs-on: windows-latest |
| 23 | + timeout-minutes: 30 |
19 | 24 | steps: |
20 | 25 | - uses: actions/checkout@v4 |
| 26 | + with: |
| 27 | + # Full history + all tags so the release-body step can diff |
| 28 | + # commits against the previous version tag for the changelog. |
| 29 | + fetch-depth: 0 |
21 | 30 |
|
22 | 31 | - uses: actions/setup-go@v5 |
23 | 32 | with: |
|
41 | 50 | if ($version -notmatch '^\d{4}\.\d+\.\d+\.\d+(-[A-Fa-f0-9]{4})?$') { |
42 | 51 | throw "version '$version' does not match the YYYY.M.D.N(-XXXX) shape" |
43 | 52 | } |
| 53 | + $isPre = $version -like '*-*' |
44 | 54 | "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
45 | 55 | "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
46 | | - Write-Host "tag=$tag version=$version" |
| 56 | + "is_pre=$($isPre.ToString().ToLower())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 57 | + Write-Host "tag=$tag version=$version is_pre=$isPre" |
47 | 58 |
|
48 | 59 | - name: Fetch picotool |
49 | 60 | shell: pwsh |
@@ -77,14 +88,141 @@ jobs: |
77 | 88 | } |
78 | 89 | $manifest | ConvertTo-Json -Compress | Out-File -FilePath handoff-version.json -Encoding ASCII |
79 | 90 | Get-Content handoff-version.json |
| 91 | + "sha256=$sha" >> $env:GITHUB_OUTPUT |
80 | 92 |
|
81 | | - - name: Upload release assets |
82 | | - uses: softprops/action-gh-release@v2 |
83 | | - with: |
84 | | - tag_name: ${{ steps.ver.outputs.tag }} |
85 | | - files: | |
86 | | - handoff.exe |
87 | | - handoff-version.json |
88 | | - generate_release_notes: true |
89 | | - draft: false |
90 | | - prerelease: ${{ contains(steps.ver.outputs.version, '-') }} |
| 93 | + - name: Publish GitHub release |
| 94 | + env: |
| 95 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 96 | + TAG_NAME: ${{ steps.ver.outputs.tag }} |
| 97 | + EXE_SHA256: ${{ steps.manifest.outputs.sha256 }} |
| 98 | + shell: pwsh |
| 99 | + run: | |
| 100 | + # Build the release body. Top section is a conventional-commit |
| 101 | + # changelog grouped by type, extracted from `git log $prev..$tag`. |
| 102 | + # Bottom section is the static install snippet. |
| 103 | +
|
| 104 | + $prevTag = git tag --list 'v*' --sort=-version:refname | |
| 105 | + Where-Object { $_ -ne $env:TAG_NAME } | |
| 106 | + Select-Object -First 1 |
| 107 | +
|
| 108 | + $sections = [ordered]@{ |
| 109 | + feat = 'Features' |
| 110 | + fix = 'Bug Fixes' |
| 111 | + perf = 'Performance' |
| 112 | + refactor = 'Refactors' |
| 113 | + docs = 'Documentation' |
| 114 | + test = 'Tests' |
| 115 | + ci = 'CI' |
| 116 | + build = 'Build' |
| 117 | + chore = 'Chores' |
| 118 | + style = 'Style' |
| 119 | + revert = 'Reverts' |
| 120 | + other = 'Other' |
| 121 | + } |
| 122 | + $groups = @{} |
| 123 | + $breaking = @() |
| 124 | +
|
| 125 | + if ($prevTag) { |
| 126 | + $range = "$prevTag..$env:TAG_NAME" |
| 127 | + $logLines = git log $range --pretty=format:"%h%x09%s" --no-merges |
| 128 | + foreach ($line in $logLines) { |
| 129 | + if ([string]::IsNullOrWhiteSpace($line)) { continue } |
| 130 | + $parts = $line -split "`t", 2 |
| 131 | + $sha = $parts[0] |
| 132 | + $subject = $parts[1] |
| 133 | + if ($subject -match '^(?<type>\w+)(\((?<scope>[^)]+)\))?(?<bang>!)?:\s*(?<desc>.+)$') { |
| 134 | + $type = $matches['type'].ToLowerInvariant() |
| 135 | + $scope = $matches['scope'] |
| 136 | + $isBreaking = -not [string]::IsNullOrEmpty($matches['bang']) |
| 137 | + $desc = $matches['desc'] |
| 138 | + if (-not $sections.Contains($type)) { $type = 'other' } |
| 139 | + if ($scope) { |
| 140 | + $entry = "- ``$sha`` **${scope}**: $desc" |
| 141 | + } else { |
| 142 | + $entry = "- ``$sha`` $desc" |
| 143 | + } |
| 144 | + if ($isBreaking) { |
| 145 | + if ($scope) { |
| 146 | + $breaking += "- ``$sha`` **${scope}**: $desc" |
| 147 | + } else { |
| 148 | + $breaking += "- ``$sha`` $desc" |
| 149 | + } |
| 150 | + } |
| 151 | + if (-not $groups.ContainsKey($type)) { $groups[$type] = @() } |
| 152 | + $groups[$type] += $entry |
| 153 | + } else { |
| 154 | + if (-not $groups.ContainsKey('other')) { $groups['other'] = @() } |
| 155 | + $groups['other'] += "- ``$sha`` $subject" |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | +
|
| 160 | + $out = New-Object System.Collections.Generic.List[string] |
| 161 | + if ($prevTag) { |
| 162 | + $out.Add("## Changes since $prevTag") |
| 163 | + $out.Add("") |
| 164 | + if ($breaking.Count -gt 0) { |
| 165 | + $out.Add("### Breaking Changes") |
| 166 | + $out.Add("") |
| 167 | + foreach ($e in $breaking) { $out.Add($e) } |
| 168 | + $out.Add("") |
| 169 | + } |
| 170 | + $emitted = $false |
| 171 | + foreach ($key in $sections.Keys) { |
| 172 | + if ($groups.ContainsKey($key) -and $groups[$key].Count -gt 0) { |
| 173 | + $emitted = $true |
| 174 | + $out.Add("### $($sections[$key])") |
| 175 | + $out.Add("") |
| 176 | + foreach ($e in $groups[$key]) { $out.Add($e) } |
| 177 | + $out.Add("") |
| 178 | + } |
| 179 | + } |
| 180 | + if (-not $emitted) { |
| 181 | + $out.Add("_No conventional-commit changes since $prevTag._") |
| 182 | + $out.Add("") |
| 183 | + } |
| 184 | + $out.Add("**Full diff:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:TAG_NAME") |
| 185 | + $out.Add("") |
| 186 | + } else { |
| 187 | + $out.Add("Initial release.") |
| 188 | + $out.Add("") |
| 189 | + } |
| 190 | +
|
| 191 | + $out.Add("## Install") |
| 192 | + $out.Add("") |
| 193 | + $out.Add("Download ``handoff.exe`` from the assets below and run it from anywhere on PATH (or wherever you keep portable tools).") |
| 194 | + $out.Add("") |
| 195 | + $out.Add("SHA256: ``$env:EXE_SHA256``") |
| 196 | + $out.Add("") |
| 197 | + $out.Add("Auto-update clients can poll ``handoff-version.json`` next to the binary in the assets list.") |
| 198 | + $out.Add("") |
| 199 | + $out.Add("Getting started: https://github.com/$env:GITHUB_REPOSITORY/wiki/Getting-Started") |
| 200 | +
|
| 201 | + $notes = $out -join "`n" |
| 202 | + $notesPath = Join-Path $env:RUNNER_TEMP "release-notes.md" |
| 203 | + Set-Content -LiteralPath $notesPath -Value $notes -Encoding UTF8 |
| 204 | +
|
| 205 | + # Draft-first: create as draft, upload assets, only then promote |
| 206 | + # to published. If anything between create and promote fails the |
| 207 | + # release stays as a draft, which is more visible than a live |
| 208 | + # release missing one of its assets. |
| 209 | + $extra = @('--draft') |
| 210 | + if ('${{ steps.ver.outputs.is_pre }}' -eq 'true') { $extra += @('--prerelease','--latest=false') } |
| 211 | + gh release create $env:TAG_NAME handoff.exe handoff-version.json --title $env:TAG_NAME --notes-file $notesPath @extra |
| 212 | + if ($LASTEXITCODE -ne 0) { throw "gh release create failed (exit $LASTEXITCODE)" } |
| 213 | +
|
| 214 | + # Verify the assets actually attached -- gh release create can |
| 215 | + # succeed with the metadata but fail mid-upload on the asset |
| 216 | + # stage; the draft would then be live with missing files. |
| 217 | + $json = gh release view $env:TAG_NAME --json assets,isDraft |
| 218 | + if ($LASTEXITCODE -ne 0) { throw "gh release view failed (exit $LASTEXITCODE)" } |
| 219 | + $info = $json | ConvertFrom-Json |
| 220 | + $expected = @('handoff.exe', 'handoff-version.json') |
| 221 | + foreach ($e in $expected) { |
| 222 | + if (-not ($info.assets.name -contains $e)) { |
| 223 | + throw "Asset '$e' did not attach to draft release $env:TAG_NAME -- leaving as draft for manual inspection." |
| 224 | + } |
| 225 | + } |
| 226 | + Write-Host "Draft release $env:TAG_NAME has all expected assets, promoting to published." |
| 227 | + gh release edit $env:TAG_NAME --draft=false |
| 228 | + if ($LASTEXITCODE -ne 0) { throw "gh release edit --draft=false failed (exit $LASTEXITCODE)" } |
0 commit comments