Release #76
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 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)" |