From 7128394ed4999be0533cd506c1fab4da6bbf6408 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:02:36 -0600 Subject: [PATCH 1/5] Make API generation failures non-fatal and track them in one issue Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/update-integration-data.yml | 38 +++++++++++++++++++ .../scripts/update-integration-data.ps1 | 20 +++++++++- .../generate-package-json.ps1 | 17 +++++++-- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index e59bc9443..fad27f629 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -205,6 +205,44 @@ jobs: --body "The scheduled [Integration Data Updater]($RUN_URL) run failed. This issue is updated on each consecutive failure; close it once the workflow is green." fi + - name: Track API generation failures + if: ${{ always() && steps.update.outcome == 'success' && steps.update.outputs.regen_ran == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + FAILURES: ${{ steps.update.outputs.api_failures }} + shell: bash + run: | + set -euo pipefail + + # One long-lived issue. Its body always lists the current failing + # packages; a comment is added only when that list changes, and the issue + # is closed once nothing fails, so the same failures never create noise. + title="Integration Data Updater: API generation failures" + existing=$(gh issue list \ + --state open \ + --search "\"$title\" in:title" \ + --json number,title \ + --jq "map(select(.title == \"$title\")) | .[0].number // empty") + + if [ -z "$FAILURES" ]; then + if [ -n "$existing" ]; then + gh issue close "$existing" --comment "No packages failed API generation in $RUN_URL." + fi + exit 0 + fi + + marker="" + body=$(printf '%s\n\nThese packages failed API generation in the latest [Integration Data Updater run](%s). Their previous API data is kept and the rest of the data still updates. This issue updates itself and closes when nothing fails.\n\n```\n%s\n```\n' "$marker" "$RUN_URL" "$FAILURES") + + if [ -z "$existing" ]; then + gh issue create --title "$title" --body "$body" + elif ! gh issue view "$existing" --json body --jq .body | grep -qF "$marker"; then + gh issue edit "$existing" --body "$body" + gh issue comment "$existing" --body "The set of failing packages changed: $RUN_URL" + fi + - name: Summary if: ${{ always() }} shell: bash diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index 3e08c5383..f9ee981d4 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -372,6 +372,8 @@ else { $regenRan = $false $pkgSummary = '' $pkgSkippedPackages = '' +$pkgFailedPackages = '' +$tsFailedPackages = '' $tsApiSummary = '' $tsSkippedPackages = '' $tsToleratedPackages = '' @@ -417,7 +419,9 @@ if ($versionsChanged -and -not $SkipRegen) { } $pkgSummary = "Success: $($Matches.Succeeded) | Failed: $($Matches.Failed) | Skipped: $($Matches.Skipped)" if ([int]$Matches.Failed -gt 0) { - Stop-ApiRegeneration "C# API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." + $pkgFailedLine = ($pkgLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) + $pkgFailedPackages = if ($pkgFailedLine) { ($pkgFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } + Write-Warning "C# API generation failed for: $pkgFailedPackages. Continuing; existing data for these packages is kept." } $pkgSkippedLine = ($pkgLog -split "`n" | Where-Object { $_ -match '^\s*Skipped packages:' } | @@ -476,7 +480,9 @@ if ($versionsChanged -and -not $SkipRegen) { } $tsApiSummary = "$($Matches.Succeeded) succeeded, $($Matches.Failed) failed, $($Matches.Skipped) skipped" if ([int]$Matches.Failed -gt 0) { - Stop-ApiRegeneration "TypeScript API generation reported $($Matches.Failed) package failure(s). Aborting; no PR will be opened." + $tsFailedLine = ($tsLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) + $tsFailedPackages = if ($tsFailedLine) { ($tsFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } + Write-Warning "TypeScript API generation failed for: $tsFailedPackages. Continuing." } $tsSkippedLine = ($tsLog -split "`n" | Where-Object { $_ -match '^\s*Skipped packages:' } | @@ -662,6 +668,15 @@ if ($regenRan) { if ($tsSkippedPackages) { [void]$sb.AppendLine("- TypeScript packages skipped because they export no ATS functions: ``$tsSkippedPackages``") } + if ($pkgFailedPackages -or $tsFailedPackages) { + [void]$sb.AppendLine("") + [void]$sb.AppendLine("### :warning: API generation failures") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("These packages failed to generate and kept their previous API data. They are tracked in the *Integration Data Updater: API generation failures* issue.") + [void]$sb.AppendLine("") + if ($pkgFailedPackages) { [void]$sb.AppendLine("- C#: ``$pkgFailedPackages``") } + if ($tsFailedPackages) { [void]$sb.AppendLine("- TypeScript: ``$tsFailedPackages``") } + } if ($tsToleratedPackages) { [void]$sb.AppendLine("") [void]$sb.AppendLine("### :warning: Tolerated ATS scanner diagnostics") @@ -723,6 +738,7 @@ Set-Output 'semantic_validation' $(if ($regenRan) { $semanticSummary } else { 'n Set-Output 'pr_title' $prTitle Set-Output 'pr_body_file' $prBodyFile Set-Output 'icon_warnings' $iconWarnings +Set-Output 'api_failures' (@(@($pkgFailedPackages, $tsFailedPackages) | Where-Object { $_ }) -join "`n") Write-Host "PR title : $prTitle" Write-Host "PR body : $prBodyFile" diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index a6d43352b..e0ea8f6bf 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -1229,8 +1229,17 @@ Write-Host "Output: $FinalOutputDir" Remove-Item (Join-Path $ScriptDir ".package-json-generator-work") -Recurse -Force -ErrorAction SilentlyContinue if ($failCount -gt 0) { - Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue - exit 1 + Write-Warning "Skipping $failCount package(s) that failed to generate; their existing data is kept." +} + +# A failed package is non-fatal: keep its previously generated files so the rest +# of the catalog can still be refreshed. The caller reports the failures. +function Test-IsFailedPackageFile { + param([string]$FileName) + foreach ($failed in $failedPackageNames) { + if ($FileName -match ("^{0}\.\d.*\.json$" -f [regex]::Escape($failed))) { return $true } + } + return $false } New-Item -ItemType Directory -Path $FinalOutputDir -Force | Out-Null @@ -1243,7 +1252,7 @@ foreach ($file in $stagedFiles) { if ($isFullReconciliation) { foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File) { - if (-not $stagedNames.Contains($existingFile.Name)) { + if (-not $stagedNames.Contains($existingFile.Name) -and -not (Test-IsFailedPackageFile $existingFile.Name)) { Remove-Item -Path $existingFile.FullName -Force Write-Host "Removed stale package data: $($existingFile.Name)" -ForegroundColor DarkYellow } @@ -1253,7 +1262,7 @@ else { foreach ($packageId in $Packages) { $namePattern = "^{0}\.\d.*\.json$" -f [regex]::Escape($packageId) foreach ($existingFile in Get-ChildItem -Path $FinalOutputDir -Filter "*.json" -File) { - if ($existingFile.Name -match $namePattern -and -not $stagedNames.Contains($existingFile.Name)) { + if ($existingFile.Name -match $namePattern -and -not $stagedNames.Contains($existingFile.Name) -and -not (Test-IsFailedPackageFile $existingFile.Name)) { Remove-Item -Path $existingFile.FullName -Force Write-Host "Removed stale package data: $($existingFile.Name)" -ForegroundColor DarkYellow } From 3e6d4e5910fbc7f01d3b8bb427bb3c23c2ea334c Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:06:19 -0600 Subject: [PATCH 2/5] Record why each package failed API generation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../scripts/update-integration-data.ps1 | 16 ++++++-- .../generate-package-json.ps1 | 41 +++++++++++++++---- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index f9ee981d4..b4500bbff 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -374,6 +374,7 @@ $pkgSummary = '' $pkgSkippedPackages = '' $pkgFailedPackages = '' $tsFailedPackages = '' +$apiFailureDetails = [System.Collections.Generic.List[string]]::new() $tsApiSummary = '' $tsSkippedPackages = '' $tsToleratedPackages = '' @@ -421,6 +422,11 @@ if ($versionsChanged -and -not $SkipRegen) { if ([int]$Matches.Failed -gt 0) { $pkgFailedLine = ($pkgLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) $pkgFailedPackages = if ($pkgFailedLine) { ($pkgFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } + $pkgReasonLines = @($pkgLog -split "`r?`n" | Where-Object { $_ -match '^\s*FAILURE \S+: ' }) + foreach ($reasonLine in $pkgReasonLines) { + $apiFailureDetails.Add("C# " + ($reasonLine -replace '^\s*FAILURE ', '').Trim()) + } + if ($pkgReasonLines.Count -eq 0) { $apiFailureDetails.Add("C# ${pkgFailedPackages}: reason not captured; see the run log") } Write-Warning "C# API generation failed for: $pkgFailedPackages. Continuing; existing data for these packages is kept." } $pkgSkippedLine = ($pkgLog -split "`n" | @@ -482,6 +488,11 @@ if ($versionsChanged -and -not $SkipRegen) { if ([int]$Matches.Failed -gt 0) { $tsFailedLine = ($tsLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) $tsFailedPackages = if ($tsFailedLine) { ($tsFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } + $tsReasonLines = @($tsLog -split "`r?`n" | Where-Object { $_ -match '^\s*(FAILED|FAILURE)\b' }) + foreach ($reasonLine in $tsReasonLines) { + $apiFailureDetails.Add("TypeScript " + ($reasonLine -replace '^\s*(FAILED|FAILURE)\s*', '').Trim()) + } + if ($tsReasonLines.Count -eq 0) { $apiFailureDetails.Add("TypeScript ${tsFailedPackages}: reason not captured; see the run log") } Write-Warning "TypeScript API generation failed for: $tsFailedPackages. Continuing." } $tsSkippedLine = ($tsLog -split "`n" | @@ -674,8 +685,7 @@ if ($regenRan) { [void]$sb.AppendLine("") [void]$sb.AppendLine("These packages failed to generate and kept their previous API data. They are tracked in the *Integration Data Updater: API generation failures* issue.") [void]$sb.AppendLine("") - if ($pkgFailedPackages) { [void]$sb.AppendLine("- C#: ``$pkgFailedPackages``") } - if ($tsFailedPackages) { [void]$sb.AppendLine("- TypeScript: ``$tsFailedPackages``") } + foreach ($detail in $apiFailureDetails) { [void]$sb.AppendLine("- ``$detail``") } } if ($tsToleratedPackages) { [void]$sb.AppendLine("") @@ -738,7 +748,7 @@ Set-Output 'semantic_validation' $(if ($regenRan) { $semanticSummary } else { 'n Set-Output 'pr_title' $prTitle Set-Output 'pr_body_file' $prBodyFile Set-Output 'icon_warnings' $iconWarnings -Set-Output 'api_failures' (@(@($pkgFailedPackages, $tsFailedPackages) | Where-Object { $_ }) -join "`n") +Set-Output 'api_failures' ($apiFailureDetails -join "`n") Write-Host "PR title : $prTitle" Write-Host "PR body : $prBodyFile" diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index e0ea8f6bf..7646e2af9 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -837,6 +837,22 @@ $skipCount = 0 $runtimeRefsByTfm = @{} $failedPackageNames = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase) +$failureReasons = @{} + +# Records why a package failed so the updater can surface an actionable reason +# (for example an NU1102 missing dependency) instead of only the package name. +function Add-PackageFailure { + param([string]$PackageId, [string]$Reason) + [void]$failedPackageNames.Add($PackageId) + $lines = @("$Reason" -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $line = $lines | Where-Object { $_ -match '\berror\s+[A-Z]+\d+:' } | Select-Object -First 1 + if (-not $line) { $line = $lines | Select-Object -First 1 } + if (-not $line) { $line = 'unknown failure' } + $line = $line -replace '^.*?\berror\s+', 'error ' + $line = $line -replace '\s*\[[^\]]*\.csproj\]\s*$', '' + if ($line.Length -gt 300) { $line = $line.Substring(0, 300) + '...' } + if (-not $failureReasons.ContainsKey($PackageId)) { $failureReasons[$PackageId] = $line } +} $skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase) @@ -910,7 +926,7 @@ if ($packagesToResolve.Count -gt 3 -and -not $Sequential) { if (-not $resolved.Version) { Write-Warning "Could not resolve version for $($resolved.PackageId) from $($resolved.Source)." $failCount++ - [void]$failedPackageNames.Add($resolved.PackageId) + Add-PackageFailure $resolved.PackageId "could not resolve a version from $($resolved.Source)" continue } $packageInfos += $resolved @@ -923,7 +939,7 @@ if ($packagesToResolve.Count -gt 3 -and -not $Sequential) { if (-not $version) { Write-Warning "Could not resolve version for $packageId from $($sourceInfo.DisplaySource)." $failCount++ - [void]$failedPackageNames.Add($packageId) + Add-PackageFailure $packageId "could not resolve a version from $($sourceInfo.DisplaySource)" continue } $packageInfos += [PSCustomObject]@{ PackageId = $packageId; Version = $version } @@ -1021,7 +1037,7 @@ foreach ($info in $packageInfos) { $restoreError = if ($null -eq $restoreOutcome) { "no restore result was produced." } else { $restoreOutcome.Error } Write-Warning "Failed to restore $packageId $version`: $restoreError" $failCount++ - [void]$failedPackageNames.Add($packageId) + Add-PackageFailure $packageId "restore $version`: $restoreError" continue } $restoreGraph = $restoreOutcome.Graph @@ -1088,6 +1104,8 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { $failedPackages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $skippedPackages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $batchFailureReasons = @{} + $batchOutput | ForEach-Object { if ($_ -match "^Generated:") { Write-Host " $_" -ForegroundColor Green @@ -1101,8 +1119,9 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { Write-Host " $_" -ForegroundColor Yellow } elseif ($_ -match "^FAILED") { - if ($_ -match "^FAILED \[(.+)\]:") { + if ($_ -match "^FAILED \[(.+?)\]:\s*(.*)$") { [void]$failedPackages.Add($Matches[1]) + $batchFailureReasons[$Matches[1]] = $Matches[2] } Write-Host " $_" -ForegroundColor Red } @@ -1126,12 +1145,12 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { if ($batchExitCode -ne 0) { $failCount++ - [void]$failedPackageNames.Add($entry.packageName) + Add-PackageFailure $entry.packageName "batch tool exited with code $batchExitCode" } elseif ($reportedStatusCount -ne 1) { Write-Warning "Batch tool reported $reportedStatusCount terminal statuses for $($entry.packageName); expected exactly one." $failCount++ - [void]$failedPackageNames.Add($entry.packageName) + Add-PackageFailure $entry.packageName "batch tool reported $reportedStatusCount terminal statuses; expected exactly one" } elseif ($succeededPackages.Contains($entry.packageName)) { if (Test-Path -LiteralPath $entry.output -PathType Leaf) { @@ -1141,7 +1160,7 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { else { Write-Warning "Batch tool reported success for $($entry.packageName) without creating $($entry.output)." $failCount++ - [void]$failedPackageNames.Add($entry.packageName) + Add-PackageFailure $entry.packageName "batch tool reported success without creating the output file" } } elseif ($skippedPackages.Contains($entry.packageName)) { @@ -1150,7 +1169,7 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { } else { $failCount++ - [void]$failedPackageNames.Add($entry.packageName) + Add-PackageFailure $entry.packageName "generation failed: $($batchFailureReasons[$entry.packageName])" } } @@ -1209,7 +1228,8 @@ else { else { Write-Warning "Tool exited with code $generationExitCode for $($entry.packageName)" $failCount++ - [void]$failedPackageNames.Add($entry.packageName) + $failedLine = $generationOutput | Where-Object { $_ -match '^FAILED ' } | Select-Object -First 1 + Add-PackageFailure $entry.packageName "generation failed (exit $generationExitCode): $failedLine" } } } @@ -1220,6 +1240,9 @@ Write-Host "`n══════════════════════ Write-Host "Done! Success: $successCount | Failed: $failCount | Skipped: $skipCount" -ForegroundColor Cyan if ($failedPackageNames.Count -gt 0) { Write-Host "Failed packages: $(($failedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Red + foreach ($name in ($failedPackageNames | Sort-Object)) { + Write-Host "FAILURE $name`: $($failureReasons[$name])" -ForegroundColor Red + } } if ($skippedPackageNames.Count -gt 0) { Write-Host "Skipped packages: $(($skippedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Yellow From c6e987b2a419d260e44fb117556392ba07587213 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:24:14 -0600 Subject: [PATCH 3/5] Tolerate failed-generation packages in API semantic validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/scripts/update-integration-data.ps1 | 5 +++++ src/frontend/scripts/validate-generated-api-data.ts | 11 ++++++++++- .../unit/validate-generated-api-data.vitest.test.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index b4500bbff..6d51fcdf1 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -536,6 +536,9 @@ if ($versionsChanged -and -not $SkipRegen) { # 3c. Cross-artifact semantic validation. This runs only after every # generator has completed and must pass before the workflow can publish. Write-Host "→ bun run validate:api-data (semantic regression gate)" -ForegroundColor Cyan + $failedPackageNames = @($pkgFailedPackages -split '[,\s]+' | ForEach-Object { ($_ -replace '@.*$', '').Trim() } | Where-Object { $_ -match '^[A-Za-z0-9_.-]+$' -and $_ -match '\.' }) + $previousFailedEnv = $env:ASPIRE_API_FAILED_PACKAGES + $env:ASPIRE_API_FAILED_PACKAGES = $failedPackageNames -join ',' Push-Location $FrontendDir try { $validationLog = & bun run validate:api-data 2>&1 | Tee-Object -Variable validationTeed | Out-String @@ -543,6 +546,8 @@ if ($versionsChanged -and -not $SkipRegen) { } finally { Pop-Location + if ($null -eq $previousFailedEnv) { Remove-Item Env:ASPIRE_API_FAILED_PACKAGES -ErrorAction SilentlyContinue } + else { $env:ASPIRE_API_FAILED_PACKAGES = $previousFailedEnv } } if ($validationExit -ne 0) { Stop-ApiRegeneration "Generated API semantic validation failed.`n$validationLog`nAborting; no PR will be opened." diff --git a/src/frontend/scripts/validate-generated-api-data.ts b/src/frontend/scripts/validate-generated-api-data.ts index 29faa7ec2..1206a78b0 100644 --- a/src/frontend/scripts/validate-generated-api-data.ts +++ b/src/frontend/scripts/validate-generated-api-data.ts @@ -92,6 +92,9 @@ export interface ValidationInput { packages: GeneratedFile[]; modules: GeneratedFile[]; declarations: string; + // Packages whose generation failed and whose previous output is carried over; + // they are reported by the updater instead of failing validation. + skipPackages?: string[]; } export interface ValidationResult { @@ -377,8 +380,10 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu errors ); + const skipPackages = new Set(input.skipPackages ?? []); for (const entry of input.catalog) { if (!isPackageOutputExpected(entry.title) || entry.apiReference === false) continue; + if (skipPackages.has(entry.title)) continue; const key = `${entry.title}@${entry.version}`; if (!packageByIdentity.has(key)) { errors.push(`Missing C# API output for catalog package ${key}.`); @@ -390,7 +395,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu const catalogEntry = catalogByName.get(metadata.name); if (!catalogEntry) { errors.push(`C# API output ${identity(metadata)} is not present in the integration catalog.`); - } else if (catalogEntry.version !== metadata.version) { + } else if (catalogEntry.version !== metadata.version && !skipPackages.has(metadata.name)) { errors.push( `Stale C# API output ${identity(metadata)}; catalog version is ${catalogEntry.version}.` ); @@ -667,6 +672,10 @@ function main(): void { packages: loadJsonFiles(packageDir, canonicalPackageDir), modules: loadJsonFiles(moduleDir), declarations: fs.readFileSync(declarationsFile, 'utf8'), + skipPackages: (process.env.ASPIRE_API_FAILED_PACKAGES ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean), }); if (result.errors.length > 0) { diff --git a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts index d8e7f6d78..be685aa3a 100644 --- a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts +++ b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts @@ -318,6 +318,14 @@ describe('validateGeneratedApiData', () => { ); expect(errors).toContain('Missing C# API output for catalog package Aspire.Hosting.Foo@2.0.0.'); }); + + test('tolerates missing and stale output for packages whose generation failed', () => { + const input = createValidInput(); + input.catalog[0].version = '2.0.0'; + input.skipPackages = ['Aspire.Hosting.Foo']; + + expect(validateGeneratedApiData(input).errors).toEqual([]); + }); }); describe('loadJsonFromHead', () => { From 070045bfc32af27eb00188a901535bbbabf5e62a Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:25:34 -0600 Subject: [PATCH 4/5] Emit API failure outputs before validation so the tracking issue is filed on aborts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/update-integration-data.yml | 2 +- src/frontend/scripts/update-integration-data.ps1 | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index fad27f629..e0fc7a116 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -206,7 +206,7 @@ jobs: fi - name: Track API generation failures - if: ${{ always() && steps.update.outcome == 'success' && steps.update.outputs.regen_ran == 'true' }} + if: ${{ always() && steps.update.outputs.regen_ran == 'true' }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index 6d51fcdf1..6b230709d 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -533,6 +533,10 @@ if ($versionsChanged -and -not $SkipRegen) { $twoslashSummary = 'succeeded' + # Publish failure details now so the tracking issue is filed even if a later step aborts. + Set-Output 'regen_ran' 'true' + Set-Output 'api_failures' ($apiFailureDetails -join "`n") + # 3c. Cross-artifact semantic validation. This runs only after every # generator has completed and must pass before the workflow can publish. Write-Host "→ bun run validate:api-data (semantic regression gate)" -ForegroundColor Cyan From e3236b2f4da47ce2767089492ca1a11b5113a01f Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:39:56 -0600 Subject: [PATCH 5/5] Distinguish completed partial batches from generator crashes Honor per-package results when a completed batch returns its expected failure exit code. Keep missing completion, incomplete statuses, and crashes fatal; allow restore-only failures to reach diagnostics and preservation. Add direct batch reconciliation regressions and an all-package-failure orchestration case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../integration-data-updater.vitest.test.ts | 11 +++- .../generate-package-json.ps1 | 24 +++---- .../generate-package-json.Tests.ps1 | 63 +++++++++++++++++++ 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/frontend/tests/unit/integration-data-updater.vitest.test.ts b/src/frontend/tests/unit/integration-data-updater.vitest.test.ts index 95df5397e..06c554afd 100644 --- a/src/frontend/tests/unit/integration-data-updater.vitest.test.ts +++ b/src/frontend/tests/unit/integration-data-updater.vitest.test.ts @@ -163,7 +163,8 @@ param([string]$OutputDir) $catalog = Get-Content "$PSScriptRoot/../../frontend/src/data/aspire-integrations.json" -Raw | ConvertFrom-Json $failed = @() foreach ($entry in $catalog) { - if ($env:FIXTURE_MODE -notin @('retry', 'ts-only-failure', 'clean-bundle-failure') -and $entry.title -ne 'Aspire.Hosting.Good') { + if ($env:FIXTURE_MODE -eq 'all-fail' -or + ($env:FIXTURE_MODE -notin @('retry', 'ts-only-failure', 'clean-bundle-failure') -and $entry.title -ne 'Aspire.Hosting.Good')) { $failed += $entry.title continue } @@ -328,4 +329,12 @@ describe('integration data orchestration', () => { expect(run.status, run.stdout + run.stderr).toBe(1); expect(run.outputs).not.toContain('api_failures_checked<<'); }); + + test('still publishes metadata and reports reasons when every package restore fails', () => { + const run = fixture('all-fail'); + expect(run.status, run.stdout + run.stderr).toBe(0); + expect(run.failures).toHaveLength(3); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Good.1.0.0.json'))).toBe(true); + expect(run.outputs).toContain('api_failures_checked<<'); + }); }); diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index 7646e2af9..5744ea060 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -1075,11 +1075,6 @@ Write-Host "Prepared $($manifestEntries.Count) packages for generation." if ($manifestEntries.Count -eq 0) { Write-Host "`nNo packages to process." -ForegroundColor Yellow - if ($failCount -gt 0) { - Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item (Join-Path $ScriptDir ".package-json-generator-work") -Recurse -Force -ErrorAction SilentlyContinue - exit 1 - } } # Phase 3: Generate JSON files @@ -1133,8 +1128,13 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { } } - if ($batchExitCode -ne 0) { - Write-Warning "Batch tool exited with code $batchExitCode; no reported result will be published." + $batchSummary = $batchOutput | Where-Object { + $_ -match '^Batch complete in .+: \d+ succeeded, \d+ failed, \d+ skipped$' + } | Select-Object -Last 1 + if (-not $batchSummary -or + $batchExitCode -ne [int]($failedPackages.Count -gt 0) -or + ($succeededPackages.Count + $failedPackages.Count + $skippedPackages.Count) -ne $manifestEntries.Count) { + throw "Batch generation did not complete reliably (exit $batchExitCode). No API data will be published." } foreach ($entry in $manifestEntries) { @@ -1143,14 +1143,8 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { [int]($skippedPackages.Contains($entry.packageName)) + [int]($failedPackages.Contains($entry.packageName)) - if ($batchExitCode -ne 0) { - $failCount++ - Add-PackageFailure $entry.packageName "batch tool exited with code $batchExitCode" - } - elseif ($reportedStatusCount -ne 1) { - Write-Warning "Batch tool reported $reportedStatusCount terminal statuses for $($entry.packageName); expected exactly one." - $failCount++ - Add-PackageFailure $entry.packageName "batch tool reported $reportedStatusCount terminal statuses; expected exactly one" + if ($reportedStatusCount -ne 1) { + throw "Batch tool reported $reportedStatusCount terminal statuses for $($entry.packageName); expected exactly one." } elseif ($succeededPackages.Contains($entry.packageName)) { if (Test-Path -LiteralPath $entry.output -PathType Leaf) { diff --git a/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 b/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 index 98705e3d1..b47ccdef3 100644 --- a/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 +++ b/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 @@ -72,3 +72,66 @@ if ($catalogResult.title -contains "Particular.Aspire.Hosting.ServicePlatform") } Write-Host "apiReference filter passed fixture and catalog checks ($($catalogResult.Count) included, $($excluded.Count) excluded)." + +# Execute the real batch reconciliation without restoring packages or building. +$batchGuardAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if (-not $batchSummary -or') +}, $true) +$batchReconciliationAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Extent.Text.Contains('$reportedStatusCount =') +}, $true) +$failureFunctionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Add-PackageFailure' +}, $true) +if ($null -eq $batchGuardAst -or $null -eq $batchReconciliationAst -or $null -eq $failureFunctionAst) { + throw 'Could not locate batch reconciliation and failure reporting.' +} +. ([scriptblock]::Create($failureFunctionAst.Extent.Text)) +$guard = [scriptblock]::Create($batchGuardAst.Extent.Text) +$reconcile = [scriptblock]::Create($batchReconciliationAst.Extent.Text) +$successCount = 0 +$skipCount = 0 +$failCount = 0 +$failureReasons = @{} +$failedPackageNames = [System.Collections.Generic.HashSet[string]]::new() +$skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new() +$succeededPackages = [System.Collections.Generic.HashSet[string]]::new() +$failedPackages = [System.Collections.Generic.HashSet[string]]::new() +$skippedPackages = [System.Collections.Generic.HashSet[string]]::new() +[void]$succeededPackages.Add('Example.Good') +[void]$failedPackages.Add('Example.Broken') +$batchFailureReasons = @{ 'Example.Broken' = 'cannot read assembly' } +$batchExitCode = 1 +$batchSummary = 'Batch complete in 1.0s: 1 succeeded, 1 failed, 0 skipped' +$removedFor = [System.Collections.Generic.List[string]]::new() +function Remove-StalePackageJsonFiles { + param($PackageName, $CurrentOutputFile, $OutputDirectory) + $removedFor.Add($PackageName) +} +$OutputDir = [System.IO.Path]::GetTempPath() +$manifestEntries = @( + @{ packageName = 'Example.Good'; output = $scriptPath }, + @{ packageName = 'Example.Broken'; output = 'unused' } +) +. $guard +. $reconcile +if ($successCount -ne 1 -or $failCount -ne 1 -or $removedFor.Count -ne 1 -or + $removedFor[0] -ne 'Example.Good' -or + $failureReasons['Example.Broken'] -notmatch 'cannot read assembly') { + throw 'A completed partial batch must publish successes and report only actual failures.' +} +foreach ($scenario in @('crash', 'incomplete')) { + $batchExitCode = if ($scenario -eq 'crash') { 2 } else { 1 } + $batchSummary = if ($scenario -eq 'incomplete') { $null } else { 'complete' } + $rejected = $false + try { . $guard } + catch { $rejected = $true } + if (-not $rejected) { throw "$scenario batch was not rejected." } +} +Write-Host 'Batch reconciliation passed partial-success, crash, and incomplete-output checks.'