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
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,20 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
# One PR for everything, not one per package. Every NuGet bump that touches
# Application or Infrastructure invalidates the packages.lock.json of Api and
# Tests too, and Dependabot only refreshes the lock file of the project whose
# .csproj it edited — so each PR needs a manual `--force-evaluate` pass (see
# scripts/refresh-lockfiles.ps1). Grouping makes that chore weekly, not per-package.
# Majors stay in their own group so they remain reviewable on their own merits
# instead of being buried in a 13-package diff.
groups:
dotnet-minor-patch:
patterns: ["*"]
update-types: [minor, patch]
dotnet-major:
patterns: ["*"]
update-types: [major]
labels: [dependencies, dotnet]
commit-message:
prefix: deps
Expand Down
103 changes: 103 additions & 0 deletions .github/workflows/refresh-lockfiles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Refresh lock files

# Regenerates the backend packages.lock.json files on a pull request and pushes the
# result back to its branch. Exists because Dependabot only refreshes the lock file of
# the project whose .csproj it edited, so any bump touching Application or Infrastructure
# leaves Api/Tests stale and ci.yml's `dotnet restore --locked-mode` fails with NU1004.
# Same job as scripts/refresh-lockfiles.ps1, but it doesn't need an SDK on anyone's laptop.
#
# Two constraints shaped this into a manually-dispatched workflow rather than an automatic
# fix-up on `pull_request`:
#
# 1. Workflow runs triggered *by Dependabot* get a read-only GITHUB_TOKEN and no access
# to secrets, so a `pull_request`-triggered job could never push the fix.
# workflow_dispatch runs in the base-branch context, with a writable token.
# 2. A push made with GITHUB_TOKEN does not raise new workflow events, so ci.yml would
# keep showing its old failure. The last step therefore re-triggers the required
# checks explicitly via `gh pr update-branch` — which also satisfies the strict
# up-to-date-branch rule on main that these PRs need anyway.
#
# Usage: gh workflow run refresh-lockfiles.yml -f pr=<number>

on:
workflow_dispatch:
inputs:
pr:
description: "Pull request number whose lock files should be refreshed"
required: true
type: string

# Unlike ci.yml (which stays contents: read), this workflow exists to commit.
permissions:
contents: write
pull-requests: write

jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Check out the PR's head branch by name (not the merge ref) so the refreshed
# lock files can be pushed straight back to it.
ref: refs/pull/${{ inputs.pr }}/head
# A detached merge ref can't be pushed; resolve the real branch below.
fetch-depth: 0
persist-credentials: true

- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x

- name: Resolve the PR's head branch
id: pr
env:
GH_TOKEN: ${{ github.token }}
run: |
branch=$(gh pr view "${{ inputs.pr }}" --json headRefName -q .headRefName)
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Refreshing lock files on $branch"

# --force-evaluate re-resolves every project graph and rewrites every lock file,
# which is exactly what --locked-mode in ci.yml then verifies.
- name: Restore with --force-evaluate
run: dotnet restore CoffeeTracker.sln --force-evaluate

# A changed .csproj would mean the restore resolved something the bump didn't
# intend, so refuse rather than commit it. Mirrors scripts/refresh-lockfiles.ps1.
- name: Reject changes outside the lock files
run: |
unexpected=$(git status --porcelain | awk '{print $2}' \
| grep -v -E '^backend/[^/]+/packages\.lock\.json$' || true)
if [ -n "$unexpected" ]; then
echo "::error::Restore changed files other than the lock files:"
echo "$unexpected"
exit 1
fi

- name: Commit and push
id: commit
env:
BRANCH: ${{ steps.pr.outputs.branch }}
run: |
if git diff --quiet -- 'backend/*/packages.lock.json'; then
echo "Lock files already consistent; nothing to push."
echo "pushed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add backend/*/packages.lock.json
# Two -m flags rather than one multi-line string: a YAML block scalar would
# carry this step's indentation into the commit message body.
git commit -m "deps: refresh packages.lock.json after the NuGet bump" -m "Dependabot updates only the lock file of the project it edited; Api and Tests reference Application/Infrastructure transitively and go stale, which fails ci.yml's restore --locked-mode with NU1004."
git push origin "HEAD:$BRANCH"
echo "pushed=true" >> "$GITHUB_OUTPUT"

# A GITHUB_TOKEN push raises no workflow events, so the required checks would still
# show the pre-fix failure. Updating the branch creates a new head and re-runs them.
- name: Re-trigger the required checks
if: steps.commit.outputs.pushed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh pr update-branch "${{ inputs.pr }}"
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ Development happens inside a dev container, so the only host prerequisites are
`http://localhost:4200` (both forwarded automatically). `http://localhost` is a
secure context, so the PWA service worker and camera work without HTTPS in dev.

### Backend dependency bumps

`backend/Directory.Build.props` enables NuGet lock files and CI restores with
`--locked-mode`, so a bump can't land without a reviewed `packages.lock.json`. NuGet keeps
one lock file per project, so changing a package in `Application` or `Infrastructure` also
invalidates the ones in `Api` and `Tests` and the restore fails with **NU1004**. Refresh
them all with:

```powershell
./scripts/refresh-lockfiles.ps1 # rewrites the lock files (--force-evaluate)
./scripts/refresh-lockfiles.ps1 -Check # just reproduce the CI restore (--locked-mode)
```

It uses a local .NET SDK if you have one and the pinned SDK container otherwise, so it
works on a bare host too. Dependabot hits this on every backend PR — refresh those without
a local checkout via `gh workflow run refresh-lockfiles.yml -f pr=<number>`.

## Install on Unraid (or any Docker host)

The published image is **public** at `ghcr.io/thomas-lg/coffee-tracker`. On Unraid,
Expand Down
123 changes: 123 additions & 0 deletions scripts/refresh-lockfiles.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<#
.SYNOPSIS
Regenerates the backend packages.lock.json files after a NuGet version change.

.DESCRIPTION
backend/Directory.Build.props sets RestorePackagesWithLockFile, and CI restores
with --locked-mode (see .github/workflows/ci.yml) so a dependency bump cannot land
without a reviewed lock file. NuGet writes one lock file per project, so bumping a
package in Application or Infrastructure also invalidates the lock files of Api and
Tests, which reference them transitively. Dependabot only refreshes the lock file of
the project whose .csproj it edited, so its PRs fail with:

error NU1004: The project references coffeetracker.infrastructure whose
dependencies has changed. The packages lock file is inconsistent with the
project dependencies so restore can't be run in locked mode.

This script is the fix: a solution-wide `dotnet restore --force-evaluate`, which
re-resolves every graph and rewrites every lock file. It runs natively when a .NET
SDK is on PATH, and otherwise inside the same SDK container image the Dockerfile
uses, so it works on a host with no SDK installed as well as in the dev container.

It refuses to leave behind changes to anything other than the lock files: a modified
.csproj would mean the restore resolved something the version bump did not intend.

.PARAMETER Check
Verify only. Runs the CI restore (--locked-mode) and fails if the lock files are
inconsistent, without rewriting anything.

.EXAMPLE
./scripts/refresh-lockfiles.ps1
Refresh the lock files, then `git add backend/*/packages.lock.json` and commit.

.EXAMPLE
./scripts/refresh-lockfiles.ps1 -Check
Reproduce the CI restore locally to confirm the committed lock files are consistent.
#>
[CmdletBinding()]
param(
[switch]$Check
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$solution = 'CoffeeTracker.sln'
$restoreArgs = if ($Check) { '--locked-mode' } else { '--force-evaluate' }

# Keep this digest in step with the SDK stage in ./Dockerfile so a local refresh
# resolves against the same SDK (and therefore the same bundled NuGet) as the image.
$sdkImage = 'mcr.microsoft.com/dotnet/sdk:10.0@sha256:ed034a8bf0b24ded0cbbac07e17825d8e9ebfe21e308191d0f7421eaf5ad4664'

function Test-Sdk {
$dotnet = Get-Command dotnet -ErrorAction SilentlyContinue
if (-not $dotnet) { return $false }
# The dotnet *host* is present on many machines without any SDK; only an SDK can restore.
$sdks = & $dotnet.Source --list-sdks 2>$null
return [bool]$sdks
}

# Hash every tracked file under backend/ so we can tell exactly what the restore
# touched. Comparing `git status` before/after would misfire: the .csproj edits that
# prompted the refresh are themselves uncommitted in the normal workflow, and unrelated
# work elsewhere in the tree is none of this script's business.
function Get-BackendHashes {
$hashes = @{}
foreach ($file in & git ls-files backend) {
if (Test-Path -LiteralPath $file -PathType Leaf) {
$hashes[$file] = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
}
}
return $hashes
}

Push-Location $repoRoot
try {
$before = if ($Check) { $null } else { Get-BackendHashes }

if (Test-Sdk) {
Write-Host "Restoring $solution with a local SDK ($restoreArgs)..."
& dotnet restore $solution $restoreArgs
}
else {
Write-Host 'No .NET SDK on PATH; falling back to the SDK container.'
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'Neither a .NET SDK nor docker is available. Install the .NET 10 SDK or start Docker.'
}
Write-Host "Restoring $solution in $sdkImage ($restoreArgs)..."
& docker run --rm -v "${repoRoot}:/src" -w /src $sdkImage dotnet restore $solution $restoreArgs
}
if ($LASTEXITCODE -ne 0) { throw "dotnet restore failed with exit code $LASTEXITCODE." }

if ($Check) {
Write-Host 'Lock files are consistent with the projects.' -ForegroundColor Green
exit 0
}

# Restore rewrites the lock files on every run, so timestamps prove nothing; compare
# content hashes to see what actually changed as a result of THIS restore.
$after = Get-BackendHashes
$changed = @(
$after.Keys | Where-Object { -not $before.ContainsKey($_) -or $before[$_] -ne $after[$_] } | Sort-Object
)
$unexpected = @($changed | Where-Object { $_ -notmatch '^backend/[^/]+/packages\.lock\.json$' })

if ($unexpected.Count -gt 0) {
Write-Host 'The restore changed files other than the lock files:' -ForegroundColor Red
$unexpected | ForEach-Object { Write-Host " $_" -ForegroundColor Red }
throw 'Refusing to continue. A .csproj changed by the restore means it resolved something unintended.'
}

if ($changed.Count -eq 0) {
Write-Host 'Lock files were already up to date; nothing to commit.' -ForegroundColor Green
}
else {
Write-Host 'Refreshed:' -ForegroundColor Green
$changed | ForEach-Object { Write-Host " $_" -ForegroundColor Green }
Write-Host 'Commit these, e.g. git commit -am "deps: refresh packages.lock.json"'
}
}
finally {
Pop-Location
}
Loading