diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index f32a4782a..1da09c5a1 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -80,6 +80,8 @@ jobs: - name: Update integration data id: update shell: pwsh + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: ./src/frontend/scripts/update-integration-data.ps1 - name: Configure git identity diff --git a/src/tools/PackageJsonGenerator/Helpers/PdbSourceReader.cs b/src/tools/PackageJsonGenerator/Helpers/PdbSourceReader.cs index eea76ba97..5eb0f1fa6 100644 --- a/src/tools/PackageJsonGenerator/Helpers/PdbSourceReader.cs +++ b/src/tools/PackageJsonGenerator/Helpers/PdbSourceReader.cs @@ -106,8 +106,12 @@ private void BuildMethodIndex() if (minLine < int.MaxValue) { - var rowId = MetadataTokens.GetRowNumber(handle); - _methodSources[rowId] = new MethodSourceInfo(CleanPath(docName), minLine, maxLine); + var sourcePath = NormalizeSourcePath(docName); + if (sourcePath is not null) + { + var rowId = MetadataTokens.GetRowNumber(handle); + _methodSources[rowId] = new MethodSourceInfo(sourcePath, minLine, maxLine); + } } } } @@ -509,15 +513,44 @@ private bool ParameterNamesMatch(MethodDefinition methodDef, IReadOnlyList - /// Strips the deterministic build path prefix (e.g. "/_/") from source paths. + /// Converts PDB document names to repository-relative paths. /// - private static string CleanPath(string path) + internal static string? NormalizeSourcePath(string path) { - if (path.StartsWith("/_/")) - return path[3..]; - return path; + if (string.IsNullOrWhiteSpace(path)) + return null; + + var normalized = path.Replace('\\', '/'); + if (normalized.StartsWith("/_/", StringComparison.Ordinal)) + { + normalized = normalized[3..]; + } + else if (IsAbsoluteSourcePath(normalized)) + { + var sourceRoot = normalized.IndexOf("/src/", StringComparison.OrdinalIgnoreCase); + if (sourceRoot < 0) + { + throw new InvalidDataException( + $"PDB source path '{path}' is absolute and cannot be mapped to the repository."); + } + + normalized = normalized[(sourceRoot + 1)..]; + } + + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0 || segments.Any(segment => segment is "." or "..")) + { + throw new InvalidDataException( + $"PDB source path '{path}' is not a safe repository-relative path."); + } + + return string.Join('/', segments); } + private static bool IsAbsoluteSourcePath(string path) => + path.StartsWith("/", StringComparison.Ordinal) || + (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':'); + public void Dispose() { _pdbProvider?.Dispose(); diff --git a/src/tools/PackageJsonGenerator/PackageJsonGenerator.cs b/src/tools/PackageJsonGenerator/PackageJsonGenerator.cs index a5f6ebe00..0cc49d911 100644 --- a/src/tools/PackageJsonGenerator/PackageJsonGenerator.cs +++ b/src/tools/PackageJsonGenerator/PackageJsonGenerator.cs @@ -549,6 +549,18 @@ private static void AdjustSourceLinesForTypeDeclarations( continue; } + if (lines.Length == 0) + { + // A 404 confirms that an inferred filename does not exist at this + // revision. Fall back to the repository instead of emitting a broken link. + foreach (var typeModel in group) + { + typeModel.SourceFile = null; + typeModel.SourceLines = null; + } + continue; + } + foreach (var typeModel in group) { var simpleName = PdbSourceReader.NormalizeGenericName(typeModel.Name); @@ -596,6 +608,12 @@ private static void AdjustSourceLinesForTypeDeclarations( var text = http.GetStringAsync(rawUrl).GetAwaiter().GetResult(); lines = text.Split('\n'); } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // An empty array is cached as a confirmed missing file, distinct from + // a transient request failure represented by null. + lines = []; + } catch { // Network failure — leave lines null so callers keep PDB-based values. diff --git a/src/tools/PackageJsonGenerator/README.md b/src/tools/PackageJsonGenerator/README.md index 7f3cddd8e..782bc9b45 100644 --- a/src/tools/PackageJsonGenerator/README.md +++ b/src/tools/PackageJsonGenerator/README.md @@ -110,5 +110,7 @@ dotnet run --project ../PackageJsonGenerator/PackageJsonGenerator.csproj -- \ - The emitted `targetFramework` matches the compile/runtime asset selected by NuGet for the analyzed package - The companion `generate-package-json.ps1` script restores every package independently, uses catalog-pinned versions, and reads `project.assets.json` for exact direct and transitive package references - Metadata loading includes the matching .NET and ASP.NET Core reference packs; official `Aspire.*` packages resolve from a branch-specific Azure Artifacts feed on `release/*` branches and use nuget.org elsewhere +- External packages that omit an immutable source revision can use an explicit release resolver; `Aspire.Hosting.AWS` maps its exact package version to the matching GitHub release tag and commit +- PDB document names are emitted as forward-slash, repository-relative source paths; unmappable absolute paths fail generation - Generation fails when input references or Aspire export attribute metadata cannot be resolved, rather than emitting incomplete attribute payloads - When the matching `microsoft/aspire` release branch is not publicly reachable yet, set `ASPIRE_RELEASE_FEED_URL`, `ASPIRE_RELEASE_FEED_NAME`, or `ASPIRE_RELEASE_COMMIT` before running the script diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index 07e45265b..57e0f476c 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -62,6 +62,12 @@ $AspireRepoCandidates = @( $env:ASPIRE_GITHUB_REPO_URL, "https://github.com/microsoft/aspire" ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +$ExternalPackageSources = @{ + "Aspire.Hosting.AWS" = [PSCustomObject]@{ + Repository = "https://github.com/aws/integrations-on-dotnet-aspire-for-aws" + ReleasesApi = "https://api.github.com/repos/aws/integrations-on-dotnet-aspire-for-aws/releases?per_page=100" + } +} $script:NuGetSourceMetadataCache = @{} # ── Resolve paths ────────────────────────────────────────────────────────────── @@ -333,6 +339,100 @@ function Resolve-OfficialAspireFeed { } } +function Resolve-GitTagCommit { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Repository, + + [Parameter(Mandatory)] + [string]$TagName + ) + + $escapedTag = [regex]::Escape($TagName) + $output = @(& git ls-remote --tags $Repository "refs/tags/$TagName" "refs/tags/$TagName^{}" 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve tag '$TagName' from '$Repository': $(($output | ForEach-Object { [string]$_ }) -join "`n")" + } + + $directCommit = $null + foreach ($line in $output) { + $text = [string]$line + if ($text -match "^([0-9a-f]{40})\s+refs/tags/$escapedTag\^\{\}$") { + return $Matches[1] + } + if ($text -match "^([0-9a-f]{40})\s+refs/tags/$escapedTag$") { + $directCommit = $Matches[1] + } + } + + if ($directCommit) { + return $directCommit + } + + throw "Tag '$TagName' was not found in '$Repository'." +} + +function Resolve-ExternalPackageSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$PackageId, + + [Parameter(Mandatory)] + [string]$Version + ) + + if (-not $ExternalPackageSources.ContainsKey($PackageId)) { + return $null + } + + $source = $ExternalPackageSources[$PackageId] + $headers = @{ + Accept = "application/vnd.github+json" + "User-Agent" = "aspire.dev-package-json-generator" + "X-GitHub-Api-Version" = "2022-11-28" + } + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + $headers.Authorization = "Bearer $($env:GITHUB_TOKEN)" + } + + $releases = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $releases = Invoke-RestMethod -Uri $source.ReleasesApi -Headers $headers + break + } + catch { + if ($attempt -eq 3) { + throw + } + Start-Sleep -Seconds ([Math]::Pow(2, $attempt - 1)) + } + } + + $headingPattern = "(?m)^###\s+$([regex]::Escape($PackageId))\s+\($([regex]::Escape($Version))\)\s*$" + $matchingReleases = @($releases | Where-Object { + -not [string]::IsNullOrWhiteSpace($_.body) -and $_.body -match $headingPattern + }) + + if ($matchingReleases.Count -ne 1) { + throw "Expected one GitHub release for $PackageId $Version in '$($source.Repository)', but found $($matchingReleases.Count)." + } + + $tagName = [string]$matchingReleases[0].tag_name + if ([string]::IsNullOrWhiteSpace($tagName)) { + throw "The GitHub release for $PackageId $Version has no tag." + } + + $commit = Resolve-GitTagCommit -Repository $source.Repository -TagName $tagName + return [PSCustomObject]@{ + Repository = $source.Repository + Commit = $commit + Tag = $tagName + } +} + function Get-NuGetSourceMetadata { [CmdletBinding()] param([string]$ServiceIndex) @@ -845,6 +945,20 @@ foreach ($info in $packageInfos) { $packageId = $info.PackageId $version = $info.Version $sourceInfo = $packageSourceMetadata[$packageId] + $externalSource = $null + + try { + $externalSource = Resolve-ExternalPackageSource -PackageId $packageId -Version $version + if ($externalSource) { + Write-Host " Source: $packageId $version -> $($externalSource.Tag) ($($externalSource.Commit.Substring(0, 12)))" + } + } + catch { + Write-Warning "Failed to resolve source provenance for $packageId $version`: $_" + $failCount++ + [void]$failedPackageNames.Add($packageId) + continue + } try { Write-Host " Restoring: $packageId $version from $($sourceInfo.DisplaySource)" -ForegroundColor Yellow @@ -883,8 +997,8 @@ foreach ($info in $packageInfos) { output = $outputFile packageVersion = $version packageName = $packageId - sourceRepo = $null - sourceCommit = $null + sourceRepo = if ($externalSource) { $externalSource.Repository } else { $null } + sourceCommit = if ($externalSource) { $externalSource.Commit } else { $null } targetFramework = $restoreGraph.TargetFramework } } diff --git a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs index 5fa15d85e..edc44207d 100644 --- a/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs +++ b/tests/PackageJsonGenerator.Tests/PackageJsonGeneratorHelperTests.cs @@ -1,3 +1,5 @@ +using PackageJsonGenerator.Helpers; + namespace PackageJsonGenerator.Tests; public sealed class PackageJsonGeneratorHelperTests @@ -57,4 +59,34 @@ public void ParseStartLine_ParsesExpectedValue(string? sourceLines, int expected { Assert.Equal(expected, PackageJsonGenerator.ParseStartLine(sourceLines)); } + + [Theory] + [InlineData("/_/src/Aspire.Hosting/Foo.cs", "src/Aspire.Hosting/Foo.cs")] + [InlineData( + @"C:\build\src\Aspire.Hosting.AWS\Lambda\LambdaExtensions.cs", + "src/Aspire.Hosting.AWS/Lambda/LambdaExtensions.cs")] + [InlineData( + "/home/runner/work/repo/repo/src/Aspire.Hosting.DocumentDB/DocumentDBBuilderExtensions.cs", + "src/Aspire.Hosting.DocumentDB/DocumentDBBuilderExtensions.cs")] + [InlineData(@"src\Aspire.Hosting\Foo.cs", "src/Aspire.Hosting/Foo.cs")] + public void NormalizeSourcePath_ReturnsRepositoryRelativePath(string path, string expected) + { + Assert.Equal(expected, PdbSourceReader.NormalizeSourcePath(path)); + } + + [Theory] + [InlineData(@"C:\build\generated\Foo.cs")] + [InlineData(@"C:generated\Foo.cs")] + [InlineData("/home/runner/work/repo/generated/Foo.cs")] + [InlineData("../src/Foo.cs")] + public void NormalizeSourcePath_RejectsUnsafePath(string path) + { + Assert.Throws(() => PdbSourceReader.NormalizeSourcePath(path)); + } + + [Fact] + public void NormalizeSourcePath_IgnoresEmptyDocumentName() + { + Assert.Null(PdbSourceReader.NormalizeSourcePath("")); + } }