Skip to content

docs(changelog): auto-append entries from 0286d54 [skip changelog] #65

docs(changelog): auto-append entries from 0286d54 [skip changelog]

docs(changelog): auto-append entries from 0286d54 [skip changelog] #65

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
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