Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/update-integration-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 40 additions & 7 deletions src/tools/PackageJsonGenerator/Helpers/PdbSourceReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down Expand Up @@ -509,15 +513,44 @@ private bool ParameterNamesMatch(MethodDefinition methodDef, IReadOnlyList<strin
}

/// <summary>
/// Strips the deterministic build path prefix (e.g. "/_/") from source paths.
/// Converts PDB document names to repository-relative paths.
/// </summary>
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();
Expand Down
18 changes: 18 additions & 0 deletions src/tools/PackageJsonGenerator/PackageJsonGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/tools/PackageJsonGenerator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 116 additions & 2 deletions src/tools/PackageJsonGenerator/generate-package-json.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
})

Comment on lines +400 to +418
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using PackageJsonGenerator.Helpers;

namespace PackageJsonGenerator.Tests;

public sealed class PackageJsonGeneratorHelperTests
Expand Down Expand Up @@ -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<InvalidDataException>(() => PdbSourceReader.NormalizeSourcePath(path));
}

[Fact]
public void NormalizeSourcePath_IgnoresEmptyDocumentName()
{
Assert.Null(PdbSourceReader.NormalizeSourcePath(""));
}
}
Loading