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
1 change: 1 addition & 0 deletions .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"GenerateTools",
"Install",
"Pack",
"PackageGuard",
"Publish",
"References",
"ReportCoverage",
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,7 @@ jobs:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- name: 'Run: VerifyGeneratedTools, Test, Pack'
run: dotnet fallout VerifyGeneratedTools Test Pack
- name: 'Run: VerifyGeneratedTools, Test, Pack, PackageGuard'
run: dotnet fallout VerifyGeneratedTools Test Pack PackageGuard
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 changes: 35 additions & 7 deletions .github/workflows/publish-packages-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,36 @@ jobs:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- name: 'Run: Test + Pack'
run: dotnet fallout Test Pack
- name: 'Run: Test + Pack + PackageGuard'
run: dotnet fallout Test Pack PackageGuard
env:
# We check out the tag above, so HEAD is detached and matches none of
# version.json's publicReleaseRefSpec entries (they're all branch refs,
# and nbgv never matches that spec against refs/tags/*). Without this,
# NB.GV treats the build as non-public and stamps a git-height suffix
# onto every package — v10.4.0-rc.3 shipped as 10.4.0-rc.3.geabd043cc2.
PublicRelease: true
# PackageGuard's own target (build/Build.PackageGuard.cs) is gated on either being on
# a long-lived branch or running in this workflow (GitHubActions.Workflow ==
# "publish-packages-release") — the tag checkout above leaves HEAD detached, so the
# branch check alone can't pass here; validate-ref already proved the tag is
# reachable from a production branch. GITHUB_TOKEN lets it authenticate to GitHub
# (license lookups) instead of hitting anonymous rate limits.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: 'Upload: package artifacts'
uses: actions/upload-artifact@v7
with:
name: packages
path: output/packages/*.nupkg
retention-days: 7
if-no-files-found: error
- name: 'Upload: PackageGuard SBOM + risk report'
uses: actions/upload-artifact@v7
with:
name: packageguard
path: output/packageguard/*
retention-days: 7
if-no-files-found: error

# Tier 1 — production / nuget.org. **OPT-IN ONLY.** Tag pushes do NOT trigger
# this job; you must invoke workflow_dispatch with publish-to-nugetorg=true.
Expand Down Expand Up @@ -241,9 +255,11 @@ jobs:
--skip-duplicate
done

# Bundled artifact distribution. Attaches all nupkgs to the GitHub Release
# for the tag. Idempotent: if the release already exists (workflow_dispatch
# retry case), uploads or replaces missing assets via --clobber.
# Bundled artifact distribution. Attaches all nupkgs, plus the PackageGuard SBOM and HTML
# risk report, to the GitHub Release for the tag. Idempotent: if the release already exists
# (workflow_dispatch retry case), uploads or replaces missing assets via --clobber. The .sarif
# itself isn't attached here — it's reported to GitHub's code-scanning feature instead
# (security-scan.yml); the HTML is the human-readable counterpart of the same report.
publish-github-releases:
name: publish → GitHub Releases
runs-on: ubuntu-latest
Expand All @@ -262,6 +278,11 @@ jobs:
with:
name: packages
path: output/packages
- name: 'Download: PackageGuard SBOM + risk report'
uses: actions/download-artifact@v8
with:
name: packageguard
path: output/packageguard
- name: 'Create or update GitHub Release with package artifacts'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -273,14 +294,21 @@ jobs:
TAG="${GITHUB_REF_NAME}"
fi
echo "Tag: $TAG"
assets=(output/packages/*.nupkg)
if [ -f output/packageguard/sbom.json ]; then
assets+=("output/packageguard/sbom.json")
fi
if [ -f output/packageguard/risk-report.html ]; then
assets+=("output/packageguard/risk-report.html")
fi
if gh release view "$TAG" > /dev/null 2>&1; then
echo "Release $TAG already exists — uploading assets with --clobber."
gh release upload "$TAG" output/packages/*.nupkg --clobber
gh release upload "$TAG" "${assets[@]}" --clobber
else
echo "Creating new release $TAG."
gh release create "$TAG" \
--title "$TAG" \
--target "$(git rev-parse HEAD)" \
--generate-notes \
output/packages/*.nupkg
"${assets[@]}"
fi
67 changes: 67 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# ------------------------------------------------------------------------------
# <auto-generated>
#
# This code was generated.
#
# - To turn off auto-generation set:
#
# [GitHubActions (AutoGenerate = false)]
#
# - To trigger manual generation invoke:
#
# fallout --generate-configuration GitHubActions_security-scan --host GitHubActions
#
# </auto-generated>
# ------------------------------------------------------------------------------

name: security-scan

on:
push:
branches:
- develop
- main
- 'release/*'
- 'support/*'
paths-ignore:
- 'docs/**'
- '.assets/**'
- '**/*.md'

permissions:
security-events: write
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
ubuntu-latest:
name: ubuntu-latest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: 'Cache: .fallout/temp, ~/.nuget/packages'
uses: actions/cache@v6
with:
path: |
.fallout/temp
~/.nuget/packages
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- name: 'Setup: .NET SDK'
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- name: 'Run: PackageGuard'
run: dotnet fallout PackageGuard
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: 'Upload risk-report SARIF to GitHub code scanning'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: output/packageguard/risk-report.sarif
21 changes: 21 additions & 0 deletions .packageguard/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Settings": {
"Allow": {
"Licenses": [
"MIT",
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"0BSD",
"MS-PL"
],
// FluentAssertions and NetArchTest.Rules are MIT-licensed but don't publish a
// license expression/URL NuGet metadata can pick up. See #659.
"Packages": [
"FluentAssertions",
"NetArchTest.Rules"
]
}
}
}
67 changes: 61 additions & 6 deletions build/Build.CI.GitHubActions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Collections.Generic;
using Fallout.Common.CI.GitHubActions;
using Fallout.Common.CI.GitHubActions.Configuration;
using Fallout.Components;

// Two generated build workflows. Both run Test+Pack; both are GENERATED from the
// attributes below — edit here and regenerate (`./build.sh`), never hand-edit the
// `.yml`.
// Three generated workflows. build.yml and build-cross-platform.yml both run Test+Pack; all
// three are GENERATED from the attributes below — edit here and regenerate (`./build.sh`),
// never hand-edit the `.yml`.
//
// build.yml — the Linux PR gate, and the ONLY required status
// check (job `ubuntu-latest`; branch protection keys on
Expand All @@ -15,7 +17,12 @@
// merge SHA, keeping HEAD attached so
// GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest
// (which reads .git/HEAD via GitRepository.FromLocalDirectory)
// resolves a non-null branch.
// resolves a non-null branch. Also runs PackageGuard — its
// policy-violation check gates every PR, though the target skips
// SBOM/risk-report generation here (build/Build.PackageGuard.cs,
// IsOnLongLivedBranch — this checkout is never on one of the four).
// EnableGitHubToken avoids anonymous GitHub API rate-limiting on
// PackageGuard's license lookups, now that it runs on every PR.
//
// build-cross-platform.yml — macOS + Windows in ONE workflow (one job per image).
// Cross-platform full Test+Pack is gated to RELEASE
Expand Down Expand Up @@ -44,7 +51,8 @@
// long-lived and protected; all require the ubuntu-latest check.
OnPullRequestBranches = new[] { DevelopBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern },
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
InvokedTargets = new[] { nameof(VerifyGeneratedTools), nameof(ITest.Test), nameof(IPack.Pack) },
InvokedTargets = new[] { nameof(VerifyGeneratedTools), nameof(ITest.Test), nameof(IPack.Pack), nameof(PackageGuard) },
EnableGitHubToken = true,
PublishArtifacts = false)]
[GitHubActions(
"build-cross-platform",
Expand All @@ -61,7 +69,34 @@
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) },
PublishArtifacts = false)]
partial class Build
// security-scan.yml — continuous SBOM + risk-report generation
// (build/Build.PackageGuard.cs). PackageGuard's policy-violation
// check already runs on every PR via build.yml above; this workflow
// is for the SBOM/SARIF/HTML side, which build.yml's target
// deliberately skips (its checkout is never one of the four
// long-lived branches, so IsOnLongLivedBranch is false there).
// Push-only, and only to develop/main/release/*/support/* — a push
// is when there's actually a new commit on one of those branches to
// report on. EnableGitHubToken feeds GITHUB_TOKEN to PackageGuard
// (avoids GitHub API rate-limiting on license lookups) and to the
// upload-sarif step's security-events:write use.
[GitHubActions(
"security-scan",
GitHubActionsImage.UbuntuLatest,
FetchDepth = 0,
ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}",
ConcurrencyCancelInProgress = true,
OnPushBranches = new[] { DevelopBranch, MainBranch, ReleaseBranchPattern, SupportBranchPattern },
OnPushExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
InvokedTargets = new[] { nameof(PackageGuard) },
EnableGitHubToken = true,
// Specifying any `permissions:` block switches the job from GitHub's default read-all to
// explicit-only — contents:read has to be listed too, or upload-sarif (and checkout) lose
// it. See GitHub's own upload-sarif docs for this exact pairing.
ReadPermissions = new[] { GitHubActionsPermissions.Contents },
WritePermissions = new[] { GitHubActionsPermissions.SecurityEvents },
PublishArtifacts = false)]
partial class Build : IConfigureGitHubActions
{
// The release workflow is intentionally hand-written at
// .github/workflows/publish-packages-release.yml — that lets us name the GitHub
Expand All @@ -71,4 +106,24 @@ partial class Build
// workflow's `name:` — it gates ICreateGitHubRelease.CreateGitHubRelease
// (Build.cs) to the release workflow only.
const string ReleaseWorkflow = "publish-packages-release";

// Injects the SARIF upload after security-scan's "dotnet fallout PackageGuard" run step —
// GitHubActionsStepPosition.PostRun is exactly "after the run block, before the built-in
// artifact upload". Scoped to this one generated job by WorkflowName; other jobs get no
// insertions.
void IConfigureGitHubActions.ConfigureSteps(GitHubActionsStepPipeline pipeline)
{
if (pipeline.WorkflowName == "security-scan")
{
pipeline.Insert(GitHubActionsStepPosition.PostRun, new GitHubActionsCustomStep
{
Name = "Upload risk-report SARIF to GitHub code scanning",
Uses = "github/codeql-action/upload-sarif@v3",
With = new Dictionary<string, string>
{
["sarif_file"] = "output/packageguard/risk-report.sarif",
},
});
}
}
}
60 changes: 60 additions & 0 deletions build/Build.PackageGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Fallout.Common;
using Fallout.Common.Git;
using Fallout.Common.IO;
using Fallout.Common.Tooling;
using Fallout.Common.Tools.PackageGuard;
using Fallout.Common.Utilities;
using Fallout.Components;

partial class Build
{
// PackageGuard's own CLI only exposes an env-var override for the risk-report path
// (see PackageGuard.json's help text on ReportRisk) — there's no `--report-risk <path>`
// argument in our wrapper, matching the repo-wide convention that bool CLI flags stay
// presence-only. Setting this process env var instead pins the SARIF/HTML pair to a
// deterministic path we can reference from CI (upload-sarif, the release asset step).
const string PackageGuardReportRiskPathOverrideEnvironmentVariable = "PACKAGEGUARD_REPORT_RISK_PATH_OVERRIDE";

AbsolutePath PackageGuardDirectory => OutputDirectory / "packageguard";
AbsolutePath PackageGuardSbomFile => PackageGuardDirectory / "sbom.json";
AbsolutePath PackageGuardSarifFile => PackageGuardDirectory / "risk-report.sarif";

// The SBOM and risk report (HTML + SARIF) are only worth generating where they're actually
// consumed: security-scan.yml uploads the SARIF on a push to one of these four branches, and
// the release workflow attaches the SBOM/HTML to the GitHub Release. Neither happens on a PR
// (build.yml checks out the contributor's own branch via github.head_ref, never one of
// these), or on a tag-triggered release checkout (detached HEAD — hence the
// GitHubActions.Workflow fallback, since that workflow's own validate-ref job already
// proved the tag is reachable from a production branch).
bool IsOnLongLivedBranch =>
GitRepository.IsOnMainBranch() ||
GitRepository.IsOnDevelopBranch() ||
GitRepository.IsOnReleaseBranch() ||
GitRepository.IsOnSupportBranch() ||
GitHubActions?.Workflow == ReleaseWorkflow;

// Runs unconditionally — this is the PR gate's policy-violation check (build.yml), so it has
// to run on every branch, including a contributor's feature branch. Only the SBOM/risk-report
// generation is restricted to the four long-lived branches, via IsOnLongLivedBranch below.
Target PackageGuard => _ => _
.DependsOn<IRestore>()
.Produces(PackageGuardSbomFile)
.Produces(PackageGuardSarifFile)
.Produces(PackageGuardDirectory / "*.html")
.Executes(() =>
{
var generateReports = IsOnLongLivedBranch;

if (generateReports)
PackageGuardDirectory.CreateOrCleanDirectory();

PackageGuardTasks.PackageGuard(_ => _
.SetProjectPath(Solution.Path)
.SetGitHubApiKey(From<ICreateGitHubRelease>().GitHubToken)
.When(generateReports, _ => _
.EnableReportRisk()
.SetSbom(SbomFormat.cyclonedx)
.SetSbomOutput(PackageGuardSbomFile)
.SetProcessEnvironmentVariable(PackageGuardReportRiskPathOverrideEnvironmentVariable, PackageGuardSarifFile)));
});
}
1 change: 1 addition & 0 deletions build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
Build.cs as the canonical version source. Replaces GitVersion.Tool (#81). Direct
dependency because _build.csproj turns off central package management. -->
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115" PrivateAssets="All" />
<PackageDownload Include="PackageGuard" Version="[2.4.0]" />
<PackageDownload Include="ReportGenerator" Version="[5.2.0]" />
<!-- Used by Fallout.Tooling.Tests' ToolTasksToolPathTest as a sample package
for tool-path resolution smoke tests. Not used for running our own tests. -->
Expand Down
Loading
Loading