Skip to content
Merged
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
7 changes: 7 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ pipeline-tests *args:
echo "Running tests pipeline..."
"{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }}

# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing
[group('Pipeline')]
pipeline-pack-validate *args:
just ensure-pipeline-tool
echo "Running pack + validate pipeline..."
"{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }}

# Build the project with the specified configuration, defaulting to "Debug"
[group('Build and Test')]
build *args:
Expand Down
9 changes: 0 additions & 9 deletions LICENSE.md

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Restore → Build → Test ├→ Pack → Validate → Publish → GitHub relea
Version ───────────────┘
```

`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow).
`Version` reads the SemVer `version` field from `package.json`. Lint restores local tools and runs CSharpier. Tests are discovered under `Build:TestRoot`/`Build:TestPatterns` and run with a TUnit tree-node filter (or an xUnit filter). Pack validation inspects each `.nupkg`/`.snupkg` against required/forbidden content rules (glob patterns) and can enforce source link, deterministic builds, and compiler flags on the packaged assemblies. Analyzer-only packages can embed portable PDBs under `analyzers/dotnet/` without requiring a `.snupkg`. Publication and GitHub release steps are controlled by `Release:Mode` (`None`, `LocalNuGet`, `NuGet`, `GitHubRelease`) and independently by the `Build__Run*` switches. `LocalNuGet` is only honoured when the tool runs locally; it is ignored in CI (for example via a reusable workflow).

## Repository CI/CD

Expand Down
9 changes: 5 additions & 4 deletions docs/wiki/Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,16 @@ Command line > environment variables > `purview-build.json` > baked-in defaults

| Key | Default | Purpose |
| --- | --- | --- |
| `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa |
| `RequireSymbolPackage` | `true` | Every `.nupkg` must have a matching `.snupkg` and vice versa, except analyzer-only packages that embed their PDBs under `analyzers/dotnet/` |
| `RequireSymbolFiles` | `true` | Every `.snupkg` must contain at least one `.pdb` |
| `RequireSourceLink` | `false` | Every `.dll`/`.exe` must have a matching portable PDB containing a Source Link record |
| `RequireDeterministic` | `false` | Every `.dll`/`.exe` must be built deterministically (the PE carries the Reproducible debug directory entry) |
| `RequiredCompilerFlags` | `[]` | Compiler-flag `key=value` entries that must appear in each assembly's PDB compiler-flags record (e.g. `optimization=release`) |
| `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package) |
| `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package) |
| `RequiredContent` | `{}` | Package-id glob → entry-path globs that must be present in the `.nupkg` (`"*"` matches every package). Entries may use the `$(TFM)` target-framework partial token, e.g. `lib/$(TFM)/Foo.dll` |
| `ForbiddenContent` | `{}` | Package-id glob → entry-path globs that must not be present in the `.nupkg` (`"*"` matches every package). Also supports the `$(TFM)` partial token |
| `RequireExplicitContent` | `false` | Makes `RequiredContent` exhaustive: every generated package must match a rule, and every non-metadata entry must match a declared glob; undeclared packages/entries are errors |

Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` (custom debug info records); they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`).
Content entry paths and package-id keys are matched as globs (case-insensitive), e.g. `tools/**/Foo.dll` or `**/*.pdb`. Entries may also contain the `$(TFM)` partial token (e.g. `lib/$(TFM)/Foo.dll`), which expands to one entry per target framework the package actually ships (short folder name, discovered from the package's own content groups); each expanded entry is checked independently. Required content is satisfied when any package entry matches; forbidden content fails when any entry matches. When `RequireExplicitContent` is `true`, `RequiredContent` becomes exhaustive: every generated package must match a rule, and every non-metadata entry in a matched package must match a declared (and `$(TFM)`-expanded) glob — undeclared packages or entries are errors. PDBs are normally delivered through `.snupkg`, but analyzer-only packages may embed them under `analyzers/dotnet/` in the `.nupkg`; those packages do not require a sibling `.snupkg`. The assembly checks (`RequireSourceLink`, `RequireDeterministic`, `RequiredCompilerFlags`) inspect each `.dll`/`.exe` in the `.nupkg` (PE header) and its sibling portable PDB in the `.snupkg` or analyzer-slot PDB in the `.nupkg`; they only apply to assemblies the package ships symbols for. Determinism is detected via the PE's Reproducible debug directory entry, source link via the PDB's Source Link record, and compiler flags via the PDB's key/value compiler-flags record (matched case-insensitively, e.g. `optimization=release`).

> **Tool defaults vs code defaults.** The shipped `appsettings.json` sets `RequireSourceLink: false`, `RequireDeterministic: false`, and `RequiredCompilerFlags: []`. The C# property initializers in `PackValidationSettings` default those to `true`/`true`/`["optimization=release"]`, but because `appsettings.json` always loads and wins over code defaults, the effective shipped defaults are the `false`/`false`/`[]` values shown above.

Expand Down
13 changes: 13 additions & 0 deletions docs/wiki/Pack-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ Both maps are keyed by package-id glob (case-insensitive; `"*"` matches every pa
- **Required**: the rule is satisfied when any package entry matches the glob; a missing match is an error.
- **Forbidden**: any matching entry is an error.

### Target-framework partials (`$(TFM)`)

An entry may contain the literal token `$(TFM)`, e.g. `lib/$(TFM)/Purview.Telemetry.dll`. The token is expanded into one entry per target framework the package actually ships (short folder name, e.g. `net8.0`, `net48`, `netstandard2.0`, discovered via the package's own `lib`/`ref`/`build`/`tools`/`frameworkAssemblies` groups), so the rule must be satisfied independently for every one of the package's target frameworks. If the package has no detectable target frameworks, a `$(TFM)` entry is reported as an error rather than silently skipped.

### Explicit/exhaustive content (`RequireExplicitContent`)

When `RequireExplicitContent` is `true`, `RequiredContent` becomes the precise, exhaustive definition of every package's contents instead of a "must contain at least" list:

- Every produced `.nupkg`'s package id must match a `RequiredContent` key; a generated package with no matching rule is an error.
- Every entry in a matched package (after `$(TFM)` expansion) — excluding standard NuGet/OPC metadata (`.nuspec`, `[Content_Types].xml`, `_rels/`, `package/services/metadata/`, `.signature.p7s`) — must match one of that package's `RequiredContent` globs; any undeclared entry is reported as an error.

`ForbiddenContent` is unaffected by `RequireExplicitContent` and continues to apply as a simple deny-list.

## Assembly inspection

When any of `RequireSourceLink`, `RequireDeterministic`, or `RequiredCompilerFlags` is enabled, each `.dll`/`.exe` in the `.nupkg` that the package ships symbols for (a sibling PDB exists in the `.snupkg` or `.nupkg`) is inspected:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-build",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"homepage": "https://purview.dev/projects/build/",
"bugs": {
Expand Down
3 changes: 2 additions & 1 deletion purview-build.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
"RequireSymbolFiles": true,
"RequireSourceLink": true,
"RequireDeterministic": true,
"RequireExplicitContent": false,
"RequiredCompilerFlags": [
"optimization=release"
],
"RequiredContent": {
"purview.build": [
"tools/**/Purview.Build.dll",
"tools/**/appsettings.json",
"README.md",
"LICENSE.md",
"purview-logo-light.png"
]
},
Expand Down
1 change: 1 addition & 0 deletions src/Build.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<File Path="../package.json" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
<File Path="../purview-build.json" />
<File Path="../README.md" />
<File Path="../.editorconfig" />
<File Path="../global.json" />
Expand Down
3 changes: 1 addition & 2 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@
<RepositoryType>git</RepositoryType>
<PackageIcon>purview-logo-light.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
</PropertyGroup>

<ItemGroup Label="NuGet package assets" Condition="'$(IsPackable)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)/../README.md" Link="Sdk/README.md" />
<None Include="$(MSBuildThisFileDirectory)/../LICENSE.md" Link="Sdk/LICENSE.md" />
<None
Include="$(MSBuildThisFileDirectory)/../assets/images/purview-logo-light.png"
Link="Sdk/purview-logo-light.png"
Expand Down
4 changes: 2 additions & 2 deletions src/src/Build/Helpers/BunCLIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ public static BunCLIOptions FromCommand(string command)

var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var commandParts = parts[0].Equals("bun", StringComparison.OrdinalIgnoreCase)
? parts.Skip(1).ToArray()
? [.. parts.Skip(1)]
: parts;

return new() { Tool = "bun", CommandParts = commandParts };
}
}
}
113 changes: 107 additions & 6 deletions src/src/Build/Helpers/PackageInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ static class PackageInspector
static readonly Guid SourceLinkDebugInfoGuid = new("CC110556-A091-4D38-9FEC-25AB9A351A6A");
static readonly Guid CompilerFlagsDebugInfoGuid = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8");

// Target-framework partial token, e.g. "lib/$(TFM)/Foo.dll" expands to one entry per
// target framework the package supports (short folder name, e.g. "net8.0", "net48").
const string TfmToken = "$(TFM)";

public static bool MatchesAny(string pattern, IEnumerable<string> paths)
{
Matcher matcher = new(StringComparison.OrdinalIgnoreCase);
Expand All @@ -24,21 +28,108 @@ public static bool RequiresAssemblyInspection(PackValidationSettings settings) =
|| settings.RequireDeterministic
|| settings.RequiredCompilerFlags.Length > 0;

public static bool IsPdbAllowedInNupkg(string path) =>
path.StartsWith("tools/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("analyzers/dotnet/", StringComparison.OrdinalIgnoreCase);

public static bool HasEmbeddedAnalyzerSymbols(IEnumerable<string> paths) =>
paths.Any(path =>
IsPdbFile(path) && path.StartsWith("analyzers/dotnet/", StringComparison.OrdinalIgnoreCase)
);

/// <summary>
/// True for standard NuGet/OPC package metadata entries that are never user-declared content:
/// the .nuspec, OPC parts ("[Content_Types].xml", "_rels/", "package/services/metadata/"),
/// and the package signature.
/// </summary>
public static bool IsPackageMetadata(string path) =>
string.Equals(path, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("package/services/metadata/", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)
|| string.Equals(path, ".signature.p7s", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Expands the <c>$(TFM)</c> partial token in <paramref name="entry"/> into one concrete entry
/// per target framework folder name. Entries without the token are returned unchanged.
/// </summary>
public static IReadOnlyList<string> ExpandTfmToken(
string entry,
IReadOnlyCollection<string> targetFrameworkFolderNames
)
{
if (!entry.Contains(TfmToken, StringComparison.OrdinalIgnoreCase))
return [entry];

if (targetFrameworkFolderNames.Count == 0)
return [];

// Expand the $(TFM) token into one entry per target framework folder name.
return [.. targetFrameworkFolderNames.Select(tfm =>
entry.Replace(TfmToken, tfm, StringComparison.OrdinalIgnoreCase)
)];
}

public static void ValidateContentRules(
IReadOnlyList<string> files,
string packageId,
Dictionary<string, string[]> requiredRules,
Dictionary<string, string[]> forbiddenRules,
List<string> errors
List<string> errors,
IReadOnlyCollection<string>? targetFrameworkFolderNames = null,
bool requireExplicitContent = false
)
{
targetFrameworkFolderNames ??= [];

var required = GetContentRule(requiredRules, packageId);
if (required is not null)
if (required is null)
{
if (requireExplicitContent)
errors.Add(
$"Package '{packageId}' has no RequiredContent rule; RequireExplicitContent requires every generated package to declare its exact content."
);
}
else
{
List<string> allowedEntries = [];
foreach (var entry in required)
{
if (!MatchesAny(entry, files))
errors.Add($"Required content '{entry}' is missing from the package.");
var expanded = ExpandTfmToken(entry, targetFrameworkFolderNames);
if (expanded.Count == 0)
{
errors.Add(
$"Required content '{entry}' uses the '$(TFM)' partial but no target frameworks were detected in the package."
);
continue;
}

foreach (var candidate in expanded)
{
allowedEntries.Add(candidate);
if (!MatchesAny(candidate, files))
{
errors.Add(
candidate == entry
? $"Required content '{entry}' is missing from the package."
: $"Required content '{candidate}' (from '{entry}') is missing from the package."
);
}
}
}

if (requireExplicitContent)
{
foreach (var file in files)
{
if (IsPackageMetadata(file))
continue;

if (!allowedEntries.Any(entry => MatchesAny(entry, [file])))
errors.Add(
$"Package '{packageId}' contains undeclared content '{file}' that is not defined in RequiredContent."
);
}
}
}

Expand All @@ -47,8 +138,15 @@ List<string> errors
{
foreach (var entry in forbidden)
{
if (MatchesAny(entry, files))
errors.Add($"Forbidden content '{entry}' must not be in the package.");
foreach (var candidate in ExpandTfmToken(entry, targetFrameworkFolderNames))
{
if (MatchesAny(candidate, files))
errors.Add(
candidate == entry
? $"Forbidden content '{entry}' must not be in the package."
: $"Forbidden content '{candidate}' (from '{entry}') must not be in the package."
);
}
}
}
}
Expand Down Expand Up @@ -283,4 +381,7 @@ static bool IsAssembly(string path)
return string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase);
}

static bool IsPdbFile(string path) =>
string.Equals(Path.GetExtension(path), ".pdb", StringComparison.OrdinalIgnoreCase);
}
2 changes: 2 additions & 0 deletions src/src/Build/Modules/LintModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ string description
settings.Value.WebFormatCheckCommand,
"format check"
);

if (formatResult is not null)
return formatResult;

// If the format check passed, run the linter
return await RunIfDeclaredAsync(settings.Value.WebLintCommand, "lint");
}

Expand Down
1 change: 1 addition & 0 deletions src/src/Build/Modules/RestoreModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ CancellationToken cancellationToken
);
}

// For non-web projects, perform a .NET restore
return await context
.DotNet()
.Restore(
Expand Down
Loading
Loading