|
22 | 22 | - 'docs/**' |
23 | 23 | workflow_dispatch: |
24 | 24 |
|
| 25 | +permissions: |
| 26 | + contents: read |
| 27 | + pull-requests: write |
| 28 | + |
25 | 29 | concurrency: |
26 | 30 | group: pr-${{ github.event.pull_request.number || github.ref }} |
27 | 31 | cancel-in-progress: true |
@@ -135,6 +139,230 @@ jobs: |
135 | 139 | shell: pwsh |
136 | 140 | run: .\scripts\DoTests.ps1 |
137 | 141 |
|
| 142 | + - name: Download baseline winmd from target branch |
| 143 | + if: github.event_name == 'pull_request' |
| 144 | + id: baseline |
| 145 | + uses: actions/github-script@v7 |
| 146 | + with: |
| 147 | + script: | |
| 148 | + const fs = require('fs'); |
| 149 | + const path = require('path'); |
| 150 | +
|
| 151 | + // Find the latest successful run on the target branch |
| 152 | + const baseBranch = context.payload.pull_request.base.ref; |
| 153 | + const runs = await github.rest.actions.listWorkflowRuns({ |
| 154 | + owner: context.repo.owner, |
| 155 | + repo: context.repo.repo, |
| 156 | + workflow_id: 'pr-validation.yml', |
| 157 | + branch: baseBranch, |
| 158 | + status: 'success', |
| 159 | + per_page: 1 |
| 160 | + }); |
| 161 | +
|
| 162 | + if (runs.data.workflow_runs.length === 0) { |
| 163 | + core.warning(`No successful runs found on ${baseBranch}. Will fall back to NuGet baseline.`); |
| 164 | + return; |
| 165 | + } |
| 166 | +
|
| 167 | + const runId = runs.data.workflow_runs[0].id; |
| 168 | + core.info(`Found baseline run ${runId} on ${baseBranch}`); |
| 169 | +
|
| 170 | + // Find the winmd artifact |
| 171 | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ |
| 172 | + owner: context.repo.owner, |
| 173 | + repo: context.repo.repo, |
| 174 | + run_id: runId |
| 175 | + }); |
| 176 | +
|
| 177 | + const winmdArtifact = artifacts.data.artifacts.find(a => a.name === 'winmd'); |
| 178 | + if (!winmdArtifact) { |
| 179 | + core.warning('No winmd artifact found in baseline run. Will fall back to NuGet baseline.'); |
| 180 | + return; |
| 181 | + } |
| 182 | +
|
| 183 | + // Download and extract |
| 184 | + const download = await github.rest.actions.downloadArtifact({ |
| 185 | + owner: context.repo.owner, |
| 186 | + repo: context.repo.repo, |
| 187 | + artifact_id: winmdArtifact.id, |
| 188 | + archive_format: 'zip' |
| 189 | + }); |
| 190 | +
|
| 191 | + const outputDir = 'bin/baseline-artifact'; |
| 192 | + fs.mkdirSync(outputDir, { recursive: true }); |
| 193 | + const zipPath = path.join(outputDir, 'artifact.zip'); |
| 194 | + fs.writeFileSync(zipPath, Buffer.from(download.data)); |
| 195 | +
|
| 196 | + // Extract using PowerShell |
| 197 | + const { execSync } = require('child_process'); |
| 198 | + execSync(`Expand-Archive -Path "${zipPath}" -DestinationPath "${outputDir}" -Force`, { shell: 'pwsh' }); |
| 199 | + fs.unlinkSync(zipPath); |
| 200 | + core.info(`Extracted baseline winmd to ${outputDir}`); |
| 201 | +
|
| 202 | + - name: Generate API surface diff |
| 203 | + if: github.event_name == 'pull_request' |
| 204 | + id: apidump |
| 205 | + shell: pwsh |
| 206 | + run: | |
| 207 | + $ErrorActionPreference = 'Continue' |
| 208 | + $PSNativeCommandUseErrorActionPreference = $false |
| 209 | +
|
| 210 | + $winmdUtils = "bin\Release\net8.0\WinmdUtils.dll" |
| 211 | + $currentWinmd = "bin\Windows.Win32.winmd" |
| 212 | +
|
| 213 | + # Determine baseline: prefer main branch artifact, fall back to last release |
| 214 | + $baselineWinmd = "bin\baseline-artifact\Windows.Win32.winmd" |
| 215 | + if (-not (Test-Path $baselineWinmd)) { |
| 216 | + Write-Host "No main branch artifact found, falling back to last NuGet release..." |
| 217 | + . .\scripts\CommonUtils.ps1 |
| 218 | + $baselineWinmd = Get-Win32MetadataLastReleaseWinmdPath |
| 219 | + } |
| 220 | +
|
| 221 | + Write-Host "Baseline: $baselineWinmd" |
| 222 | + Write-Host "Current: $currentWinmd" |
| 223 | +
|
| 224 | + # Dump baseline |
| 225 | + Write-Host "Dumping baseline..." |
| 226 | + & dotnet $winmdUtils dump --winmd $baselineWinmd --output bin\baseline.apidump.cs |
| 227 | + if ($LASTEXITCODE -ne 0) { throw "Failed to dump baseline" } |
| 228 | +
|
| 229 | + # Dump current build |
| 230 | + Write-Host "Dumping current build..." |
| 231 | + & dotnet $winmdUtils dump --winmd $currentWinmd --output bin\current.apidump.cs |
| 232 | + if ($LASTEXITCODE -ne 0) { throw "Failed to dump current" } |
| 233 | +
|
| 234 | + # Generate diff — git diff exits 1 when differences exist, which is expected |
| 235 | + & cmd /c "git diff --no-index --unified=3 bin\baseline.apidump.cs bin\current.apidump.cs > bin\api-diff.patch 2>&1" |
| 236 | + $diffExitCode = $LASTEXITCODE |
| 237 | +
|
| 238 | + if ($diffExitCode -eq 0) { |
| 239 | + Write-Host "No API differences found." |
| 240 | + "has_diff=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 241 | + } else { |
| 242 | + Write-Host "API differences detected." |
| 243 | + "has_diff=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 244 | +
|
| 245 | + # Count additions/deletions from the patch file |
| 246 | + $diffLines = Get-Content bin\api-diff.patch |
| 247 | + $additions = ($diffLines | Where-Object { $_ -match '^\+[^+]' }).Count |
| 248 | + $deletions = ($diffLines | Where-Object { $_ -match '^\-[^-]' }).Count |
| 249 | + "additions=$additions" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 250 | + "deletions=$deletions" | Out-File -FilePath $env:GITHUB_OUTPUT -Append |
| 251 | +
|
| 252 | + Write-Host "+$additions additions / -$deletions deletions" |
| 253 | +
|
| 254 | + # Truncate if too large for a GH comment |
| 255 | + $diffText = $diffLines -join "`n" |
| 256 | + $maxLen = 60000 |
| 257 | + if ($diffText.Length -gt $maxLen) { |
| 258 | + $diffText = $diffText.Substring(0, $maxLen) + "`n`n... (diff truncated - see full artifact)" |
| 259 | + Set-Content -Path bin\api-diff.patch -Value $diffText -Encoding UTF8 |
| 260 | + } |
| 261 | + } |
| 262 | +
|
| 263 | + # Reset LASTEXITCODE so GitHub Actions doesn't treat this step as failed |
| 264 | + $global:LASTEXITCODE = 0 |
| 265 | +
|
| 266 | + - name: Post API diff comment on PR |
| 267 | + if: github.event_name == 'pull_request' && steps.apidump.outputs.has_diff == 'true' |
| 268 | + uses: actions/github-script@v7 |
| 269 | + with: |
| 270 | + script: | |
| 271 | + const fs = require('fs'); |
| 272 | + const diff = fs.readFileSync('bin/api-diff.patch', 'utf8'); |
| 273 | + const prNumber = context.payload.pull_request.number; |
| 274 | + const additions = ${{ steps.apidump.outputs.additions || 0 }}; |
| 275 | + const deletions = ${{ steps.apidump.outputs.deletions || 0 }}; |
| 276 | + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; |
| 277 | +
|
| 278 | + const marker = '<!-- win32metadata-api-diff -->'; |
| 279 | + const body = `${marker} |
| 280 | + ## 📋 API Surface Diff |
| 281 | +
|
| 282 | + **+${additions} additions / -${deletions} deletions** vs main branch ([full build log](${runUrl})) |
| 283 | +
|
| 284 | + <details> |
| 285 | + <summary>Click to expand API diff</summary> |
| 286 | +
|
| 287 | + \`\`\`diff |
| 288 | + ${diff} |
| 289 | + \`\`\` |
| 290 | +
|
| 291 | + </details> |
| 292 | +
|
| 293 | + > This comment is automatically updated on each push.`; |
| 294 | +
|
| 295 | + const comments = await github.rest.issues.listComments({ |
| 296 | + owner: context.repo.owner, |
| 297 | + repo: context.repo.repo, |
| 298 | + issue_number: prNumber, |
| 299 | + per_page: 100 |
| 300 | + }); |
| 301 | +
|
| 302 | + const existing = comments.data.find(c => c.body.includes(marker)); |
| 303 | +
|
| 304 | + if (existing) { |
| 305 | + await github.rest.issues.updateComment({ |
| 306 | + owner: context.repo.owner, |
| 307 | + repo: context.repo.repo, |
| 308 | + comment_id: existing.id, |
| 309 | + body: body |
| 310 | + }); |
| 311 | + } else { |
| 312 | + await github.rest.issues.createComment({ |
| 313 | + owner: context.repo.owner, |
| 314 | + repo: context.repo.repo, |
| 315 | + issue_number: prNumber, |
| 316 | + body: body |
| 317 | + }); |
| 318 | + } |
| 319 | +
|
| 320 | + - name: Post no-diff comment on PR |
| 321 | + if: github.event_name == 'pull_request' && steps.apidump.outputs.has_diff == 'false' |
| 322 | + uses: actions/github-script@v7 |
| 323 | + with: |
| 324 | + script: | |
| 325 | + const prNumber = context.payload.pull_request.number; |
| 326 | + const marker = '<!-- win32metadata-api-diff -->'; |
| 327 | + const body = `${marker}\n## 📋 API Surface Diff\n\n✅ No API differences vs last release.\n\n> This comment is automatically updated on each push.`; |
| 328 | +
|
| 329 | + const comments = await github.rest.issues.listComments({ |
| 330 | + owner: context.repo.owner, |
| 331 | + repo: context.repo.repo, |
| 332 | + issue_number: prNumber, |
| 333 | + per_page: 100 |
| 334 | + }); |
| 335 | +
|
| 336 | + const existing = comments.data.find(c => c.body.includes(marker)); |
| 337 | +
|
| 338 | + if (existing) { |
| 339 | + await github.rest.issues.updateComment({ |
| 340 | + owner: context.repo.owner, |
| 341 | + repo: context.repo.repo, |
| 342 | + comment_id: existing.id, |
| 343 | + body: body |
| 344 | + }); |
| 345 | + } else { |
| 346 | + await github.rest.issues.createComment({ |
| 347 | + owner: context.repo.owner, |
| 348 | + repo: context.repo.repo, |
| 349 | + issue_number: prNumber, |
| 350 | + body: body |
| 351 | + }); |
| 352 | + } |
| 353 | +
|
| 354 | + - name: Upload winmd and API dump |
| 355 | + uses: actions/upload-artifact@v4 |
| 356 | + if: always() |
| 357 | + with: |
| 358 | + name: winmd |
| 359 | + path: | |
| 360 | + bin/Windows.Win32.winmd |
| 361 | + bin/current.apidump.cs |
| 362 | + bin/baseline.apidump.cs |
| 363 | + bin/api-diff.patch |
| 364 | + retention-days: 30 |
| 365 | + |
138 | 366 | - name: Upload build logs |
139 | 367 | uses: actions/upload-artifact@v4 |
140 | 368 | if: always() |
|
0 commit comments