feat(ci): reusable .NET CI and NuGet release workflows #3

Merged
KofTwentyTwo merged 2 commits from feat/dotnet-workflows into main 2026-10-09 19:36:50 +00:00
9 changed files with 832 additions and 10 deletions

No files matched your search

+265
View File
@@ -0,0 +1,265 @@
# .NET build, test, coverage, and format gates (K22-CI-01, K22-CI-02, K22-TEST-10,
# K22-DEP-11, K22-CODE-01). Called as:
#
# jobs:
# ci:
# uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@<sha> # vX.Y.Z
# permissions:
# contents: read
# with:
# solution: MySolution.slnx
# coverage-packages: My.Library,My.Library.Core
# test-projects: tests/My.Library.Tests
# ui-test-projects: tests/My.App.UiTests
#
# producing the required contexts `ci / build-test` and `ci / format`, plus
# `ci / ui-tests` when ui-test-projects is set. Every step runs the same command a
# developer runs locally (K22-CI-03):
#
# dotnet restore <solution> -p:Platform=x64 --locked-mode
# dotnet build <solution> -p:Platform=x64 -warnaserror --no-restore
# dotnet test <project> --no-build --collect:"XPlat Code Coverage"
# dotnet format <solution> --verify-no-changes --severity warn --no-restore
#
# Test projects are run without -p:Platform: an x64 solution build maps AnyCPU test
# projects to their AnyCPU output folders, which is where `dotnet test` must look.
name: dotnet
on:
workflow_call:
inputs:
solution:
description: Solution (.slnx or .sln) or project file to build, relative to the repository root.
type: string
required: true
platform:
description: Solution platform passed as -p:Platform (WinUI solutions need x64, x86, or ARM64).
type: string
default: x64
runs-on:
description: Runner label. WinUI and WPF solutions need a Windows runner.
type: string
default: windows-latest
coverage-packages:
description: Comma-separated assembly (coverage package) names that must meet coverage-minimum. Empty disables the gate.
type: string
default: ""
coverage-minimum:
description: Minimum line coverage, in percent, for each package in coverage-packages.
type: number
default: 100
test-projects:
description: Newline-separated test projects to run with coverage.
type: string
default: ""
ui-test-projects:
description: Newline-separated end-to-end UI test projects (FlaUI). Empty skips the ui-tests job.
type: string
default: ""
pack:
description: Also run dotnet pack (Release) and upload the packages as an artifact.
type: boolean
default: false
permissions: {}
jobs:
build-test:
name: build-test
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 45
permissions:
contents: read
env:
# Prefixed: MSBuild turns every environment variable into a property, so a variable
# named PLATFORM would silently change output paths (bin/x64/) for dotnet test.
K22_SOLUTION: ${{ inputs.solution }}
K22_PLATFORM: ${{ inputs.platform }}
TEST_PROJECTS: ${{ inputs.test-projects }}
COVERAGE_PACKAGES: ${{ inputs.coverage-packages }}
COVERAGE_MINIMUM: ${{ inputs.coverage-minimum }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
# --locked-mode fails if a resolved version differs from packages.lock.json, so
# the build can only use the dependency graph that was reviewed and scanned.
- name: Restore (locked)
shell: pwsh
run: dotnet restore $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --locked-mode
# -warnaserror also fails on NuGetAudit vulnerability warnings.
- name: Build (warnings as errors)
shell: pwsh
run: dotnet build $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" -warnaserror --no-restore
- name: Test with coverage
if: inputs.test-projects != ''
shell: pwsh
run: |
$settings = @()
if (Test-Path -LiteralPath coverage.runsettings) { $settings = @('--settings', 'coverage.runsettings') }
$projects = $env:TEST_PROJECTS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
foreach ($project in $projects)
{
dotnet test $project --no-build @settings --collect:"XPlat Code Coverage" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# Best line rate per package across every report: a package can be exercised by
# several test projects, and the report that covers it most is authoritative.
- name: Coverage gate
if: inputs.coverage-packages != ''
shell: pwsh
run: |
$files = @(Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
if ($files.Count -eq 0)
{
Write-Output '::error::No coverage.cobertura.xml under TestResults; did the coverage collector run?'
exit 1
}
$reports = foreach ($file in $files) { [xml] (Get-Content -LiteralPath $file.FullName -Raw) }
$required = [double] $env:COVERAGE_MINIMUM / 100
$failed = $false
foreach ($name in ($env:COVERAGE_PACKAGES -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }))
{
$best = $null
$bestPackage = $null
foreach ($report in $reports)
{
$package = @($report.coverage.packages.package) | Where-Object { $_.name -eq $name }
if (-not $package) { continue }
$rate = [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture)
if ($null -eq $best -or $rate -gt $best) { $best = $rate; $bestPackage = $package }
}
if ($null -eq $best)
{
Write-Output "::error::Package '$name' was not found in any coverage report."
$failed = $true
continue
}
Write-Output ('{0} line coverage: {1}% (required {2}%)' -f $name, [math]::Round($best * 100, 2), $env:COVERAGE_MINIMUM)
if ($best -lt $required)
{
foreach ($class in @($bestPackage.classes.class))
{
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
if ($missed) { Write-Output "::error file=$($class.filename)::$name uncovered lines $($missed -join ', ')" }
}
$failed = $true
}
}
if ($failed) { exit 1 }
Write-Output 'Coverage gate passed.'
- name: Upload coverage
if: always() && inputs.test-projects != ''
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage-cobertura
path: TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
- name: Pack (Release)
if: inputs.pack
shell: pwsh
run: dotnet pack $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" -o artifacts/nuget
- name: Upload packages
if: inputs.pack
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nuget-packages
path: artifacts/nuget/*
if-no-files-found: error
format:
name: format
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 30
permissions:
contents: read
env:
K22_SOLUTION: ${{ inputs.solution }}
K22_PLATFORM: ${{ inputs.platform }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Restore (locked)
shell: pwsh
run: dotnet restore $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --locked-mode
# The Kingsrook .editorconfig sets layout (IDE0055) and the style rules to
# warning, so --severity warn makes any layout or style drift fail here exactly
# as it fails `dotnet build` at the desk.
# dotnet format analyzes the workspace without running XAML compilation; without the
# generated *.g.cs files from a build, XAML event handlers look unused (IDE0051).
- name: Build (generates XAML code the analyzers need)
shell: pwsh
run: dotnet build $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --no-restore
- name: Verify formatting (whitespace, style, analyzers)
shell: pwsh
run: dotnet format $env:K22_SOLUTION --verify-no-changes --severity warn --no-restore
ui-tests:
name: ui-tests
if: inputs.ui-test-projects != ''
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 45
permissions:
contents: read
env:
K22_SOLUTION: ${{ inputs.solution }}
K22_PLATFORM: ${{ inputs.platform }}
UI_TEST_PROJECTS: ${{ inputs.ui-test-projects }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Restore (locked)
shell: pwsh
run: dotnet restore $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --locked-mode
- name: Build
shell: pwsh
run: dotnet build $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --no-restore
# FlaUI drives the built apps on the hosted runner's interactive desktop.
- name: Run UI tests
shell: pwsh
run: |
$projects = $env:UI_TEST_PROJECTS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
foreach ($project in $projects)
{
dotnet test $project --no-build
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
+365
View File
@@ -0,0 +1,365 @@
# Release of NuGet packages (K22-CI-30, K22-CI-31, K22-REL-01..07). Called from the
# calling repository's tag workflow; see templates/workflows/release-nuget.yml:
#
# on:
# push:
# tags: ['v*']
# jobs:
# release:
# uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@<sha> # vX.Y.Z
# permissions:
# contents: write # create the GitHub release
# id-token: write # sign attestations (Sigstore)
# attestations: write # store attestations
# with:
# solution: MySolution.slnx
# coverage-packages: My.Library
# test-projects: tests/My.Library.Tests
# publish: # nuget.org push: must live in the CALLER's workflow file
# needs: release
# ...
#
# What this workflow does, in order:
# build (Windows, read-only): validate the tag, locked restore, Release build with
# warnings as errors, tests and coverage gate, dotnet pack with the tag version.
# sbom (Linux, per package): CycloneDX 1.6 SBOM from the project's lock file
# (Syft), attested against its package.
# attest (Linux): SHA256SUMS over every asset, then SLSA build provenance for all
# of them. Because the build runs here, in KofTwentyTwo/standards, the
# provenance names this reusable workflow as the builder (SLSA Build L3).
# release (Linux): draft GitHub release with generated notes and every asset, then
# publish (releases are immutable once published).
#
# Publishing to nuget.org is deliberately NOT done here. nuget.org trusted publishing
# matches the OIDC token's `repository` AND `job_workflow_ref` claims against the
# package's own repository, so the push must run in a job defined in the calling
# repository's workflow file (see the template). That job downloads the `packages`
# artifact, verifies its attestation was signed by this workflow, then pushes.
name: release-nuget
on:
workflow_call:
inputs:
solution:
description: Solution (.slnx or .sln) to build and pack.
type: string
required: true
platform:
description: Solution platform passed as -p:Platform.
type: string
default: x64
runs-on:
description: Runner for the build job (Windows for WinUI/WPF solutions).
type: string
default: windows-latest
coverage-packages:
description: Comma-separated coverage packages that must meet coverage-minimum. Empty disables the gate.
type: string
default: ""
coverage-minimum:
description: Minimum line coverage, in percent.
type: number
default: 100
test-projects:
description: Newline-separated test projects to run with coverage before packing.
type: string
default: ""
outputs:
version:
description: The released version (tag without the leading v).
value: ${{ jobs.build.outputs.version }}
prerelease:
description: '"true" when the version has a prerelease suffix.'
value: ${{ jobs.build.outputs.prerelease }}
permissions: {}
jobs:
build:
name: build
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 60
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
prerelease: ${{ steps.version.outputs.prerelease }}
packages: ${{ steps.packages.outputs.packages }}
env:
K22_SOLUTION: ${{ inputs.solution }}
K22_PLATFORM: ${{ inputs.platform }}
TEST_PROJECTS: ${{ inputs.test-projects }}
COVERAGE_PACKAGES: ${{ inputs.coverage-packages }}
COVERAGE_MINIMUM: ${{ inputs.coverage-minimum }}
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Strict SemVer 2.0 tag; build metadata ('+...') is rejected (K22-REL-01).
- name: Validate tag and derive version
id: version
shell: pwsh
env:
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
if ($env:REF_TYPE -ne 'tag')
{
Write-Output "::error::Releases run only for tags; got $env:REF_TYPE '$env:REF_NAME'."
exit 1
}
$semver = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$'
if ($env:REF_NAME -cnotmatch $semver)
{
Write-Output "::error::Tag '$env:REF_NAME' is not vMAJOR.MINOR.PATCH with an optional prerelease suffix."
exit 1
}
$version = $env:REF_NAME.Substring(1)
$prerelease = $version.Contains('-').ToString().ToLowerInvariant()
Write-Output "Version $version, prerelease $prerelease"
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Set up .NET SDK
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
# No caches anywhere in this workflow (K22-CI-15): a cache written by a pull
# request run must never reach a release.
- name: Restore (locked)
shell: pwsh
run: dotnet restore $env:K22_SOLUTION "-p:Platform=$env:K22_PLATFORM" --locked-mode
- name: Build (Release, warnings as errors)
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: dotnet build $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" "-p:Version=$env:K22_VERSION" -warnaserror --no-restore
- name: Test with coverage
if: inputs.test-projects != ''
shell: pwsh
run: |
$settings = @()
if (Test-Path -LiteralPath coverage.runsettings) { $settings = @('--settings', 'coverage.runsettings') }
$projects = $env:TEST_PROJECTS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }
foreach ($project in $projects)
{
dotnet test $project -c Release --no-build @settings --collect:"XPlat Code Coverage" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Coverage gate
if: inputs.coverage-packages != ''
shell: pwsh
run: |
$files = @(Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
if ($files.Count -eq 0)
{
Write-Output '::error::No coverage.cobertura.xml under TestResults.'
exit 1
}
$reports = foreach ($file in $files) { [xml] (Get-Content -LiteralPath $file.FullName -Raw) }
$required = [double] $env:COVERAGE_MINIMUM / 100
$failed = $false
foreach ($name in ($env:COVERAGE_PACKAGES -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }))
{
$rates = foreach ($report in $reports)
{
$package = @($report.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates)
{
Write-Output "::error::Package '$name' was not found in any coverage report."
$failed = $true
continue
}
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output ('{0} line coverage: {1}% (required {2}%)' -f $name, [math]::Round($best * 100, 2), $env:COVERAGE_MINIMUM)
if ($best -lt $required) { $failed = $true }
}
if ($failed) { exit 1 }
- name: Pack
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: dotnet pack $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" "-p:Version=$env:K22_VERSION" --no-build -o artifacts/nuget
# Maps each package to the project folder whose packages.lock.json describes its
# dependencies, for the SBOM job. Package ids come from the produced file names.
- name: List packages
id: packages
shell: pwsh
env:
K22_VERSION: ${{ steps.version.outputs.version }}
run: |
$suffix = ".$env:K22_VERSION.nupkg"
$entries = foreach ($file in Get-ChildItem -Path artifacts/nuget -Filter '*.nupkg')
{
if (-not $file.Name.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase)) { continue }
$id = $file.Name.Substring(0, $file.Name.Length - $suffix.Length)
$project = Get-ChildItem -Path . -Recurse -Filter "$id.csproj" -File | Select-Object -First 1
if (-not $project)
{
Write-Output "::error::No project file named $id.csproj for package $($file.Name)."
exit 1
}
$directory = [IO.Path]::GetRelativePath((Get-Location).Path, $project.DirectoryName) -replace '\\', '/'
if (-not (Test-Path -LiteralPath (Join-Path $project.DirectoryName 'packages.lock.json')))
{
Write-Output "::error::$directory has no packages.lock.json (K22-REPO-09); the SBOM needs it."
exit 1
}
[ordered]@{ id = $id; package = $file.Name; directory = $directory }
}
$json = ConvertTo-Json -InputObject @($entries) -Compress
Write-Output "Packages: $json"
"packages=$json" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Upload packages
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packages
path: artifacts/nuget/*
if-no-files-found: error
sbom:
name: sbom (${{ matrix.package.id }})
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
id-token: write # sign the SBOM attestation
attestations: write # store it
strategy:
fail-fast: true
matrix:
package: ${{ fromJSON(needs.build.outputs.packages) }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download packages
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packages
path: artifacts/nuget
- name: Prepare SBOM folder
run: mkdir -p artifacts/sbom
# CycloneDX 1.6 from the project's packages.lock.json: the exact resolved graph
# the package was built with (K22-REL-06).
- name: Generate SBOM (Syft)
uses: docker://ghcr.io/anchore/syft:v1.54.1@sha256:3eb5379ba7b409c3f4069b686110527af0c47df993fa5c10d13e7cf34f49b1aa
with:
args: >-
scan dir:/github/workspace/${{ matrix.package.directory }}
--source-name ${{ matrix.package.id }}
--source-version ${{ needs.build.outputs.version }}
--output cyclonedx-json@1.6=/github/workspace/artifacts/sbom/${{ matrix.package.id }}.${{ needs.build.outputs.version }}.cdx.json
--quiet
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: artifacts/nuget/${{ matrix.package.package }}
sbom-path: artifacts/sbom/${{ matrix.package.id }}.${{ needs.build.outputs.version }}.cdx.json
- name: Upload SBOM
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: sbom-${{ matrix.package.id }}
path: artifacts/sbom/*.cdx.json
if-no-files-found: error
attest:
name: attest
needs: [build, sbom]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
id-token: write # sign the provenance attestation
attestations: write # store it
steps:
- name: Download packages
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packages
path: release
- name: Download SBOMs
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: sbom-*
path: release
merge-multiple: true
- name: Checksums
working-directory: release
run: |
find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort | xargs sha256sum > "$RUNNER_TEMP/SHA256SUMS"
mv "$RUNNER_TEMP/SHA256SUMS" SHA256SUMS
cat SHA256SUMS
# Anyone can verify an asset with:
# gh attestation verify <file> --repo <owner/repo> --signer-repo KofTwentyTwo/standards
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: release/*
- name: Upload release assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: release/*
if-no-files-found: error
release:
name: release
needs: [build, attest]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write # create and publish the GitHub release
steps:
- name: Download release assets
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-assets
path: release
# Releases are immutable once published (K22-REL-07): everything lands on a draft
# first, and publishing is the last step.
- name: Create draft release with assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
K22_VERSION: ${{ needs.build.outputs.version }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
REPOSITORY_NAME: ${{ github.event.repository.name }}
run: |
prerelease_flag=()
if [[ "$PRERELEASE" == "true" ]]; then prerelease_flag=(--prerelease); fi
gh release create "$TAG" --draft --verify-tag --generate-notes \
--title "$REPOSITORY_NAME $K22_VERSION" "${prerelease_flag[@]}" release/*
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: gh release edit "$TAG" --draft=false
+2
View File
@@ -234,6 +234,8 @@ from each repository:
| `pr.yml` | `pr / title`, `pr / dco`, `pr / dependency-review` | Conventional Commit PR title, DCO sign-off on every commit, new dependencies' vulnerabilities and licenses |
| `security.yml` | `security / secrets`, `security / sca`, `security / workflows` | gitleaks over full history; Trivy vulnerabilities and OSV malicious-package advisories in the dependency graph; zizmor and actionlint on workflows |
| `codeql.yml` | `codeql / analyze (<language>)` | CodeQL `security-extended`, failing on high severity and above |
| `dotnet.yml` | `ci / build-test`, `ci / format`, `ci / ui-tests` | .NET locked restore, zero-warning build, tests, coverage gate, Kingsrook format check, FlaUI UI tests |
| `release-nuget.yml` | (tag workflow) | NuGet release: gates, pack, SBOM, checksums, SLSA provenance, GitHub release; pairs with a caller `publish` job for nuget.org |
A caller looks like this:
+10 -3
View File
@@ -22,16 +22,23 @@ formatter cannot express (three blank lines between members).
1. Copy the three files. Keep any repository-specific groups of an existing
`Directory.Build.props` (version, package metadata) and replace its quality settings.
2. Set `* text=auto eol=lf` in `.gitattributes` (K22-CODE-07).
3. Restore once so `packages.lock.json` files are created, and commit them.
4. Reformat and fix:
3. Add a `global.json` pinning the SDK feature band (`"rollForward": "latestFeature"`);
CI installs the SDK from it.
4. Restore once so `packages.lock.json` files are created, and commit them. CI restores
with `--locked-mode`, which fails on any drift from the lock files, and the release
workflow builds each package's SBOM from them.
5. Reformat and fix:
```powershell
dotnet format # layout, var, usings, naming, line endings
dotnet build -warnaserror # remaining analyzer findings
```
5. Commit the reformat on its own (`style: adopt Kingsrook layout`) so `git blame` can
6. Commit the reformat on its own (`style: adopt Kingsrook layout`) so `git blame` can
skip it: add that commit's hash to `.git-blame-ignore-revs`.
7. Add the CI and release callers from
[`templates/workflows/dotnet-ci.yml`](../../templates/workflows/dotnet-ci.yml) and
[`templates/workflows/release-nuget.yml`](../../templates/workflows/release-nuget.yml).
## IDEs
+12 -4
View File
@@ -19,7 +19,10 @@ requires all of them:
| `security / sca` | No HIGH or CRITICAL vulnerability, and no package flagged as malicious (OSV `MAL-` advisories), in the resolved dependency graph | `security.yml` |
| `security / workflows` | Workflow files pass zizmor and actionlint | `security.yml` |
| `codeql / analyze (<language>)` | No CodeQL alert at *high* severity or above | `codeql.yml` |
| Language checks, e.g. `ci / build-test`, `ci / format` | Zero-warning build, tests, coverage gate, format | language workflow |
| `ci / build-test` (.NET) | Locked restore, zero-warning build, tests, coverage gate | `dotnet.yml` |
| `ci / format` (.NET) | `dotnet format --verify-no-changes --severity warn`: Kingsrook layout, style, analyzers | `dotnet.yml` |
| `ci / ui-tests` (.NET apps with UI tests) | FlaUI end-to-end tests against the built app | `dotnet.yml` |
| Other languages | The language profile's build, test, coverage, and format commands | language workflow |
*Why:* the merge gate is the same in every repository and cannot be skipped.
*Verified by:* conformance checker (required contexts in the ruleset). *Maps to:*
@@ -127,10 +130,15 @@ badge.
**K22-CI-30 (MUST)** Releases are built only by a workflow triggered by a protected
`v*` tag, running on a GitHub-hosted runner, through a **reusable workflow** from
`KofTwentyTwo/standards`, so the build platform and its definition are isolated from
the calling repository.
`KofTwentyTwo/standards` (`release-nuget.yml` for NuGet packages), so the build
platform and its definition are isolated from the calling repository. Publishing to a
registry whose trusted publishing binds the token to the calling repository's own
workflow file (nuget.org does) runs in a job of the caller's tag workflow, after it has
verified the artifacts' attestations were signed by the reusable workflow.
*Why:* this is what makes provenance trustworthy (SLSA Build L3). *Verified by:*
provenance `builder.id` names the reusable workflow. See [releases](releases.md).
provenance `builder.id` names the reusable workflow; conformance checker (the tag
workflow calls `KofTwentyTwo/standards/.github/workflows/release-*.yml` pinned by SHA).
See [releases](releases.md).
**K22-CI-31 (MUST)** A release re-runs every gate that guards `main` (build, tests,
coverage, security scans) before publishing anything.
+26
View File
@@ -78,6 +78,32 @@ protected `release` environment.
*Why:* no long-lived publishing credential to steal. *Verified by:*
[`K22-CI-16`](ci-cd.md#workflow-security).
### .NET packages
NuGet packages are released by the shared
[`release-nuget.yml`](../.github/workflows/release-nuget.yml) workflow, called from the
repository's tag workflow ([template](../templates/workflows/release-nuget.yml)). It
builds, tests, packs, writes a CycloneDX SBOM per package from its lock file, attests
provenance and SBOMs, and publishes the GitHub release. The template's `publish` job
then pushes to nuget.org.
nuget.org trusted publishing compares the OIDC token's `repository` and
`job_workflow_ref` claims with the policy. `job_workflow_ref` names the workflow file
that *defines the job*, so the push cannot run inside the reusable workflow (that file
lives in `KofTwentyTwo/standards`); it runs in the calling repository's own
`release.yml`, after verifying the packages' attestations. The policy is an owner
action, made once per repository on nuget.org (profile → Trusted Publishing):
| Field | Value |
| --- | --- |
| Repository Owner | `KofTwentyTwo` |
| Repository | the repository name, e.g. `AppKit` |
| Workflow File | `release.yml` (the calling workflow's file name) |
| Environment | `release` |
Then set the repository variable `NUGET_USER` to the nuget.org profile name. Until the
variable is set, the `publish` job is skipped and the release stays GitHub-only.
## Release process
**K22-REL-12 (MUST)** Each MINOR or MAJOR release has a release checklist (an issue made
+33
View File
@@ -0,0 +1,33 @@
# .NET build, test, coverage, and format gates for a KofTwentyTwo repository
# (K22-CI-01, K22-CI-02). Copy to .github/workflows/ci.yml and fill the inputs.
#
# Produces the required contexts `ci / build-test` and `ci / format` (and
# `ci / ui-tests` when ui-test-projects is set). Reusable workflows are pinned by full
# commit SHA (K22-CI-11): replace {{STANDARDS_SHA}} with the commit of the standards
# release you conform to; Dependabot's github-actions ecosystem keeps it current.
name: ci
on:
pull_request:
push:
branches: [main]
permissions: {}
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ci:
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@{{STANDARDS_SHA}} # {{STANDARDS_VERSION}}
permissions:
contents: read
with:
solution: "{{SOLUTION}}"
platform: x64
coverage-packages: "{{COVERAGE_PACKAGES}}"
test-projects: |
{{TEST_PROJECT}}
ui-test-projects: |
{{UI_TEST_PROJECT}}
+76
View File
@@ -0,0 +1,76 @@
# NuGet package release for a KofTwentyTwo repository (K22-CI-30, K22-REL-*).
# Copy to .github/workflows/release.yml. Pushing a protected v* tag builds, tests,
# packs, generates SBOMs, attests, and publishes the GitHub release through the shared
# reusable workflow, then pushes the packages to nuget.org.
#
# nuget.org trusted publishing (one-time owner setup, no API key stored anywhere):
# nuget.org → your profile → Trusted Publishing → Add policy:
# Repository Owner: {{OWNER}}
# Repository: {{REPOSITORY}}
# Workflow File: release.yml (THIS file's name, not the reusable workflow's)
# Environment: release
# then set the repository variable NUGET_USER to your nuget.org profile name.
# The publish job must stay in this file: nuget.org checks that the OIDC token's
# job_workflow_ref belongs to this repository, which a job inside the reusable
# workflow (in KofTwentyTwo/standards) can never satisfy.
name: release
on:
push:
tags:
- "v*"
permissions: {}
jobs:
release:
uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@{{STANDARDS_SHA}} # {{STANDARDS_VERSION}}
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations
attestations: write # store attestations
with:
solution: "{{SOLUTION}}"
platform: x64
coverage-packages: "{{COVERAGE_PACKAGES}}"
test-projects: |
{{TEST_PROJECT}}
publish:
name: publish (nuget.org)
needs: release
if: vars.NUGET_USER != ''
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
permissions:
contents: read
attestations: read # verify the attestations before pushing
id-token: write # trusted publishing token exchange
steps:
- name: Download packages
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packages
path: packages
# Push only bytes that the shared release workflow built and attested.
- name: Verify attestations
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
for package in packages/*.nupkg; do
gh attestation verify "$package" --repo "$REPOSITORY" --signer-repo KofTwentyTwo/standards
done
- name: NuGet login (OIDC to a one-hour API key)
id: login
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ vars.NUGET_USER }}
- name: Push to nuget.org
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
run: dotnet nuget push "packages/*.nupkg" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
+43 -3
View File
@@ -317,6 +317,31 @@ function Get-MissingLocks
}
# Any of these makes a repository buildable software rather than documentation (K22-REPO-02).
# True when workflow text calls a KofTwentyTwo/standards reusable release workflow
# pinned to a full commit SHA (K22-CI-30, K22-CI-11), or calls it by local path inside
# the standards repository itself.
function Test-StandardsReleaseCall
{
param([string] $Text)
return [bool] ($Text -match '(?m)^\s*(?:-\s+)?uses:\s*["'']?(KofTwentyTwo/standards/\.github/workflows/release-[A-Za-z0-9_.-]+\.ya?ml@[0-9a-f]{40}|\./\.github/workflows/release-[A-Za-z0-9_.-]+\.ya?ml)')
}
# Status contexts the protect-main ruleset must require for a repository with these
# files (K22-CI-01): the shared gates, plus the .NET gates when there is a solution
# or project file.
function Get-RequiredContexts
{
param([string[]] $Paths)
$contexts = [System.Collections.Generic.List[string]]::new()
foreach ($context in $script:RequiredContexts) { $contexts.Add($context) }
if (@($Paths | Where-Object { $_ -match '(^|/)[^/]+\.(slnx?|csproj)$' }).Count -gt 0)
{
$contexts.Add('ci / build-test')
$contexts.Add('ci / format')
}
return , $contexts.ToArray()
}
$script:BuildManifestPattern = '(^|/)([^/]+\.(slnx?|csproj|fsproj|vbproj)|package\.json|pyproject\.toml|Cargo\.toml|go\.mod|pom\.xml|build\.gradle(\.kts)?|Package\.swift)$'
$script:BinaryPattern = '\.(exe|dll|so|dylib|a|lib|pdb|o|obj|class|jar|war|ear|pyc|nupkg|snupkg|msi|msix|appx|appxbundle|zip|7z|rar|tar|gz|tgz|bz2|xz|dmg|pkg|deb|rpm|apk|ipa|wasm)$'
@@ -397,6 +422,17 @@ jobs:
Assert (@(Get-MissingLocks @('samples/x/package.json')).Count -eq 0) 'locks: samples ignored'
Assert (@(Get-MissingLocks @('Cargo.toml', 'Cargo.lock', 'go.mod')) -contains 'Go') 'locks: go'
$sha = '0123456789abcdef0123456789abcdef01234567'
Assert (Test-StandardsReleaseCall "jobs:`n release:`n uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@$sha # v1.0.0") 'release call: pinned'
Assert (-not (Test-StandardsReleaseCall " uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@main")) 'release call: branch ref'
Assert (-not (Test-StandardsReleaseCall " uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@$sha")) 'release call: not a release workflow'
Assert (-not (Test-StandardsReleaseCall " uses: Someone/standards/.github/workflows/release-nuget.yml@$sha")) 'release call: other owner'
Assert (Test-StandardsReleaseCall " uses: ./.github/workflows/release-nuget.yml") 'release call: local in standards'
Assert ((Get-RequiredContexts @('AppKit.slnx', 'README.md')) -contains 'ci / build-test') 'contexts: dotnet build-test'
Assert ((Get-RequiredContexts @('src/a/a.csproj')) -contains 'ci / format') 'contexts: dotnet format'
Assert (-not ((Get-RequiredContexts @('README.md')) -contains 'ci / build-test')) 'contexts: docs repo'
Assert ((Get-RequiredContexts @('README.md')) -contains 'security / sca') 'contexts: shared'
Assert ('src/App/App.csproj' -match $script:BuildManifestPattern) 'manifest: csproj'
Assert ('AppKit.slnx' -match $script:BuildManifestPattern) 'manifest: slnx'
Assert ('tools/x/package.json' -match $script:BuildManifestPattern) 'manifest: nested package.json'
@@ -736,8 +772,12 @@ else
# K22-CI-30: release only through the standards' reusable release workflow.
if ($releaseFlows.Count -eq 0) { Add-Result 'K22-CI-30' 'N/A' 'no tag-triggered release workflow' }
elseif (Test-Calls 'release[^/]*') { Add-Result 'K22-CI-30' 'PASS' 'releases through a KofTwentyTwo/standards reusable workflow' }
else { Add-Result 'K22-CI-30' 'FAIL' ('release workflow builds in the calling repository, not through a standards reusable workflow (no SLSA Build L3): ' + ($releaseFlows -join ', ')) }
else
{
$direct = @($releaseFlows | Where-Object { -not (Test-StandardsReleaseCall $workflows[$_]) })
if ($direct.Count -eq 0) { Add-Result 'K22-CI-30' 'PASS' 'releases through a SHA-pinned KofTwentyTwo/standards release-*.yml reusable workflow' }
else { Add-Result 'K22-CI-30' 'FAIL' ('tag workflow does not call a SHA-pinned KofTwentyTwo/standards release-*.yml (no SLSA Build L3): ' + ($direct -join ', ')) }
}
}
Add-Result 'K22-CI-02' 'REVIEW' 'tests run on every PR and push: confirm in the language workflow'
@@ -816,7 +856,7 @@ else
# K22-CI-01: required contexts.
$contexts = if ($rules.ContainsKey('required_status_checks')) { @($rules['required_status_checks'].parameters.required_status_checks | ForEach-Object context) } else { @() }
$missingContexts = @($script:RequiredContexts | Where-Object { $contexts -notcontains $_ })
$missingContexts = @((Get-RequiredContexts $script:Paths) | Where-Object { $contexts -notcontains $_ })
foreach ($prefix in $script:RequiredContextPrefixes) { if (-not ($contexts | Where-Object { $_.StartsWith($prefix) })) { $missingContexts += "$prefix (...)" } }
if ($missingContexts) { Add-Result 'K22-CI-01' 'FAIL' ('protect-main does not require: ' + ($missingContexts -join ', ') + $(if ($contexts) { " (requires: $($contexts -join ', '))" } else { '' })) }
else { Add-Result 'K22-CI-01' 'PASS' "requires $($contexts.Count) checks including every standard context" }