docs(changelog): auto-append entries from 0286d54 [skip changelog] #65
Workflow file for this run
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 | |
| # 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 | |
| fetch-tags: true | |
| # 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 --prune origin '+refs/tags/*:refs/tags/*' | |
| $tags = git tag --list 'v*' | |
| if (-not $tags) { throw "No release tags available after fetch; release-note range would walk from root." } | |
| $tags | Sort-Object | ForEach-Object { Write-Host "release-tag=$_" } | |
| - 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 may treat the tag as the first | |
| # release and use the curated changelog section. | |
| 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 | |
| if ($LASTEXITCODE -ne 0) { throw "gh release view $Tag failed: $raw" } | |
| try { | |
| $actual = [string](($raw | ConvertFrom-Json).body) | |
| } catch { | |
| throw "Failed to parse gh release view JSON for ${Tag}: $_" | |
| } | |
| $actual = ($actual -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: Append wrapper SHA to known_wrapper_hashes.txt | |
| # Only fires for release tags (no -XXXX dev suffix). Dev builds keep | |
| # their SHA out of the public hash list -- WrapperIdentity identifies | |
| # them via the embedded marker and PE-metadata signals instead. | |
| # | |
| # Runs after the release is published so a build/release failure | |
| # doesn't leave a phantom hash entry on main for a release that never | |
| # shipped. Uses the same createCommitOnBranch mutation pattern as | |
| # changelog-append.yml so the commit lands verified on main and | |
| # clears the protected-branch signature rule. | |
| if: ${{ !contains(github.ref_name, '-') }} | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| TAG_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| wrapper="dist/tools/yt-dlp.exe" | |
| if [[ ! -f "$wrapper" ]]; then | |
| echo "::error::Wrapper binary not found at $wrapper -- cannot compute SHA" | |
| exit 1 | |
| fi | |
| sha=$(sha256sum "$wrapper" | awk '{print $1}') | |
| version="${TAG_NAME#v}" | |
| iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| list="data/known_wrapper_hashes.txt" | |
| if [[ ! -f "$list" ]]; then | |
| echo "::error::Hash list missing at $list -- create the seed file in repo root" | |
| exit 1 | |
| fi | |
| # Idempotency: skip if the SHA is already recorded. Allows safe | |
| # re-runs of the workflow against the same tag without duplicating | |
| # entries. | |
| if grep -qi "^${sha}" "$list"; then | |
| echo "SHA $sha already present in $list -- skipping append" | |
| exit 0 | |
| fi | |
| printf '%s %s %s\n' "$sha" "$version" "$iso" >> "$list" | |
| echo "Appended: $sha $version $iso" | |
| # Read main's current head via API rather than local rev-parse: | |
| # the workflow's working tree is on the tag commit, which may sit | |
| # behind main if a concurrent push slipped in. The concurrency | |
| # group already serialises against changelog-append, but this | |
| # belt-and-braces fetch makes the failure loud if main moves | |
| # between read and mutate. | |
| expected_oid=$(gh api "repos/$GITHUB_REPOSITORY/git/refs/heads/main" --jq '.object.sha') | |
| list_b64=$(base64 -w 0 "$list") | |
| headline="chore(release): append wrapper hash for ${TAG_NAME} [skip changelog]" | |
| body=$(printf '%s\n%s' \ | |
| "Appended SHA-256 of dist/tools/yt-dlp.exe for ${TAG_NAME}." \ | |
| 'Maintained by .github/workflows/release.yml; consumed by WrapperIdentity.') | |
| payload=$(jq -n \ | |
| --arg repo "$GITHUB_REPOSITORY" \ | |
| --arg headline "$headline" \ | |
| --arg body "$body" \ | |
| --arg oid "$expected_oid" \ | |
| --arg list "$list_b64" \ | |
| '{ | |
| query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }", | |
| variables: { | |
| input: { | |
| branch: { repositoryNameWithOwner: $repo, branchName: "main" }, | |
| message: { headline: $headline, body: $body }, | |
| fileChanges: { | |
| additions: [ | |
| { path: "data/known_wrapper_hashes.txt", contents: $list } | |
| ] | |
| }, | |
| expectedHeadOid: $oid | |
| } | |
| } | |
| }') | |
| response=$(printf '%s' "$payload" | gh api graphql --input -) | |
| echo "$response" | jq . | |
| if echo "$response" | jq -e '.errors // empty' >/dev/null; then | |
| echo "::error::createCommitOnBranch returned GraphQL errors" | |
| exit 1 | |
| fi | |
| new_oid=$(echo "$response" | jq -r '.data.createCommitOnBranch.commit.oid') | |
| if [[ -z "$new_oid" || "$new_oid" == "null" ]]; then | |
| echo "::error::createCommitOnBranch did not return a commit oid" | |
| exit 1 | |
| fi | |
| verified=$(gh api "repos/$GITHUB_REPOSITORY/commits/$new_oid" --jq '.commit.verification.verified') | |
| if [[ "$verified" != "true" ]]; then | |
| echo "::error::Commit $new_oid is not verified (got: $verified)" | |
| gh api "repos/$GITHUB_REPOSITORY/commits/$new_oid" --jq '.commit.verification' | |
| exit 1 | |
| fi | |
| echo "Hash append commit: $new_oid (verified)" | |
| - 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_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." | |
| if pr_url="$(gh pr create \ | |
| --base main \ | |
| --head "$branch" \ | |
| --title "docs(changelog): promote Unreleased -> ${TAG_NAME}" \ | |
| --body "$pr_body" \ | |
| 2>pr-create.err)"; then | |
| 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). | |
| if ! gh pr merge "$pr_url" \ | |
| --auto \ | |
| --squash \ | |
| --delete-branch \ | |
| --subject "docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]" \ | |
| 2>pr-merge.err; then | |
| cat pr-merge.err >&2 | |
| echo "::warning::Release published, but auto-merge could not be enabled for promotion PR $pr_url." | |
| fi | |
| else | |
| cat pr-create.err >&2 | |
| echo "::warning::Release published, but GitHub Actions could not create the promotion PR. Branch '$branch' was pushed; promote CHANGELOG.md and wiki/Changelog.md manually." | |
| fi |