Skip to content

fix(release): fetch tags before note generation [skip changelog] #59

fix(release): fetch tags before note generation [skip changelog]

fix(release): fetch tags before note generation [skip changelog] #59

Workflow file for this run

name: Release
# Triggered by pushing a tag like v2026.4.27.0 (release; .N = release iteration for the day,
# starting at 0). Dev-style tags like v2026.4.27.7-4974 are also accepted but should be rare on
# the release stream — the -XXXX suffix is intended for local dev builds where many rebuilds at
# the same daily counter need to be disambiguated. The tag (minus the leading "v") drives the
# build's version: it's passed to build.ps1 -Version so the assembly version, version.txt, and the
# zip filename all match the tag. build.ps1 validates the shape and fails fast on a malformed tag.
# Builds the full distribution via build.ps1 on a Windows runner — required because the build
# produces win-x64 exes and uses PowerShell-only cmdlets — then attaches the zip
# from dist/ to a GitHub release with a SHA256 line so updater.exe can verify the download.
#
# Changelog promotion: before the build runs, .github/scripts/Update-Changelog.ps1 renames the
# "## Unreleased" heading in CHANGELOG.md and wiki/Changelog.md to "## [vTAG] - DATE", linking
# to this release. After the GitHub release is created the promoted files are pushed to a
# release/promote-changelog-<tag> branch and a PR is opened with auto-merge so main eventually
# carries the promotion (squashed in by github-actions[bot] once CI passes). Direct push does
# not work on protected main: the dotnet-build-+-test required check is missing on bot pushes,
# so branch protection rejects them.
on:
push:
tags:
- 'v*'
permissions:
contents: write
pull-requests: write # release step opens a PR for the changelog promotion
# Share a concurrency group with changelog-append so the appender can't run
# during a release build and add entries to main's Unreleased that the release
# itself doesn't contain. Only one of these two workflows runs at a time.
concurrency:
group: changelog-append
cancel-in-progress: false
jobs:
release:
name: Build and publish release
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
# GITHUB_TOKEN is enough: the promotion-PR step pushes the bot's
# branch (not main) and uses gh to open + auto-merge the PR.
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch release tags
shell: pwsh
run: |
git fetch --force --tags origin
$tags = git tag --list 'v*'
if (-not $tags) { throw "No release tags available after fetch; release-note range would walk from root." }
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Promote Unreleased -> tagged section
shell: pwsh
env:
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
./.github/scripts/Update-Changelog.ps1 -Mode Promote -Version '${{ github.ref_name }}'
# Stash the promoted files so we can re-apply them on main after the
# release. Doing it now (before build.ps1 mutates the worktree) means
# the bytes we push back to main are exactly what shipped in the exe.
New-Item -ItemType Directory -Force -Path .changelog-stash/wiki | Out-Null
Copy-Item CHANGELOG.md .changelog-stash/CHANGELOG.md -Force
Copy-Item wiki/Changelog.md .changelog-stash/wiki/Changelog.md -Force
- name: Build distribution
shell: pwsh
env:
TAG_NAME: ${{ github.ref_name }}
run: |
# Strip the leading "v" so the version baked into the build matches the tag.
# build.ps1 validates the shape (YYYY.M.D.N-XXXX) and fails fast on a bad tag.
$version = $env:TAG_NAME -replace '^v', ''
./build.ps1 -Version $version -Package
- name: Locate release artifacts
id: zip
shell: pwsh
run: |
$zip = Get-ChildItem dist/WKVRCProxy-*.zip | Select-Object -First 1
if (-not $zip) { throw "No release zip found in dist/ -- build.ps1 -Package did not produce one." }
$hash = (Get-FileHash $zip.FullName -Algorithm SHA256).Hash
$manifest = Get-ChildItem dist/WKVRCProxy-*.manifest.tsv | Select-Object -First 1
if (-not $manifest) { throw "No manifest found in dist/ -- build.ps1 -Package did not produce one. Generate-ReleaseNotes.ps1 needs it for the File integrity section." }
"path=$($zip.FullName)" >> $env:GITHUB_OUTPUT
"name=$($zip.Name)" >> $env:GITHUB_OUTPUT
"size=$($zip.Length)" >> $env:GITHUB_OUTPUT
"sha256=$hash" >> $env:GITHUB_OUTPUT
"manifest=$($manifest.FullName)" >> $env:GITHUB_OUTPUT
Write-Host "Release zip: $($zip.Name)"
Write-Host "Release manifest: $($manifest.Name)"
Write-Host "SHA256: $hash"
- name: Generate release body
id: changelog
shell: pwsh
env:
TAG_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
# gh release list is used by the prev-tag resolver's subject-match
# fallback. Without it the resolver silently falls through to the
# root walk, which produces a giant slice on rebased histories.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Composes the full release body: title + auto-changelog slice +
# File integrity (from the manifest emitted by build.ps1) + four
# templated evergreen sections (More / Install / Uninstall / What
# you need to do) + optional .github/release-extras/<tag>.md.
# See .github/scripts/Generate-ReleaseNotes.ps1 for filtering rules,
# token substitution, and scrub gates.
$body = & ./.github/scripts/Generate-ReleaseNotes.ps1 `
-ZipPath '${{ steps.zip.outputs.path }}' `
-ZipName '${{ steps.zip.outputs.name }}' `
-ZipSize '${{ steps.zip.outputs.size }}' `
-ZipSha256 '${{ steps.zip.outputs.sha256 }}' `
-Manifest '${{ steps.zip.outputs.manifest }}'
if (-not $body) {
throw "Generate-ReleaseNotes.ps1 returned empty output. Inspect the workflow log for warnings."
}
$delim = "EOF_" + [Guid]::NewGuid().ToString('N')
"body<<$delim" >> $env:GITHUB_OUTPUT
$body >> $env:GITHUB_OUTPUT
$delim >> $env:GITHUB_OUTPUT
- name: Create GitHub release
id: publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = "${{ github.ref_name }}"
$zip = "${{ steps.zip.outputs.path }}"
$notes = @'
${{ steps.changelog.outputs.body }}
'@
# Persist the input body so the verify step can diff against what
# GitHub actually serves. Doing it here avoids re-deriving the body
# in a second step and risking divergence.
$notesPath = Join-Path $env:RUNNER_TEMP 'release-body-input.md'
$notes | Out-File -LiteralPath $notesPath -Encoding utf8 -NoNewline
"input_path=$notesPath" >> $env:GITHUB_OUTPUT
# gh release create accepts the asset paths as positional arguments after the tag.
gh release create $tag $zip --title $tag --notes-file $notesPath
- name: Verify published release body matches input
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = "${{ github.ref_name }}"
$inputPath = "${{ steps.publish.outputs.input_path }}"
$expected = (Get-Content -LiteralPath $inputPath -Raw -Encoding UTF8) -replace "`r`n","`n"
$expected = $expected.TrimEnd("`n")
function Get-StringSha256 {
param([string]$Text)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Text)
$sha = [System.Security.Cryptography.SHA256]::Create()
try { return [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '') }
finally { $sha.Dispose() }
}
# GitHub's release-body read-after-write isn't strictly consistent.
# Right after `gh release create` (or `gh release edit`) the next
# `gh release view` can return a stub or partial body for several
# seconds before the API settles on the published value. Compare
# via SHA256 (catches length-match-but-content-different corruption
# the old length check would miss), and retry with exponential
# backoff so the transient settle window doesn't false-fail the
# workflow. Total budget across attempts: 2+4+8+16+32 = 62s.
function Test-PublishedBodyMatches {
param([string]$Tag, [string]$Expected, [int]$MaxAttempts = 6)
$expectedSha = Get-StringSha256 -Text $Expected
$delay = 2
$lastActual = ''
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$raw = & gh release view $Tag --json body --jq '.body'
if ($LASTEXITCODE -ne 0) { throw "gh release view $Tag failed: $raw" }
$actual = ($raw -replace "`r`n","`n").TrimEnd("`n")
$lastActual = $actual
$actualSha = Get-StringSha256 -Text $actual
if ($actualSha -eq $expectedSha) {
Write-Host "Attempt $attempt/$MaxAttempts: body matches (len=$($actual.Length) sha256=$($actualSha.Substring(0,12)))."
return @{ Match = $true; Actual = $actual; AttemptCount = $attempt }
}
Write-Host "Attempt $attempt/$MaxAttempts: actual_len=$($actual.Length) expected_len=$($Expected.Length) actual_sha=$($actualSha.Substring(0,12)) expected_sha=$($expectedSha.Substring(0,12))"
if ($attempt -lt $MaxAttempts) {
Write-Host "Sleeping ${delay}s before retry..."
Start-Sleep -Seconds $delay
$delay = $delay * 2
}
}
return @{ Match = $false; Actual = $lastActual; AttemptCount = $MaxAttempts }
}
# First pass: tolerate the post-create settle window.
$result = Test-PublishedBodyMatches -Tag $tag -Expected $expected
if ($result.Match) {
Write-Host "Published release body matches input on attempt $($result.AttemptCount) ($($expected.Length) chars)."
return
}
# Real divergence after the settle window. Auto-correct via
# `gh release edit`, then re-run the same retry loop -- the edit
# itself has the same eventual-consistency window so a single
# post-edit read can still see stale content.
Write-Host "::warning::Body still differs after $($result.AttemptCount) attempts. Auto-correcting via gh release edit."
gh release edit $tag --notes-file $inputPath
if ($LASTEXITCODE -ne 0) { throw "gh release edit failed during auto-correct" }
$recheck = Test-PublishedBodyMatches -Tag $tag -Expected $expected
if ($recheck.Match) {
Write-Host "Auto-correct succeeded; body matches on attempt $($recheck.AttemptCount)."
return
}
# Real mismatch. Persist the served body and dump head/tail of
# both expected and actual so the operator can diagnose without
# re-running anything.
$diffPath = Join-Path $env:RUNNER_TEMP 'release-body-actual.md'
$actualText = [string]$recheck.Actual
$actualText | Out-File -LiteralPath $diffPath -Encoding utf8 -NoNewline
$actualLength = $actualText.Length
$staleReadThreshold = [Math]::Min(256, [Math]::Floor($expected.Length / 2))
if ($actualLength -lt $staleReadThreshold) {
Write-Host "::warning::Release body still reads as a short/stale value after auto-correct. The edit request succeeded; continuing so a transient GitHub release-body read does not fail the published release."
return
}
$headLen = [Math]::Min(200, $expected.Length)
$tailStart = [Math]::Max(0, $expected.Length - 200)
$expectedHead = $expected.Substring(0, $headLen)
$expectedTail = $expected.Substring($tailStart)
$actHeadLen = [Math]::Min(200, $actualLength)
$actTailStart = [Math]::Max(0, $actualLength - 200)
$actualHead = $actualText.Substring(0, $actHeadLen)
$actualTail = $actualText.Substring($actTailStart)
Write-Host "Expected length $($expected.Length); got $actualLength."
Write-Host "--- Expected head ---"
Write-Host $expectedHead
Write-Host "--- Expected tail ---"
Write-Host $expectedTail
Write-Host "--- Actual head ---"
Write-Host $actualHead
Write-Host "--- Actual tail ---"
Write-Host $actualTail
Write-Host "Compare $inputPath vs $diffPath in the runner artifacts."
throw "Release body still differs after auto-correct + retries."
- name: Open promotion PR + enable auto-merge
# We can't push directly to main: branch protection requires the
# "dotnet build + test" status check, which a bot push bypasses (so
# the push is rejected). Instead we open a PR off the tag and let
# auto-merge squash it once CI goes green. ci.yml has CHANGELOG.md
# and wiki/Changelog.md carved out of paths-ignore so the required
# check actually runs on changelog-only PRs.
#
# Done last so a build/release failure doesn't leave a phantom
# "promotion PR open but never released" state.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
branch="release/promote-changelog-${TAG_NAME}"
git checkout -b "$branch"
# Replay the stashed files (captured before build.ps1 ran) so the
# bytes we put on main are exactly what shipped in the embedded exe.
cp .changelog-stash/CHANGELOG.md CHANGELOG.md
cp .changelog-stash/wiki/Changelog.md wiki/Changelog.md
if [[ -z "$(git status --porcelain CHANGELOG.md wiki/Changelog.md)" ]]; then
echo "Nothing to promote — main is already in promoted state."
exit 0
fi
# [skip changelog] keeps changelog-append from re-bulleting this commit
# if the squash trigger ever races past the bot-actor filter.
git add CHANGELOG.md wiki/Changelog.md
git commit -m "docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]"
git push origin "$branch"
pr_url="$(gh pr create \
--base main \
--head "$branch" \
--title "docs(changelog): promote Unreleased -> ${TAG_NAME}" \
--body "Promotion PR opened by .github/workflows/release.yml after publishing **${TAG_NAME}**. Mirrors the embedded \`CHANGELOG.md\` (and \`wiki/Changelog.md\`) that shipped inside the exe back onto \`main\` so the next push starts with a fresh \`## Unreleased\` section. Auto-merge enabled -- will squash once \`dotnet build + test\` passes.")"
echo "Opened: $pr_url"
# --auto queues the merge; it actually fires once required checks pass.
# Use --subject so the squash commit subject keeps the [skip changelog]
# marker (the appender also filters by bot actor, but belt-and-braces).
gh pr merge "$pr_url" \
--auto \
--squash \
--delete-branch \
--subject "docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]"