diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 16da23e..f3cd455 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -7,11 +7,15 @@ on: push: branches: - main - tags: - - '**' pull_request: branches: - main + # Publishing a GitHub Release builds production NuGet packages versioned from the release tag + # and attaches them to the release. Publishing a release also pushes its tag, so this workflow + # does not also trigger on tag pushes; that would build every release twice. + release: + types: + - published permissions: contents: read @@ -98,6 +102,12 @@ jobs: name: Build + Test Library runs-on: windows-latest timeout-minutes: 60 + # Release runs sign the library DLLs with Azure Artifact Signing. Azure only trusts GitHub's OIDC token + # from the `release` environment, so pull requests and pushes to main build and pack unsigned DLLs. + environment: ${{ github.event_name == 'release' && 'release' || '' }} + permissions: + contents: read + id-token: write steps: - name: Checkout @@ -109,8 +119,20 @@ jobs: env: PR_NUMBER: ${{ github.event.pull_request.number }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { + if ($env:GITHUB_EVENT_NAME -eq 'release') { + # Release tags are the package version, optionally prefixed with `v` (e.g. `v10.1.0` or `10.1.0-pre1`). + $nugetPackageVersion = $env:RELEASE_TAG -replace '^v', '' + if ($nugetPackageVersion -notmatch '^(\d+\.\d+\.\d+)(-[0-9A-Za-z.-]+)?$') { + throw "Release tag '$($env:RELEASE_TAG)' is not a valid NuGet version. Use a tag such as v10.1.0 or v10.1.0-pre1." + } + + # AssemblyVersion and AssemblyFileVersion come from the NuGetVersion MSBuild property, which must not contain a prerelease suffix. + "ReleaseNuGetVersion=$($Matches[1])" >> $env:GITHUB_ENV + Write-Host "GitHub Release Tag = $($env:RELEASE_TAG)" + } + elseif ($env:GITHUB_EVENT_NAME -eq 'pull_request') { $commitId = $env:PR_HEAD_SHA.Substring(0, 7) $nugetPackageVersion = "$($env:CurrentSemanticVersionBase)-build-$($env:PR_NUMBER).$($env:GITHUB_RUN_NUMBER)+$commitId" Write-Host "GitHub PR = $($env:PR_NUMBER), Commit = $commitId" @@ -176,11 +198,115 @@ jobs: name: code-coverage-report path: ${{ runner.temp }}/CodeCoverage - - name: Pack AsyncAwaitBestPractices NuGet - run: dotnet pack $env:PathToAsyncAwaitBestPracticesCsproj -c Release "-p:PackageVersion=$env:NugetPackageVersion" + # Building, signing, and packing are separate steps so the DLLs are signed after they are built and + # `dotnet pack --no-build` packages those signed DLLs instead of rebuilding them. + - name: Build AsyncAwaitBestPractices + AsyncAwaitBestPractices.MVVM + run: | + $buildArguments = @('-c', 'Release', "-p:PackageVersion=$env:NugetPackageVersion") + if ($env:ReleaseNuGetVersion) { + $buildArguments += "-p:NuGetVersion=$env:ReleaseNuGetVersion" + } + + dotnet build $env:PathToAsyncAwaitBestPracticesCsproj @buildArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet build $env:PathToAsyncAwaitBestPracticesMVVMCsproj @buildArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Check Azure Artifact Signing Configuration + if: ${{ github.event_name == 'release' }} + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ vars.ARTIFACT_SIGNING_ACCOUNT_NAME }} + ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ vars.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + $missing = @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT_NAME', 'ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME') | + Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) } + if ($missing) { + throw "Add these secrets/variables to the 'release' environment (Settings > Environments > release): $($missing -join ', ')" + } + + - name: Find Library DLLs to Sign + id: files-to-sign + if: ${{ github.event_name == 'release' }} + run: | + $files = @( + Get-ChildItem -Path 'src/AsyncAwaitBestPractices/bin/Release/*/AsyncAwaitBestPractices.dll' + Get-ChildItem -Path 'src/AsyncAwaitBestPractices.MVVM/bin/Release/*/AsyncAwaitBestPractices.MVVM.dll' + ) + if ($files.Count -eq 0) { + throw "No library DLLs were found to sign." + } - - name: Pack AsyncAwaitBestPractices.MVVM NuGet - run: dotnet pack $env:PathToAsyncAwaitBestPracticesMVVMCsproj -c Release "-p:PackageVersion=$env:NugetPackageVersion" + $files.FullName | ForEach-Object { Write-Host $_ } + "files<> $env:GITHUB_OUTPUT + $files.FullName >> $env:GITHUB_OUTPUT + "EOF" >> $env:GITHUB_OUTPUT + + - name: Sign In to Azure + if: ${{ github.event_name == 'release' }} + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Library DLLs with Azure Artifact Signing + if: ${{ github.event_name == 'release' }} + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: ${{ steps.files-to-sign.outputs.files }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Pack AsyncAwaitBestPractices + AsyncAwaitBestPractices.MVVM NuGets + run: | + $packArguments = @('-c', 'Release', '--no-build', "-p:PackageVersion=$env:NugetPackageVersion") + if ($env:ReleaseNuGetVersion) { + $packArguments += "-p:NuGetVersion=$env:ReleaseNuGetVersion" + } + + dotnet pack $env:PathToAsyncAwaitBestPracticesCsproj @packArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet pack $env:PathToAsyncAwaitBestPracticesMVVMCsproj @packArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify Authenticode Signatures in NuGet Packages + if: ${{ github.event_name == 'release' }} + run: | + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packages = @(Get-ChildItem -Path ./src -Recurse -Filter *.nupkg | Where-Object { $_.FullName -match '[\\/]bin[\\/]Release[\\/]' }) + if ($packages.Count -eq 0) { + throw "No NuGet packages were produced." + } + + foreach ($package in $packages) { + $extractPath = Join-Path $env:RUNNER_TEMP ([System.IO.Path]::GetRandomFileName()) + [System.IO.Compression.ZipFile]::ExtractToDirectory($package.FullName, $extractPath) + $assemblies = @(Get-ChildItem -Path (Join-Path $extractPath 'lib') -Recurse -Filter *.dll -ErrorAction SilentlyContinue) + if ($assemblies.Count -eq 0) { + throw "No library DLLs were found in $($package.Name)." + } + + foreach ($assembly in $assemblies) { + $signature = Get-AuthenticodeSignature -FilePath $assembly.FullName + Write-Host "$($package.Name) $($assembly.FullName.Substring($extractPath.Length)): $($signature.Status) ($($signature.SignerCertificate.Subject))" + if ($signature.Status -ne 'Valid') { + throw "Authenticode signature verification failed for $($assembly.FullName): $($signature.StatusMessage)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "The Authenticode signature for $($assembly.FullName) is not timestamped." + } + } + + Remove-Item -Path $extractPath -Recurse -Force + } - name: Check AsyncAwaitBestPractices Dependencies Vulnerabilities if: ${{ !cancelled() }} @@ -204,15 +330,38 @@ jobs: exit 0 - name: Copy NuGet Packages to Staging Directory - if: ${{ !startsWith(github.ref, 'refs/tags/') }} run: | $stagingDirectory = New-Item -ItemType Directory -Force -Path (Join-Path $env:RUNNER_TEMP 'nuget') Get-ChildItem -Path ./src -Recurse | Where-Object { $_.Extension -match 'nupkg' } | Copy-Item -Destination $stagingDirectory.FullName - name: Publish NuGet Packages - if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: nuget path: ${{ runner.temp }}/nuget - if-no-files-found: error \ No newline at end of file + if-no-files-found: error + + attach_release_assets: + name: Attach NuGet Packages to GitHub Release + if: ${{ github.event_name == 'release' }} + needs: + - build_sample + - build_test_library + runs-on: ubuntu-latest + timeout-minutes: 10 + # Only this job can write to the repository, and it runs no build tools or third-party actions besides artifact download. + permissions: + contents: write + + steps: + - name: Download NuGet Packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget + path: ${{ runner.temp }}/nuget + + - name: Upload NuGet Packages to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$RELEASE_TAG" "$RUNNER_TEMP"/nuget/*.nupkg "$RUNNER_TEMP"/nuget/*.snupkg --repo "$GITHUB_REPOSITORY" --clobber