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
4 changes: 3 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ set -eu
# partial staging (git add -p) the hook can pass while the staged content
# alone would not build. Acceptable for a tripwire; CI checks the real tree.
# D included: deleting a .cs/.csproj can break references just as well.
# Directory.Packages.props included: a version bump there changes the graph
# every project restores (#684), the same as editing the csproj used to.
staged=$(git diff --cached --name-only --diff-filter=ACMRD)

fail() { echo "pre-commit: FAILED ($1) — fix or use --no-verify." >&2; exit 1; }

if echo "$staged" | grep -qE '\.sln$|\.cs(proj)?$'; then
if echo "$staged" | grep -qE '\.sln$|\.cs(proj)?$|^Directory\.Packages\.props$'; then
echo "pre-commit: .NET unit tests (domain + application)..."
dotnet test tests/Cluckwork.Domain.Tests --nologo -v q || fail ".NET unit tests"
dotnet test tests/Cluckwork.Application.Tests --nologo -v q || fail ".NET unit tests"
Expand Down
20 changes: 12 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ jobs:
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: "**/*.csproj"
cache-dependency-path: |
**/*.csproj
Directory.Packages.props

# --locked-mode: restore must match the committed packages.lock.json
# exactly, so a dependency can't float to a different resolved version
Expand Down Expand Up @@ -282,9 +284,9 @@ jobs:
uses: actions/cache@v6
with:
path: /tmp/.buildx-cache
key: image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
key: image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
restore-keys: |
image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-

# Build the exact image the container ships: the multi-stage Dockerfile
Expand All @@ -306,7 +308,7 @@ jobs:
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

# #315 — prove --locked-mode is enforced in the Docker restore: perturb an
# exact-pinned PackageReference WITHOUT refreshing its lock and assert the
# exact-pinned PackageVersion WITHOUT refreshing its lock and assert the
# build fails (NU1004). `--target build` stops at the restore/publish stage
# (skips the SPA/web stage), so this is a fast negative check on a throwaway
# copy of the committed tree — the real build above is untouched.
Expand All @@ -316,12 +318,14 @@ jobs:
work="$(mktemp -d)"
git archive HEAD | tar -x -C "$work"
cd "$work"
sed -i 's#<PackageReference Include="Microsoft.OpenApi" Version="2.12.2" #<PackageReference Include="Microsoft.OpenApi" Version="2.12.1" #' \
src/Cluckwork.Api/Cluckwork.Api.csproj
# #684 — versions live in Directory.Packages.props (Central Package
# Management), so the perturbation edits the PackageVersion there.
sed -i 's#<PackageVersion Include="Microsoft.OpenApi" Version="2.12.2" #<PackageVersion Include="Microsoft.OpenApi" Version="2.12.1" #' \
Directory.Packages.props
# Fail loudly (don't silently pass) if the pin used as the perturbation
# target has since moved — the guard must be kept pointing at a real pin.
grep -q '<PackageReference Include="Microsoft.OpenApi" Version="2.12.1" ' \
src/Cluckwork.Api/Cluckwork.Api.csproj \
grep -q '<PackageVersion Include="Microsoft.OpenApi" Version="2.12.1" ' \
Directory.Packages.props \
|| { echo "::error::drift-guard perturbation target not found; update the Microsoft.OpenApi version it edits"; exit 1; }
if docker build --target build -f src/Cluckwork.Api/Dockerfile -t cluckwork-lockdrift:ci . ; then
echo "::error::Docker restore succeeded with a stale packages.lock.json — --locked-mode is not enforced"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/dependency-submission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ on:
- "**/*.csproj"
- "**/packages.lock.json"
- "Directory.Build.props"
- "Directory.Packages.props"
- "Cluckwork.sln"
- ".github/workflows/dependency-submission.yml"
workflow_dispatch:
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/e2e-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ on:
# itself lives under src/ (covered above), but it COPYs these from the
# repo root, and .dockerignore governs its whole build context.
- "Directory.Build.props"
- "Directory.Packages.props"
- "Cluckwork.sln"
- ".dockerignore"
- "deploy/**"
Expand Down Expand Up @@ -112,9 +113,9 @@ jobs:
uses: actions/cache@v6
with:
path: /tmp/.buildx-cache
key: image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
key: image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-${{ github.sha }}
restore-keys: |
image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-${{ hashFiles('src/Cluckwork.Api/Dockerfile', '**/packages.lock.json', 'web/package-lock.json', 'Directory.Build.props', 'Directory.Packages.props', 'Cluckwork.sln', '.dockerignore') }}-
image-layers-

# Pre-build the app image under the compose-derived tag (project
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ A guard is a test whose job is to *fail* when someone later does the wrong thing

CI fails a PR when a **production** dependency carries a known **high+** advisory — NuGet (`dotnet list package --vulnerable`) and npm prod deps (`npm audit --omit=dev`; dev-only advisories are logged, not blocking). Plus dependency-review, CodeQL (advisory), and a weekly scheduled audit. Both audit gates run through `.github/scripts/vuln-gate.mjs` and **fail closed**; the only mute is a dated `.github/security-exceptions.json` entry (exact GHSA id, required `expires`). → [`146-ci-security-gates.md`](docs/decisions/146-ci-security-gates.md)

- **NuGet versions live in `Directory.Packages.props` (#684).** Central Package Management: every `.csproj` carries bare `PackageReference` elements and the one `PackageVersion` list at the repo root decides the version, so a bump is one file, and two projects cannot silently disagree. `CentralPackageFloatingVersionsEnabled` is on because the ranges (`10.*`, `1.*`) moved over as they were; the committed lock files, not the ranges, pin what restores. `Directory.Build.props` beside it does one unrelated thing (`RestorePackagesWithLockFile`) — do not merge the two. The Dockerfile's restore layer, the CI drift guard and every path filter name the props file explicitly; a new restore input goes in all of them.
- **NuGet lock files.** Every project has a committed `packages.lock.json` and CI restores `--locked-mode`, so a package add or bump commits the regenerated lock files **in the same commit** or CI fails with `NU1004`. Dependabot NuGet PRs are auto-healed by `.github/workflows/dependabot-lockfix.yml`.
- **Pin third-party Actions to a full commit SHA** with a trailing `# vX.Y.Z` comment — never a mutable tag (the 2026-03 `aquasecurity/trivy-action` and 2025-03 `tj-actions/changed-files` compromises both retargeted tags). `actions/*` and `github/*` may keep major-version tags.

Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ Block on these like a missing test:

## Dependencies

- NuGet versions live in `Directory.Packages.props` at the repo root (Central
Package Management, #684); the `.csproj` files carry no `Version=`. Bump there,
then `dotnet restore Cluckwork.sln`.
- A package add or bump commits the regenerated `packages.lock.json` **in the same
commit** — CI restores `--locked-mode` and otherwise fails with `NU1004`.
- A known-vulnerable production dependency fails CI. The only mute is a dated
Expand Down
57 changes: 57 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<Project>

<PropertyGroup>
<!-- #684: Central Package Management. Every NuGet version lives here;
the .csproj files carry bare PackageReference elements. Bump a package
by editing this file, then run `dotnet restore Cluckwork.sln` and commit
the regenerated packages.lock.json files with it (#146). -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!-- CPM rejects floating ranges (10.*, 1.*, ...) with NU1011 unless this is
set. The ranges below are the same ones the .csproj files carried; the
committed lock files, not the ranges, pin what actually restores. -->
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
</PropertyGroup>

<ItemGroup>
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.5.3" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.5.3" />
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.5.3" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.5.3" />
<PackageVersion Include="FluentValidation" Version="12.*" />
<PackageVersion Include="Google.Protobuf" Version="3.*" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.*" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.*" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.*" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.*" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.*" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.*" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.*" />
<!-- Pinned, floor AND ceiling load-bearing: transitive 2.0.0 has
GHSA-v5pm-xwqc-g5wc (NU1903); Microsoft.AspNetCore.OpenApi 10.x
requires Microsoft.OpenApi [2.7.5, 3.0.0). Reason also noted at the
reference in Cluckwork.Api.csproj. -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.12.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.*" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.*" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.*" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.16.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.*" />
<PackageVersion Include="Riok.Mapperly" Version="4.*" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.*" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.*" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.*" />
<!-- Transitive-pin override for GHSA-q939-rpr3-3284 via Testcontainers;
full rationale at the reference in Cluckwork.Api.IntegrationTests.csproj. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<PackageVersion Include="StackExchange.Redis" Version="3.*" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.*" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.*" />
<PackageVersion Include="Testcontainers.Redis" Version="4.*" />
<PackageVersion Include="xunit" Version="2.*" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.*" />
</ItemGroup>

</Project>
5 changes: 4 additions & 1 deletion docs/decisions/146-ci-security-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ CI fails a PR when a dependency carries a known **high+** advisory:
gates only fire on a PR or a push, so without this an advisory published
against a dependency nobody is touching goes unnoticed until the next PR.

**NuGet lock files.** `Directory.Build.props` sets `RestorePackagesWithLockFile`,
**NuGet lock files.** `Directory.Build.props` sets `RestorePackagesWithLockFile`
(and, since #684, `Directory.Packages.props` beside it holds every package
version under Central Package Management — the lock files are format version 2
from then on, which is the CPM lock format, not a resolution change),
so every project has a committed `packages.lock.json` and CI restores with
`--locked-mode` — restores are **deterministic**, and a dependency can't float to
a different resolved version between a green local run and CI. **When you add or
Expand Down
24 changes: 12 additions & 12 deletions src/Cluckwork.Api/Cluckwork.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,27 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<!-- Pin patched Microsoft.OpenApi: transitive 2.0.0 has GHSA-v5pm-xwqc-g5wc (NU1903) -->
<PackageReference Include="Microsoft.OpenApi" Version="2.12.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.*" />
<PackageReference Include="Serilog.AspNetCore" Version="10.*" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Serilog.AspNetCore" />
<!-- #404 — appsettings.Production.json names CompactJsonFormatter by
assembly-qualified type. Transitive via Serilog.AspNetCore today; this
states the dependency the config file relies on rather than inheriting
it silently. Note no test can enforce this: the assembly stays in the
graph transitively, so removing this line leaves the suite green. -->
<PackageReference Include="Serilog.Formatting.Compact" Version="3.*" />
<PackageReference Include="FluentValidation" Version="12.*" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.16.0-beta.1" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
</ItemGroup>

<ItemGroup>
Expand Down
9 changes: 6 additions & 3 deletions src/Cluckwork.Api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ WORKDIR /src
# BEFORE the restore layer, then restore with --locked-mode. This makes the image
# resolve the SAME deterministic NuGet graph CI enforces: a stale/hand-edited lock
# fails the build (NU1004) instead of silently floating to a different version.
# Directory.Build.props sets RestorePackagesWithLockFile, so it must precede
# restore. Only the SERVING projects are restored here (not the test projects),
# Directory.Build.props sets RestorePackagesWithLockFile and
# Directory.Packages.props carries every package VERSION (#684, Central Package
# Management), so both must precede restore — without the second the csproj
# files reference packages with no version and the restore fails (NU1010).
# Only the SERVING projects are restored here (not the test projects),
# so only their four lock files are needed. Copying just these inputs (not the
# full source tree) keeps the restore layer cached across source-only changes.
COPY Directory.Build.props ./
COPY Directory.Build.props Directory.Packages.props ./
COPY Cluckwork.sln ./
COPY src/Cluckwork.Domain/Cluckwork.Domain.csproj src/Cluckwork.Domain/packages.lock.json src/Cluckwork.Domain/
COPY src/Cluckwork.Application/Cluckwork.Application.csproj src/Cluckwork.Application/packages.lock.json src/Cluckwork.Application/
Expand Down
Loading
Loading