Skip to content

Release

Release #76

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 an integrity TSV 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 to "## [vTAG] - DATE", linking
# to this release. After the GitHub release is created, the promoted file is
# committed back to main through the verified createCommitOnBranch path so main
# carries the same CHANGELOG.md that shipped in the release artifact.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (for example, v2026.6.17.0-beta)'
required: true
permissions:
contents: write
# 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@v7
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ github.event.inputs.tag || github.ref }}
# GITHUB_TOKEN is enough for release publishing and the verified
# createCommitOnBranch mutations used after publish.
token: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve release tag
id: release_tag
shell: pwsh
run: |
$tag = '${{ github.event.inputs.tag }}'
if ([string]::IsNullOrWhiteSpace($tag)) {
$tag = '${{ github.ref_name }}'
}
if ($tag -notmatch '^v\d{4}\.\d+\.\d+\.\d+(-([A-Fa-f0-9]{4}|beta))?$') {
throw "tag '$tag' does not match vYYYY.M.D.N, vYYYY.M.D.N-XXXX, or vYYYY.M.D.N-beta."
}
git rev-parse --verify --quiet "refs/tags/$tag^{commit}" *> $null
if ($LASTEXITCODE -ne 0) {
throw "tag '$tag' does not exist in the checked-out repository."
}
"tag=$tag" >> $env:GITHUB_OUTPUT
Write-Host "tag=$tag"
- name: Detect beta tag
id: beta
shell: pwsh
run: |
$isBeta = '${{ steps.release_tag.outputs.tag }}' -match '^v\d{4}\.\d+\.\d+\.\d+-beta$'
"is_beta=$($isBeta.ToString().ToLower())" >> $env:GITHUB_OUTPUT
- name: Validate PowerShell and changelog scripts
shell: pwsh
run: |
./.github/scripts/Test-WorkflowSyntax.ps1
./.github/scripts/Test-UpdateChangelog.ps1
./.github/scripts/Test-ReleaseVersionSequence.ps1
- 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: Validate release version sequence
shell: pwsh
run: ./.github/scripts/Assert-ReleaseVersionSequence.ps1 -Tag '${{ steps.release_tag.outputs.tag }}'
- name: Test release notes generator
shell: pwsh
run: ./.github/scripts/Test-GenerateReleaseNotes.ps1
- 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: |
$tag = '${{ steps.release_tag.outputs.tag }}'
$prevTag = ''
$prevOutput = & git describe --tags --abbrev=0 --exclude '*-*' "$tag^" 2>$null
if ($LASTEXITCODE -eq 0 -and $prevOutput) {
$prevTag = [string]$prevOutput
Write-Host "Preloading changelog entries from $prevTag..$tag before promotion."
./.github/scripts/Update-Changelog.ps1 -Mode Append -Range "$prevTag..$tag"
} else {
Write-Host "No previous stable tag found before $tag; promoting the existing Unreleased section."
}
./.github/scripts/Update-Changelog.ps1 -Mode Promote -Version $tag
# Stash the promoted file so we can re-apply it 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 | Out-Null
Copy-Item CHANGELOG.md .changelog-stash/CHANGELOG.md -Force
- name: Build distribution
shell: pwsh
env:
TAG_NAME: ${{ steps.release_tag.outputs.tag }}
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." }
$integrityName = $zip.Name -replace '\.zip$', '.integrity.tsv'
$integrity = Join-Path $zip.DirectoryName $integrityName
$zipLine = "$hash`t$($zip.Length)`t$($zip.Name)"
$manifestLines = Get-Content -LiteralPath $manifest.FullName -Encoding UTF8
[System.IO.File]::WriteAllLines(
$integrity,
[string[]](@($zipLine) + $manifestLines),
[System.Text.UTF8Encoding]::new($false))
"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
"integrity=$integrity" >> $env:GITHUB_OUTPUT
"integrity_name=$integrityName" >> $env:GITHUB_OUTPUT
Write-Host "Release zip: $($zip.Name)"
Write-Host "Release manifest: $($manifest.Name)"
Write-Host "Integrity asset: $integrityName"
Write-Host "Zip digest: $hash"
- name: Generate release body
id: changelog
shell: pwsh
env:
TAG_NAME: ${{ steps.release_tag.outputs.tag }}
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 +
# integrity-asset pointer + 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 = "${{ steps.release_tag.outputs.tag }}"
$zip = "${{ steps.zip.outputs.path }}"
$integrity = "${{ steps.zip.outputs.integrity }}"
$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.
$extra = @()
if ('${{ steps.beta.outputs.is_beta }}' -eq 'true') { $extra = @('--prerelease','--latest=false') }
gh release create $tag $zip $integrity --title $tag --notes-file $notesPath @extra
- name: Verify published release body matches input
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$tag = "${{ steps.release_tag.outputs.tag }}"
$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(steps.release_tag.outputs.tag, '-') }}
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
TAG_NAME: ${{ steps.release_tag.outputs.tag }}
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: Commit promoted CHANGELOG.md back to main (verified)
if: ${{ !contains(steps.release_tag.outputs.tag, '-') }}
# Uses the same createCommitOnBranch pattern as changelog-append.yml
# and the wrapper-hash step so the commit lands verified on main.
# Done last so a build/release failure doesn't leave a phantom
# changelog promotion for a release that never shipped.
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
TAG_NAME: ${{ steps.release_tag.outputs.tag }}
run: |
set -euo pipefail
# Replay the stashed file (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
main_oid=$(gh api "repos/$GITHUB_REPOSITORY/branches/main" --jq '.commit.sha')
main_changelog_blob=$(gh api "repos/$GITHUB_REPOSITORY/contents/CHANGELOG.md?ref=$main_oid" --jq '.sha' 2>/dev/null || echo "")
local_changelog_blob=$(git hash-object CHANGELOG.md)
if [[ "$main_changelog_blob" == "$local_changelog_blob" ]]; then
echo "main already carries the promoted changelog -- nothing to commit."
exit 0
fi
changelog_b64=$(base64 -w 0 CHANGELOG.md)
headline="docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]"
body=$(printf '%s\n%s' \
"Promotes the CHANGELOG.md section published for ${TAG_NAME}." \
'Mirrors the file that shipped in the release artifact back to main.')
payload=$(jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "main" \
--arg headline "$headline" \
--arg body "$body" \
--arg oid "$main_oid" \
--arg changelog "$changelog_b64" \
'{
query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }",
variables: {
input: {
branch: { repositoryNameWithOwner: $repo, branchName: $branch },
message: { headline: $headline, body: $body },
fileChanges: {
additions: [
{ path: "CHANGELOG.md", contents: $changelog }
]
},
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 "Changelog promotion commit: $new_oid (verified)"