diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 00000000..c77392aa --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,243 @@ +name: Manual Beta Release + +on: + workflow_dispatch: + inputs: + release_notes: + description: Markdown release notes for this beta release + required: true + type: string + release_title: + description: Optional GitHub release title override + required: false + type: string + +permissions: + contents: write + packages: read + +jobs: + release-beta: + name: Build and publish beta release + runs-on: windows-latest + env: + PROJECT_PATH: Wino.Mail.WinUI/Wino.Mail.WinUI.csproj + MANIFEST_PATH: Wino.Mail.WinUI/Package.appxmanifest + PACKAGE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\package + RELEASE_OUTPUT_DIR: ${{ github.workspace }}\artifacts\release + CERTIFICATE_PFX_PATH: ${{ github.workspace }}\artifacts\signing\beta-signing-cert.pfx + CERTIFICATE_CER_PATH: ${{ github.workspace }}\artifacts\release\Wino-Mail-Beta.cer + steps: + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Fetch tags from origin + shell: pwsh + run: git fetch origin --force --tags refs/heads/main:refs/remotes/origin/main + + - name: Validate release secrets + shell: pwsh + env: + BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }} + BETA_SIGNING_CERT_PASSWORD: ${{ secrets.BETA_SIGNING_CERT_PASSWORD }} + run: | + if ([string]::IsNullOrWhiteSpace($env:BETA_SIGNING_CERT_PFX_BASE64)) { + throw "Missing required secret: BETA_SIGNING_CERT_PFX_BASE64" + } + + if ([string]::IsNullOrWhiteSpace($env:BETA_SIGNING_CERT_PASSWORD)) { + throw "Missing required secret: BETA_SIGNING_CERT_PASSWORD" + } + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + source-url: https://nuget.pkg.github.com/bkaankose/index.json + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute beta version and release metadata + id: metadata + shell: pwsh + env: + RELEASE_NOTES_INPUT: ${{ github.event.inputs.release_notes }} + RELEASE_TITLE_INPUT: ${{ github.event.inputs.release_title }} + run: | + $manifestPath = Join-Path $env:GITHUB_WORKSPACE $env:MANIFEST_PATH + if (-not (Test-Path $manifestPath)) { + throw "Package manifest not found: $manifestPath" + } + + [xml]$manifest = Get-Content -LiteralPath $manifestPath + $identityNode = $manifest.Package.Identity + if (-not $identityNode) { + throw "Could not locate the Package/Identity node in $manifestPath" + } + + $currentVersionText = [string]$identityNode.Version + if ($currentVersionText -notmatch '^(?\d+)\.(?\d+)\.(?\d+)\.(?\d+)$') { + throw "Manifest version '$currentVersionText' is not a four-part numeric version." + } + + $baseVersion = '{0}.{1}.{2}' -f $Matches.major, $Matches.minor, $Matches.patch + $nextRevision = [int]$Matches.revision + 1 + $packageVersion = '{0}.{1}.{2}.{3}' -f $Matches.major, $Matches.minor, $Matches.patch, $nextRevision + + $matchingTags = git tag --list "v$baseVersion.*" + $releaseNumbers = @() + foreach ($tag in $matchingTags) { + if ($tag -match "^v$([regex]::Escape($baseVersion))\.(\d+)$") { + $releaseNumbers += [int]$Matches[1] + } + } + + $nextReleaseNumber = if ($releaseNumbers.Count -gt 0) { ($releaseNumbers | Measure-Object -Maximum).Maximum + 1 } else { 1 } + $releaseTag = "v$baseVersion.$nextReleaseNumber" + $releaseTitleInput = $env:RELEASE_TITLE_INPUT + $releaseTitle = if ([string]::IsNullOrWhiteSpace($releaseTitleInput)) { $releaseTag } else { $releaseTitleInput.Trim() } + + $headSha = (git rev-parse HEAD).Trim() + if ([string]::IsNullOrWhiteSpace($headSha)) { + throw "Failed to resolve the checked out main commit SHA." + } + + $previousReleaseTag = git tag --sort=-creatordate --list 'v*.*.*.*' | Select-Object -First 1 + $commitLines = @() + if (-not [string]::IsNullOrWhiteSpace($previousReleaseTag)) { + $commitLines = git log "$previousReleaseTag..$headSha" --pretty=format:"- %s (%h)" + } + else { + $commitLines = git log $headSha -n 20 --pretty=format:"- %s (%h)" + } + + $buildDateUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'") + $notesInput = $env:RELEASE_NOTES_INPUT.Trim() + $metadataSection = @" + ## Build metadata + + - Package version: `$packageVersion` + - Release tag: `$releaseTag` + - Commit: `$headSha` + - Built: $buildDateUtc + "@ + + $commitSection = if ($commitLines.Count -gt 0) { + $commitHeader = if ([string]::IsNullOrWhiteSpace($previousReleaseTag)) { "## Recent commits" } else { "## Commits since $previousReleaseTag" } + @" + $commitHeader + + $($commitLines -join [Environment]::NewLine) + "@ + } + else { + '' + } + + $releaseNotesBody = @( + $notesInput + $metadataSection.Trim() + $commitSection.Trim() + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + New-Item -ItemType Directory -Path $env:RELEASE_OUTPUT_DIR -Force | Out-Null + $releaseNotesPath = Join-Path $env:RELEASE_OUTPUT_DIR 'beta-release-notes.md' + $releaseNotesBody -join ([Environment]::NewLine + [Environment]::NewLine) | Set-Content -LiteralPath $releaseNotesPath -Encoding utf8 + + "package_version=$packageVersion" >> $env:GITHUB_OUTPUT + "base_version=$baseVersion" >> $env:GITHUB_OUTPUT + "release_tag=$releaseTag" >> $env:GITHUB_OUTPUT + "release_title=$releaseTitle" >> $env:GITHUB_OUTPUT + "release_notes_path=$releaseNotesPath" >> $env:GITHUB_OUTPUT + "head_sha=$headSha" >> $env:GITHUB_OUTPUT + + - name: Materialize signing certificate + shell: pwsh + env: + BETA_SIGNING_CERT_PFX_BASE64: ${{ secrets.BETA_SIGNING_CERT_PFX_BASE64 }} + BETA_SIGNING_CERT_PASSWORD: ${{ secrets.BETA_SIGNING_CERT_PASSWORD }} + run: | + $signingDir = Split-Path -Parent $env:CERTIFICATE_PFX_PATH + New-Item -ItemType Directory -Path $signingDir -Force | Out-Null + [IO.File]::WriteAllBytes($env:CERTIFICATE_PFX_PATH, [Convert]::FromBase64String($env:BETA_SIGNING_CERT_PFX_BASE64)) + + $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($env:CERTIFICATE_PFX_PATH, $env:BETA_SIGNING_CERT_PASSWORD, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable) + + New-Item -ItemType Directory -Path (Split-Path -Parent $env:CERTIFICATE_CER_PATH) -Force | Out-Null + [IO.File]::WriteAllBytes($env:CERTIFICATE_CER_PATH, $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + + - name: Restore WinUI project dependencies + run: dotnet restore ${{ env.PROJECT_PATH }} --configfile nuget.config -p:Platform=x64 -p:RuntimeIdentifier=win-x64 + + - name: Build MSIX bundle + shell: pwsh + env: + BETA_SIGNING_CERT_PASSWORD: ${{ secrets.BETA_SIGNING_CERT_PASSWORD }} + run: | + New-Item -ItemType Directory -Path $env:PACKAGE_OUTPUT_DIR -Force | Out-Null + + dotnet build $env:PROJECT_PATH ` + --configuration Release ` + --no-restore ` + /p:Platform=x64 ` + /p:RuntimeIdentifier=win-x64 ` + /p:GenerateAppxPackageOnBuild=true ` + /p:UapAppxPackageBuildMode=SideloadOnly ` + /p:AppxBundle=Always ` + /p:AppxBundlePlatforms="x86|x64|arm64" ` + /p:AppxPackageDir="$env:PACKAGE_OUTPUT_DIR\\" ` + /p:AppxPackageVersion=${{ steps.metadata.outputs.package_version }} ` + /p:PackageCertificateKeyFile="$env:CERTIFICATE_PFX_PATH" ` + /p:PackageCertificatePassword="$env:BETA_SIGNING_CERT_PASSWORD" ` + /p:PackageCertificateThumbprint= ` + /p:AppxPackageSigningEnabled=true + + - name: Collect packaged artifacts + id: package + shell: pwsh + run: | + $bundle = Get-ChildItem -Path $env:PACKAGE_OUTPUT_DIR -Recurse -Filter *.msixbundle | Select-Object -First 1 + if (-not $bundle) { + throw "No .msixbundle file was generated under $env:PACKAGE_OUTPUT_DIR" + } + + $packageZipPath = Join-Path $env:RELEASE_OUTPUT_DIR 'Wino-Mail-Beta-PackageOutput.zip' + if (Test-Path $packageZipPath) { + Remove-Item -LiteralPath $packageZipPath -Force + } + + Compress-Archive -Path (Join-Path $env:PACKAGE_OUTPUT_DIR '*') -DestinationPath $packageZipPath -Force + + "bundle_path=$($bundle.FullName)" >> $env:GITHUB_OUTPUT + "bundle_name=$($bundle.Name)" >> $env:GITHUB_OUTPUT + "package_zip_path=$packageZipPath" >> $env:GITHUB_OUTPUT + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v4 + with: + name: beta-release-assets-${{ steps.metadata.outputs.release_tag }} + path: | + ${{ steps.package.outputs.bundle_path }} + ${{ env.CERTIFICATE_CER_PATH }} + ${{ steps.metadata.outputs.release_notes_path }} + ${{ steps.package.outputs.package_zip_path }} + if-no-files-found: error + + - name: Create GitHub prerelease + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.metadata.outputs.release_tag }}" ` + "${{ steps.package.outputs.bundle_path }}" ` + "${{ env.CERTIFICATE_CER_PATH }}" ` + "${{ steps.metadata.outputs.release_notes_path }}" ` + "${{ steps.package.outputs.package_zip_path }}" ` + --repo "${{ github.repository }}" ` + --target "${{ steps.metadata.outputs.head_sha }}" ` + --title "${{ steps.metadata.outputs.release_title }}" ` + --notes-file "${{ steps.metadata.outputs.release_notes_path }}" ` + --prerelease