Skip to content

Publish Windows x64 release v1.8.3 #34

Publish Windows x64 release v1.8.3

Publish Windows x64 release v1.8.3 #34

Workflow file for this run

name: Publish Windows Release
run-name: >-
Publish Windows x64 release ${{ github.ref_name }}
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: Existing version tag to build and publish
required: true
type: string
expected_source_sha:
description: Expected tag commit; defaults to the selected workflow ref
required: false
type: string
permissions:
actions: write
checks: read
contents: write
statuses: write
concurrency:
group: windows-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
release:
name: Build and publish Windows x64 release
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Resolve release tag
id: version
shell: pwsh
env:
PUSH_TAG: ${{ github.ref_name }}
MANUAL_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
run: |
$tag = if ($env:EVENT_NAME -eq 'workflow_dispatch') { $env:MANUAL_TAG } else { $env:PUSH_TAG }
if ($tag -notmatch '^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') {
throw "Release tag must match v<major>.<minor>.<patch>[-suffix]: $tag"
}
"tag=$tag" >> $env:GITHUB_OUTPUT
"version=$($tag.Substring(1))" >> $env:GITHUB_OUTPUT
- name: Checkout release tag
uses: actions/checkout@v5
with:
ref: ${{ steps.version.outputs.tag }}
fetch-depth: 0
- name: Verify release source commit
id: source
shell: pwsh
env:
EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha || github.sha }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
if ($env:EXPECTED_SOURCE_SHA -notmatch '^[0-9a-fA-F]{40}$') {
throw "Expected release source must be a full commit SHA."
}
$releaseSha = git rev-parse HEAD
if ($LASTEXITCODE -ne 0 -or
[string]::IsNullOrWhiteSpace($releaseSha)) {
throw "Failed to resolve release tag $env:RELEASE_TAG."
}
$releaseSha = $releaseSha.Trim()
if (-not [string]::Equals($releaseSha, $env:EXPECTED_SOURCE_SHA,
[StringComparison]::OrdinalIgnoreCase)) {
throw "Release tag $env:RELEASE_TAG resolves to $releaseSha, but the expected source is $env:EXPECTED_SOURCE_SHA."
}
"sha=$releaseSha" >> $env:GITHUB_OUTPUT
- name: Set up .NET 10 SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Set up Python for USB touch bridge
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Set up MSYS2 UCRT64 for UxPlay
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
# Avoid a full MSYS2 mirror upgrade on every run. The action still
# installs the pinned build dependencies below, while avoiding
# unrelated mirror timeouts during database refresh.
update: false
install: >-
mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja
mingw-w64-ucrt-x86_64-toolchain
mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-gstreamer
mingw-w64-ucrt-x86_64-gst-plugins-base
mingw-w64-ucrt-x86_64-gst-plugins-good
mingw-w64-ucrt-x86_64-gst-plugins-bad
mingw-w64-ucrt-x86_64-gst-libav
mingw-w64-ucrt-x86_64-libplist
mingw-w64-ucrt-x86_64-openssl
- name: Install Microsoft SBOM Tool
shell: pwsh
run: |
$tool = Get-Command sbom-tool,sbom -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $tool) {
dotnet tool install --global Microsoft.Sbom.DotNetTool
if ($LASTEXITCODE -ne 0) { throw "Failed to install Microsoft SBOM Tool." }
}
- name: Verify localization
shell: pwsh
run: ./scripts/verify_localization.ps1
- name: Build and test
shell: pwsh
run: ./build.ps1 -Configuration Release
- name: Package release assets
shell: pwsh
run: >-
./scripts/package_release.ps1
-Version '${{ steps.version.outputs.version }}'
-SkipBuild
-GenerateSbom
-UpdateReleaseManifest
- name: Prepare release notes
id: notes
shell: pwsh
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WORKFLOW_SHA: ${{ github.sha }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
$expectedNames = @(
"iPhoneMirror-Setup-v$env:RELEASE_VERSION-x64.exe",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64.zip",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64-sbom.spdx.json",
"SHA256SUMS.txt"
)
$descriptions = @{
$expectedNames[0] = 'Windows x64 安装程序,支持覆盖升级与卸载。'
$expectedNames[1] = '免安装便携版,完整解压后运行。'
$expectedNames[2] = 'SPDX 2.2 软件物料清单。'
$expectedNames[3] = '全部公开 Release 资产的 SHA-256 校验清单。'
}
$manifestPath = Join-Path $env:GITHUB_WORKSPACE 'updates/releases.json'
$manifestJson = [IO.File]::ReadAllText(
$manifestPath, [Text.Encoding]::UTF8) | ConvertFrom-Json
$manifestReleases = if ($manifestJson -is [array]) {
@($manifestJson)
}
elseif ($manifestJson.PSObject.Properties.Name -contains 'value') {
@($manifestJson.value)
}
else {
@($manifestJson)
}
$manifestMatches = @($manifestReleases | Where-Object {
[string]::Equals([string]$_.tag_name, $env:RELEASE_TAG,
[StringComparison]::OrdinalIgnoreCase)
})
if ($manifestMatches.Count -ne 1) {
throw "Generated manifest must contain exactly one $env:RELEASE_TAG entry."
}
$manifestAssets = @($manifestMatches[0].assets)
if ($manifestAssets.Count -ne $expectedNames.Count) {
throw "Generated manifest must contain exactly $($expectedNames.Count) release assets."
}
$assetRecords = @($expectedNames | ForEach-Object {
$name = $_
$path = Join-Path $env:GITHUB_WORKSPACE "outputs/releases/$name"
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Expected release asset is missing: $name"
}
$item = Get-Item -LiteralPath $path
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
$manifestAsset = @($manifestAssets | Where-Object {
[string]::Equals([string]$_.name, $name,
[StringComparison]::Ordinal)
})
if ($manifestAsset.Count -ne 1 -or
[long]$manifestAsset[0].size -ne $item.Length -or
-not [string]::Equals([string]$manifestAsset[0].digest,
"sha256:$hash", [StringComparison]::OrdinalIgnoreCase)) {
throw "Generated manifest does not match the local release asset: $name"
}
[PSCustomObject]@{
Name = $name
Path = $path
Size = [long]$item.Length
Hash = $hash
}
})
$checksumPath = Join-Path $env:GITHUB_WORKSPACE `
'outputs/releases/SHA256SUMS.txt'
$checksumEntries = @(Get-Content -LiteralPath $checksumPath -Encoding utf8 |
ForEach-Object {
if ($_ -notmatch '^([0-9a-fA-F]{64}) (.+)$') {
throw "Invalid SHA256SUMS.txt entry: $_"
}
[PSCustomObject]@{
Hash = $Matches[1].ToLowerInvariant()
Name = $Matches[2]
}
})
$hashedAssets = @($assetRecords | Where-Object {
$_.Name -ne 'SHA256SUMS.txt'
})
if ($checksumEntries.Count -ne $hashedAssets.Count) {
throw 'SHA256SUMS.txt must cover every non-checksum release asset exactly once.'
}
foreach ($asset in $hashedAssets) {
$matches = @($checksumEntries | Where-Object { $_.Name -eq $asset.Name })
if ($matches.Count -ne 1 -or $matches[0].Hash -ne $asset.Hash) {
throw "SHA256SUMS.txt does not match $($asset.Name)."
}
}
git fetch origin `
"refs/heads/$env:DEFAULT_BRANCH`:refs/remotes/origin/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Failed to fetch release notes from $env:DEFAULT_BRANCH."
}
$templateSpec = "$env:WORKFLOW_SHA`:docs/releases/$env:RELEASE_TAG.md"
$templateLines = @(git show $templateSpec)
if ($LASTEXITCODE -ne 0 -or $templateLines.Count -eq 0) {
throw "Release notes template is missing: docs/releases/$env:RELEASE_TAG.md"
}
$template = ($templateLines -join "`n").Trim()
if ([regex]::IsMatch($template,
'(?mi)^#{1,6}[ \t]*(?:中文|英文)[ \t]*$')) {
throw 'Release notes must not contain standalone Chinese or English labels.'
}
if ([regex]::IsMatch($template,
'(?mi)^##[ \t]+发布资产[ \t]*$|<!-- release-assets:')) {
throw 'The release notes template must not contain a generated asset section.'
}
$firstChinese = [regex]::Match($template, '[\u3400-\u9fff]')
$firstEnglishHeading = [regex]::Match($template,
'(?m)^### (?:Added|Changed|Fixed|Removed|Security|Notes)$')
if (-not $firstChinese.Success -or -not $firstEnglishHeading.Success -or
$firstChinese.Index -gt $firstEnglishHeading.Index) {
throw 'Release notes must place the Chinese content above the English content.'
}
$assetSection = [Collections.Generic.List[string]]::new()
$assetSection.Add('<!-- release-assets:start -->')
$assetSection.Add('## 发布资产')
$assetSection.Add('')
foreach ($asset in $assetRecords) {
$assetSection.Add("- ``$($asset.Name)``:$($descriptions[$asset.Name])")
}
$assetSection.Add('')
$assetSection.Add('| 资产 | 大小 | SHA-256 |')
$assetSection.Add('| --- | ---: | --- |')
foreach ($asset in $assetRecords) {
$size = $asset.Size.ToString('N0', [Globalization.CultureInfo]::InvariantCulture)
$assetSection.Add("| ``$($asset.Name)`` | $size bytes | ``$($asset.Hash)`` |")
}
$assetSection.Add('<!-- release-assets:end -->')
$notesPath = Join-Path $env:RUNNER_TEMP `
"release-notes-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.md"
$notes = $template + "`n`n" + ($assetSection -join "`n") + "`n"
[IO.File]::WriteAllText($notesPath, $notes,
[Text.UTF8Encoding]::new($false))
"path=$notesPath" >> $env:GITHUB_OUTPUT
- name: Create draft release when needed
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_NOTES: ${{ steps.notes.outputs.path }}
run: |
$releaseJson = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json isDraft 2> $null
if ($LASTEXITCODE -eq 0) {
$release = $releaseJson | ConvertFrom-Json
if (-not $release.isDraft) {
throw "Release $env:RELEASE_TAG is already public; refusing to overwrite its assets."
}
exit 0
}
gh release create $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY `
--draft `
--notes-file $env:RELEASE_NOTES `
--verify-tag `
--title "iPhoneMirror $env:RELEASE_TAG"
if ($LASTEXITCODE -ne 0) { throw "Failed to create draft release $env:RELEASE_TAG" }
- name: Upload release assets directly
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
$expectedNames = @(
"iPhoneMirror-Setup-v$env:RELEASE_VERSION-x64.exe",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64.zip",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64-sbom.spdx.json",
"SHA256SUMS.txt"
)
$assets = @($expectedNames | ForEach-Object {
$path = Join-Path outputs/releases $_
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Expected release asset is missing: $_"
}
Get-Item -LiteralPath $path
})
$unexpected = @(Get-ChildItem -LiteralPath outputs/releases -File |
Where-Object { $_.Name -notin $expectedNames })
if ($unexpected.Count -ne 0) {
throw "Unexpected release assets: $($unexpected.Name -join ', ')"
}
$assetPaths = @($assets.FullName)
gh release upload $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
@assetPaths --clobber
if ($LASTEXITCODE -ne 0) { throw "Failed to upload release assets for $env:RELEASE_TAG" }
- name: Verify uploaded assets and update release notes
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_NOTES: ${{ steps.notes.outputs.path }}
run: |
$expectedNames = @(
"iPhoneMirror-Setup-v$env:RELEASE_VERSION-x64.exe",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64.zip",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64-sbom.spdx.json",
"SHA256SUMS.txt"
)
$localAssets = @($expectedNames | ForEach-Object {
$path = Join-Path $env:GITHUB_WORKSPACE "outputs/releases/$_"
$item = Get-Item -LiteralPath $path
[PSCustomObject]@{
Name = $_
Size = [long]$item.Length
Digest = 'sha256:' + (Get-FileHash -LiteralPath $path `
-Algorithm SHA256).Hash.ToLowerInvariant()
}
})
function Get-ReleaseState {
$json = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json body,isDraft,assets
if ($LASTEXITCODE -ne 0) {
throw "Failed to inspect release $env:RELEASE_TAG."
}
return $json | ConvertFrom-Json
}
function Assert-ReleaseAssets([object]$Release) {
if (-not $Release.isDraft) {
throw "Release $env:RELEASE_TAG became public before validation completed."
}
$remoteAssets = @($Release.assets)
if ($remoteAssets.Count -ne $localAssets.Count) {
throw "Release $env:RELEASE_TAG has an unexpected asset count."
}
foreach ($local in $localAssets) {
$matches = @($remoteAssets | Where-Object {
[string]::Equals([string]$_.name, $local.Name,
[StringComparison]::Ordinal)
})
if ($matches.Count -ne 1 -or
[string]$matches[0].state -ne 'uploaded' -or
[long]$matches[0].size -ne $local.Size -or
-not [string]::Equals([string]$matches[0].digest,
$local.Digest, [StringComparison]::OrdinalIgnoreCase)) {
throw "Uploaded release asset does not match the build output: $($local.Name)"
}
}
}
$release = Get-ReleaseState
Assert-ReleaseAssets $release
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
--notes-file $env:RELEASE_NOTES
if ($LASTEXITCODE -ne 0) {
throw "Failed to update release notes for $env:RELEASE_TAG."
}
$updatedRelease = Get-ReleaseState
Assert-ReleaseAssets $updatedRelease
$expectedBody = [IO.File]::ReadAllText(
$env:RELEASE_NOTES, [Text.Encoding]::UTF8) -replace "`r`n", "`n"
$actualBody = ([string]$updatedRelease.body) -replace "`r`n", "`n"
if (-not [string]::Equals($actualBody.TrimEnd(),
$expectedBody.TrimEnd(), [StringComparison]::Ordinal)) {
throw "Published release notes do not match the generated notes for $env:RELEASE_TAG."
}
- name: Prepare fallback release manifest commit
id: manifest
shell: pwsh
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
git diff --quiet -- updates/releases.json
$packageDiffExit = $LASTEXITCODE
if ($packageDiffExit -eq 0) {
throw "Packaging did not update updates/releases.json for $env:RELEASE_TAG."
}
if ($packageDiffExit -ne 1) {
throw "Failed to inspect the generated release manifest."
}
$generatedManifest = Join-Path $env:RUNNER_TEMP `
"releases-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"
Copy-Item -LiteralPath updates/releases.json `
-Destination $generatedManifest -Force
git fetch origin `
"refs/heads/$env:DEFAULT_BRANCH`:refs/remotes/origin/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Failed to fetch $env:DEFAULT_BRANCH before publishing the release manifest."
}
git checkout --force --detach "refs/remotes/origin/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Failed to check out the latest $env:DEFAULT_BRANCH commit."
}
function Read-ReleaseManifest([string]$Path) {
$parsed = [IO.File]::ReadAllText(
$Path, [Text.Encoding]::UTF8) | ConvertFrom-Json
if ($parsed -is [array]) { return @($parsed) }
if ($parsed.PSObject.Properties.Name -contains 'value') {
return @($parsed.value)
}
return @($parsed)
}
$generatedReleases = @(Read-ReleaseManifest $generatedManifest)
$generatedMatches = @($generatedReleases | Where-Object {
[string]::Equals([string]$_.tag_name, $env:RELEASE_TAG,
[StringComparison]::OrdinalIgnoreCase)
})
if ($generatedMatches.Count -ne 1) {
throw "Generated manifest must contain exactly one $env:RELEASE_TAG entry."
}
$manifestPath = Join-Path $env:GITHUB_WORKSPACE 'updates/releases.json'
$currentReleases = @(Read-ReleaseManifest $manifestPath)
$currentMatches = @($currentReleases | Where-Object {
[string]::Equals([string]$_.tag_name, $env:RELEASE_TAG,
[StringComparison]::OrdinalIgnoreCase)
})
if ($currentMatches.Count -gt 1) {
throw "Current manifest contains duplicate $env:RELEASE_TAG entries."
}
if ($currentMatches.Count -eq 1) {
$generatedMatches[0].published_at = $currentMatches[0].published_at
}
$mergedReleases = @($generatedMatches[0]) + @($currentReleases |
Where-Object {
-not [string]::Equals([string]$_.tag_name, $env:RELEASE_TAG,
[StringComparison]::OrdinalIgnoreCase)
})
$mergedJson = ConvertTo-Json -InputObject $mergedReleases -Depth 100
[IO.File]::WriteAllText($manifestPath, $mergedJson + "`n",
[Text.UTF8Encoding]::new($false))
git diff --quiet -- updates/releases.json
$mergedDiffExit = $LASTEXITCODE
if ($mergedDiffExit -eq 0) {
Write-Host "Release manifest already contains $env:RELEASE_TAG."
"changed=false" >> $env:GITHUB_OUTPUT
exit 0
}
if ($mergedDiffExit -ne 1) {
throw "Failed to inspect the merged release manifest."
}
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -- updates/releases.json
git commit -m "Update $env:RELEASE_TAG release manifest"
if ($LASTEXITCODE -ne 0) { throw "Failed to commit the release manifest." }
$changedPaths = @(git diff-tree --no-commit-id --name-only -r HEAD |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($changedPaths.Count -ne 1 -or
$changedPaths[0] -ne 'updates/releases.json') {
throw "Release manifest commit contains unexpected paths: $($changedPaths -join ', ')"
}
$manifestSha = (git rev-parse HEAD).Trim()
$parentSha = (git rev-parse HEAD^).Trim()
$defaultBranchSha = (git rev-parse `
"refs/remotes/origin/$env:DEFAULT_BRANCH").Trim()
if ($parentSha -ne $defaultBranchSha) {
throw "Release manifest commit is not based on the latest $env:DEFAULT_BRANCH."
}
$safeTag = $env:RELEASE_TAG -replace '[^0-9A-Za-z.-]', '-'
$manifestBranch = "automation/release-manifest-$safeTag-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
"changed=true" >> $env:GITHUB_OUTPUT
"branch=$manifestBranch" >> $env:GITHUB_OUTPUT
"sha=$manifestSha" >> $env:GITHUB_OUTPUT
git push origin "HEAD:refs/heads/$manifestBranch"
if ($LASTEXITCODE -ne 0) {
throw "Failed to push temporary manifest branch $manifestBranch."
}
- name: Validate fallback release manifest commit
id: validation
if: steps.manifest.outputs.changed == 'true'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
MANIFEST_BRANCH: ${{ steps.manifest.outputs.branch }}
MANIFEST_SHA: ${{ steps.manifest.outputs.sha }}
run: |
gh workflow run windows-build.yml --repo $env:GITHUB_REPOSITORY `
--ref $env:MANIFEST_BRANCH
if ($LASTEXITCODE -ne 0) {
throw "Failed to dispatch manifest validation for $env:MANIFEST_BRANCH."
}
$validationRun = $null
for ($attempt = 0; $attempt -lt 60 -and $null -eq $validationRun; ++$attempt) {
$runsJson = gh run list --repo $env:GITHUB_REPOSITORY `
--workflow windows-build.yml `
--branch $env:MANIFEST_BRANCH `
--event workflow_dispatch `
--limit 10 `
--json databaseId,headSha,createdAt
if ($LASTEXITCODE -ne 0) {
throw "Failed to query the manifest validation run."
}
$validationRun = @($runsJson | ConvertFrom-Json |
Where-Object { $_.headSha -eq $env:MANIFEST_SHA } |
Sort-Object createdAt -Descending |
Select-Object -First 1)[0]
if ($null -eq $validationRun) { Start-Sleep -Seconds 2 }
}
if ($null -eq $validationRun) {
throw "Timed out waiting for manifest validation to start."
}
"run_id=$($validationRun.databaseId)" >> $env:GITHUB_OUTPUT
"run_url=$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$($validationRun.databaseId)" >> `
$env:GITHUB_OUTPUT
gh run watch $validationRun.databaseId --repo $env:GITHUB_REPOSITORY `
--exit-status
if ($LASTEXITCODE -ne 0) {
throw "Manifest validation failed: $($validationRun.databaseId)."
}
$checksJson = gh api `
"repos/$env:GITHUB_REPOSITORY/commits/$env:MANIFEST_SHA/check-runs"
if ($LASTEXITCODE -ne 0) {
throw "Failed to inspect manifest validation checks."
}
$matchingChecks = @(($checksJson | ConvertFrom-Json).check_runs |
Where-Object {
$_.name -eq 'build-test' -and
$_.app.slug -eq 'github-actions' -and
$_.conclusion -eq 'success'
})
if ($matchingChecks.Count -eq 0) {
throw "The manifest commit has no successful GitHub Actions build-test check."
}
$validationUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$($validationRun.databaseId)"
$statusJson = gh api --method POST `
"repos/$env:GITHUB_REPOSITORY/statuses/$env:MANIFEST_SHA" `
-f state=success `
-f context=build-test `
-f target_url=$validationUrl `
-f description="Validated by Windows build run $($validationRun.databaseId)"
if ($LASTEXITCODE -ne 0) {
throw "Failed to relay the successful manifest validation status."
}
$status = $statusJson | ConvertFrom-Json
if ($status.state -ne 'success' -or
$status.context -ne 'build-test' -or
$status.target_url -ne $validationUrl -or
$status.creator.login -ne 'github-actions[bot]') {
throw "Relayed manifest validation status is invalid."
}
"status_id=$($status.id)" >> $env:GITHUB_OUTPUT
- name: Verify release tag before publication
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SOURCE_SHA: ${{ steps.source.outputs.sha }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
$tagRefJson = gh api `
"repos/$env:GITHUB_REPOSITORY/git/ref/tags/$env:RELEASE_TAG"
if ($LASTEXITCODE -ne 0) {
throw "Failed to inspect release tag $env:RELEASE_TAG."
}
$tagObject = ($tagRefJson | ConvertFrom-Json).object
while ($tagObject.type -eq 'tag') {
$tagJson = gh api `
"repos/$env:GITHUB_REPOSITORY/git/tags/$($tagObject.sha)"
if ($LASTEXITCODE -ne 0) {
throw "Failed to peel release tag $env:RELEASE_TAG."
}
$tagObject = ($tagJson | ConvertFrom-Json).object
}
if ($tagObject.type -ne 'commit' -or
-not [string]::Equals([string]$tagObject.sha,
$env:RELEASE_SOURCE_SHA,
[StringComparison]::OrdinalIgnoreCase)) {
throw "Release tag $env:RELEASE_TAG moved during the release workflow."
}
- name: Preflight fallback release manifest
if: steps.manifest.outputs.changed == 'true'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
MANIFEST_BRANCH: ${{ steps.manifest.outputs.branch }}
MANIFEST_SHA: ${{ steps.manifest.outputs.sha }}
VALIDATION_RUN_URL: ${{ steps.validation.outputs.run_url }}
VALIDATION_STATUS_ID: ${{ steps.validation.outputs.status_id }}
run: |
git fetch origin `
"refs/heads/$env:DEFAULT_BRANCH`:refs/remotes/origin/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Failed to refresh $env:DEFAULT_BRANCH before publishing the manifest."
}
$parentSha = (git rev-parse HEAD^).Trim()
$defaultBranchSha = (git rev-parse `
"refs/remotes/origin/$env:DEFAULT_BRANCH").Trim()
if ($parentSha -ne $defaultBranchSha) {
throw "$env:DEFAULT_BRANCH advanced during manifest validation; rerun the release workflow."
}
$statusesJson = gh api `
"repos/$env:GITHUB_REPOSITORY/commits/$env:MANIFEST_SHA/statuses"
if ($LASTEXITCODE -ne 0) {
throw "Failed to inspect the relayed manifest validation status."
}
$statusId = [long]$env:VALIDATION_STATUS_ID
if ($statusId -le 0) {
throw "The relayed manifest validation status ID is invalid."
}
$statuses = @($statusesJson | ConvertFrom-Json)
$matchingStatuses = @($statuses |
Where-Object {
[long]$_.id -eq $statusId -and
$_.context -eq 'build-test' -and
$_.state -eq 'success' -and
$_.target_url -eq $env:VALIDATION_RUN_URL -and
$_.creator.login -eq 'github-actions[bot]'
})
if ($matchingStatuses.Count -eq 0) {
throw "The manifest commit has no valid relayed build-test status."
}
$headSha = (git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $headSha -ne $env:MANIFEST_SHA) {
throw 'The checked-out manifest commit does not match the validated SHA.'
}
$branchJson = gh api `
"repos/$env:GITHUB_REPOSITORY/git/ref/heads/$env:MANIFEST_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Failed to inspect temporary branch $env:MANIFEST_BRANCH."
}
$branchSha = [string](($branchJson | ConvertFrom-Json).object.sha)
if ($branchSha -ne $env:MANIFEST_SHA) {
throw "Temporary branch $env:MANIFEST_BRANCH no longer points to the validated commit."
}
- name: Publish release and fallback manifest
id: transaction
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_NOTES: ${{ steps.notes.outputs.path }}
MANIFEST_CHANGED: ${{ steps.manifest.outputs.changed }}
MANIFEST_SHA: ${{ steps.manifest.outputs.sha }}
run: |
$expectedNames = @(
"iPhoneMirror-Setup-v$env:RELEASE_VERSION-x64.exe",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64.zip",
"iPhoneMirror-v$env:RELEASE_VERSION-win-x64-sbom.spdx.json",
"SHA256SUMS.txt"
)
$localAssets = @($expectedNames | ForEach-Object {
$path = Join-Path $env:GITHUB_WORKSPACE "outputs/releases/$_"
$item = Get-Item -LiteralPath $path
[PSCustomObject]@{
Name = $_
Size = [long]$item.Length
Digest = 'sha256:' + (Get-FileHash -LiteralPath $path `
-Algorithm SHA256).Hash.ToLowerInvariant()
}
})
function Read-ManifestReleases([string]$Json) {
$parsed = $Json | ConvertFrom-Json
if ($parsed -is [array]) { return $parsed }
if ($parsed.PSObject.Properties.Name -contains 'value') {
return $parsed.value
}
return $parsed
}
function ConvertTo-CanonicalRelease([object]$Release) {
$assets = @($Release.assets | Sort-Object { [string]$_.name } |
ForEach-Object {
[ordered]@{
name = [string]$_.name
size = [long]$_.size
digest = [string]$_.digest
browser_download_url = [string]$_.browser_download_url
}
})
return ([ordered]@{
tag_name = [string]$Release.tag_name
name = [string]$Release.name
body = [string]$Release.body
published_at = [string]$Release.published_at
draft = [bool]$Release.draft
prerelease = [bool]$Release.prerelease
assets = $assets
} | ConvertTo-Json -Depth 10 -Compress)
}
$localManifest = [IO.File]::ReadAllText(
(Join-Path $env:GITHUB_WORKSPACE 'updates/releases.json'),
[Text.Encoding]::UTF8)
$expectedManifestMatches = @(Read-ManifestReleases $localManifest |
Where-Object { $_.tag_name -eq $env:RELEASE_TAG })
if ($expectedManifestMatches.Count -ne 1) {
throw "Local manifest must contain exactly one $env:RELEASE_TAG entry."
}
$expectedManifestEntry = ConvertTo-CanonicalRelease `
$expectedManifestMatches[0]
function Get-ReleaseState {
$json = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json body,isDraft,assets
if ($LASTEXITCODE -ne 0) { return $null }
return $json | ConvertFrom-Json
}
function Assert-ReleaseContent([object]$Release) {
if ($null -eq $Release -or $Release.isDraft) {
throw "Release $env:RELEASE_TAG is not public."
}
$remoteAssets = @($Release.assets)
if ($remoteAssets.Count -ne $localAssets.Count) {
throw "Public release $env:RELEASE_TAG has an unexpected asset count."
}
foreach ($local in $localAssets) {
$matches = @($remoteAssets | Where-Object {
[string]::Equals([string]$_.name, $local.Name,
[StringComparison]::Ordinal)
})
if ($matches.Count -ne 1 -or
[string]$matches[0].state -ne 'uploaded' -or
[long]$matches[0].size -ne $local.Size -or
-not [string]::Equals([string]$matches[0].digest,
$local.Digest, [StringComparison]::OrdinalIgnoreCase)) {
throw "Public release asset does not match the build output: $($local.Name)"
}
}
$expectedBody = [IO.File]::ReadAllText(
$env:RELEASE_NOTES, [Text.Encoding]::UTF8) -replace "`r`n", "`n"
$actualBody = ([string]$Release.body) -replace "`r`n", "`n"
if (-not [string]::Equals($actualBody.TrimEnd(),
$expectedBody.TrimEnd(), [StringComparison]::Ordinal)) {
throw "Public release notes do not match the generated notes for $env:RELEASE_TAG."
}
}
function Restore-Draft([string]$Reason) {
if ($env:MANIFEST_CHANGED -ne 'true') {
throw "$Reason The manifest is already public, so the Release was not returned to draft."
}
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
--draft=true
$redraftExit = $LASTEXITCODE
$release = Get-ReleaseState
if ($redraftExit -eq 0 -and $null -ne $release -and $release.isDraft) {
"cleanup_branch=true" >> $env:GITHUB_OUTPUT
throw "$Reason Release $env:RELEASE_TAG was returned to draft state."
}
throw "$Reason Failed to restore draft state; manual recovery is required."
}
function Get-DefaultBranchSha {
$refJson = gh api `
"repos/$env:GITHUB_REPOSITORY/git/ref/heads/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) { return $null }
return [string](($refJson | ConvertFrom-Json).object.sha)
}
function Get-ManifestPublication([string]$RemoteSha) {
if ([string]::IsNullOrWhiteSpace($RemoteSha)) { return 'unknown' }
if ($RemoteSha -eq $env:MANIFEST_SHA) { return 'published' }
$contentJson = gh api `
"repos/$env:GITHUB_REPOSITORY/contents/updates/releases.json?ref=$RemoteSha"
if ($LASTEXITCODE -ne 0) { return 'unknown' }
try {
$contentResponse = $contentJson | ConvertFrom-Json
$bytes = [Convert]::FromBase64String([string]$contentResponse.content)
$remoteManifest = [Text.Encoding]::UTF8.GetString($bytes)
$matches = @(Read-ManifestReleases $remoteManifest |
Where-Object { $_.tag_name -eq $env:RELEASE_TAG })
if ($matches.Count -ne 1) { return 'not-published' }
$actualEntry = ConvertTo-CanonicalRelease $matches[0]
if ([string]::Equals($actualEntry, $expectedManifestEntry,
[StringComparison]::Ordinal)) {
return 'published'
}
return 'not-published'
}
catch {
return 'unknown'
}
}
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
--draft=false
$releaseEditExit = $LASTEXITCODE
$release = Get-ReleaseState
if ($releaseEditExit -ne 0 -and ($null -eq $release -or $release.isDraft)) {
Restore-Draft "Failed to publish release $env:RELEASE_TAG."
}
try {
Assert-ReleaseContent $release
$publicUri = "$env:GITHUB_API_URL/repos/$env:GITHUB_REPOSITORY/releases/tags/" +
[Uri]::EscapeDataString($env:RELEASE_TAG)
$publicRelease = $null
$publicError = $null
for ($attempt = 0; $attempt -lt 6 -and $null -eq $publicRelease; ++$attempt) {
try {
$candidate = Invoke-RestMethod -Uri $publicUri -Headers @{
Accept = 'application/vnd.github+json'
'X-GitHub-Api-Version' = '2022-11-28'
'User-Agent' = 'iPhoneMirror-release-workflow'
}
if (-not $candidate.draft -and
@($candidate.assets).Count -eq $expectedNames.Count) {
$publicRelease = $candidate
break
}
$publicError = 'The public release is incomplete.'
}
catch {
$statusCode = if ($null -ne $_.Exception.Response) {
[int]$_.Exception.Response.StatusCode
}
else { 0 }
$publicError = "Public Release API request failed with HTTP $statusCode."
}
if ($attempt -lt 5) {
$delay = [Math]::Min([Math]::Pow(2, $attempt), 10)
Start-Sleep -Seconds $delay
}
}
if ($null -eq $publicRelease) {
throw $publicError
}
if ($publicRelease.draft -or
@($publicRelease.assets).Count -ne $expectedNames.Count) {
throw 'The unauthenticated Release API does not expose the complete release.'
}
foreach ($name in $expectedNames) {
if (@($publicRelease.assets | Where-Object { $_.name -eq $name }).Count -ne 1) {
throw "The unauthenticated Release API does not expose asset $name."
}
}
}
catch {
Restore-Draft "Release publication verification failed: $($_.Exception.Message)"
}
if ($env:MANIFEST_CHANGED -ne 'true') {
"cleanup_branch=true" >> $env:GITHUB_OUTPUT
exit 0
}
git push origin "HEAD:refs/heads/$env:DEFAULT_BRANCH"
$pushExit = $LASTEXITCODE
$publication = 'unknown'
for ($attempt = 0; $attempt -lt 6; ++$attempt) {
$remoteSha = Get-DefaultBranchSha
$publication = Get-ManifestPublication $remoteSha
if ($publication -eq 'published') { break }
if ($attempt -lt 5) {
$delay = [Math]::Min([Math]::Pow(2, $attempt), 10)
Write-Host "Release manifest is $publication; retrying in $delay seconds."
Start-Sleep -Seconds $delay
}
}
if ($publication -eq 'not-published') {
Restore-Draft "Failed to publish the release manifest to $env:DEFAULT_BRANCH."
}
if ($publication -eq 'unknown') {
throw "Unable to determine whether the release manifest reached $env:DEFAULT_BRANCH; the public Release was left unchanged."
}
if ($pushExit -ne 0) {
Write-Warning 'git push reported an error, but the validated manifest is present on the default branch.'
}
"cleanup_branch=true" >> $env:GITHUB_OUTPUT
- name: Reconcile interrupted release transaction
id: reconcile
if: >-
always() &&
steps.manifest.outcome == 'success' &&
steps.transaction.outcome != 'success' &&
((steps.manifest.outputs.changed == 'true' &&
steps.validation.outcome == 'success') ||
steps.manifest.outputs.changed == 'false')
continue-on-error: true
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
MANIFEST_CHANGED: ${{ steps.manifest.outputs.changed }}
MANIFEST_SHA: ${{ steps.manifest.outputs.sha }}
run: |
function Read-ManifestReleases([string]$Json) {
$parsed = $Json | ConvertFrom-Json
if ($parsed -is [array]) { return $parsed }
if ($parsed.PSObject.Properties.Name -contains 'value') {
return $parsed.value
}
return $parsed
}
function ConvertTo-CanonicalRelease([object]$Release) {
$assets = @($Release.assets | Sort-Object { [string]$_.name } |
ForEach-Object {
[ordered]@{
name = [string]$_.name
size = [long]$_.size
digest = [string]$_.digest
browser_download_url = [string]$_.browser_download_url
}
})
return ([ordered]@{
tag_name = [string]$Release.tag_name
name = [string]$Release.name
body = [string]$Release.body
published_at = [string]$Release.published_at
draft = [bool]$Release.draft
prerelease = [bool]$Release.prerelease
assets = $assets
} | ConvertTo-Json -Depth 10 -Compress)
}
$localManifest = [IO.File]::ReadAllText(
(Join-Path $env:GITHUB_WORKSPACE 'updates/releases.json'),
[Text.Encoding]::UTF8)
$expectedMatches = @(Read-ManifestReleases $localManifest |
Where-Object { $_.tag_name -eq $env:RELEASE_TAG })
if ($expectedMatches.Count -ne 1) {
throw "Local manifest must contain exactly one $env:RELEASE_TAG entry."
}
$expectedEntry = ConvertTo-CanonicalRelease $expectedMatches[0]
function Get-RemoteManifestState([string]$RemoteSha) {
$contentJson = gh api `
"repos/$env:GITHUB_REPOSITORY/contents/updates/releases.json?ref=$RemoteSha"
if ($LASTEXITCODE -ne 0) { return 'unknown' }
try {
$contentResponse = $contentJson | ConvertFrom-Json
$bytes = [Convert]::FromBase64String([string]$contentResponse.content)
$remoteManifest = [Text.Encoding]::UTF8.GetString($bytes)
$matches = @(Read-ManifestReleases $remoteManifest |
Where-Object { $_.tag_name -eq $env:RELEASE_TAG })
if ($matches.Count -ne 1) { return 'invalid' }
$actualEntry = ConvertTo-CanonicalRelease $matches[0]
if ([string]::Equals($actualEntry, $expectedEntry,
[StringComparison]::Ordinal)) {
return 'valid'
}
return 'invalid'
}
catch {
return 'unknown'
}
}
$releaseJson = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json isDraft
if ($LASTEXITCODE -ne 0) {
throw "Unable to inspect release $env:RELEASE_TAG during reconciliation."
}
$release = $releaseJson | ConvertFrom-Json
$refJson = gh api `
"repos/$env:GITHUB_REPOSITORY/git/ref/heads/$env:DEFAULT_BRANCH"
if ($LASTEXITCODE -ne 0) {
throw "Unable to inspect $env:DEFAULT_BRANCH during reconciliation."
}
$remoteSha = [string](($refJson | ConvertFrom-Json).object.sha)
$manifestState = if ($env:MANIFEST_CHANGED -eq 'true' -and
$remoteSha -eq $env:MANIFEST_SHA) {
'valid'
}
else {
Get-RemoteManifestState $remoteSha
}
if ($manifestState -eq 'unknown') {
throw 'Unable to validate the current remote release manifest.'
}
$manifestPublished = $manifestState -eq 'valid'
if ($manifestPublished -and $release.isDraft) {
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
--draft=false
if ($LASTEXITCODE -ne 0) {
throw 'The manifest is public, but the Release could not be republished.'
}
$releaseJson = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json isDraft
if ($LASTEXITCODE -ne 0 -or ($releaseJson | ConvertFrom-Json).isDraft) {
throw 'The manifest is public, but the Release remains a draft.'
}
}
elseif (-not $manifestPublished -and -not $release.isDraft) {
gh release edit $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY `
--draft=true
if ($LASTEXITCODE -ne 0) {
throw 'The manifest is not public, but the Release could not be returned to draft.'
}
$releaseJson = gh release view $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY --json isDraft
if ($LASTEXITCODE -ne 0 -or -not ($releaseJson | ConvertFrom-Json).isDraft) {
throw 'The manifest is not public, but the Release remains public.'
}
}
"cleanup_branch=true" >> $env:GITHUB_OUTPUT
- name: Delete manifest validation branch
if: >-
always() &&
steps.manifest.outputs.changed == 'true' &&
(steps.validation.outcome != 'success' ||
steps.transaction.outputs.cleanup_branch == 'true' ||
steps.reconcile.outputs.cleanup_branch == 'true')
continue-on-error: true
shell: pwsh
env:
MANIFEST_BRANCH: ${{ steps.manifest.outputs.branch }}
run: |
git ls-remote --exit-code --heads origin `
"refs/heads/$env:MANIFEST_BRANCH" *> $null
$branchExit = $LASTEXITCODE
if ($branchExit -eq 2) {
Write-Host "Temporary branch $env:MANIFEST_BRANCH is already deleted."
exit 0
}
if ($branchExit -ne 0) {
Write-Warning "Failed to inspect temporary branch $env:MANIFEST_BRANCH."
exit 0
}
git push origin --delete $env:MANIFEST_BRANCH
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to delete temporary branch $env:MANIFEST_BRANCH."
}