- C# 95%
- CSS 3.8%
- HTML 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
CI / build (push) Successful in 46s
`indent_style = tab` on the root rule, with the exceptions enumerated and each stating its own reason: YAML because a tab is illegal there, Markdown because a leading tab is a code block, JSON because the file is not ours alone to reformat, and SQL because a merged script is immutable under ADR-0021 — so tabs would reach only new scripts and leave the rule disagreeing with every script in the tree. The build-file `indent_size = 2` rule is deleted rather than updated. Under `indent_style = tab` with no `tab_width` of its own, that line would have made a tab render two columns wide in build files and four everywhere else — the setting would have changed meaning, not merely lost it. Whitespace only, checked independently rather than asserted. Review verified the string literals through the compiler — the `#US` heap, `Constant` blobs, `CustomAttribute` blobs and `#Strings` across all ten assemblies — which covers attribute arguments, const-field blobs and the Razor-generated C# that a source-level sweep never sees. The only differences are the commit SHA in `AssemblyInformationalVersion`, two `CallerFilePath` literals, and one whitespace-only text node in `MainLayout.razor` between the brand link and the nav: four spaces to one tab, inconsequential because `.page-header` is `display: flex` and nothing sets `white-space: pre*`. `dotnet format` does not touch the interior of a multi-line raw string literal, so the SQL bodies in `DatabaseSeeder.cs` and three test files keep 8-space indentation — the only spaces left in any `.cs` file. They render as before at `tab_width = 4`. Reindenting them by hand is precisely the operation that can silently alter a literal, so it was not done. Merged with an admin override: branch protection requires one approving review, which Forgejo will not accept on a self-authored pull request. |
||
| .forgejo/workflows | ||
| docs | ||
| src | ||
| tests | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| Directory.Build.props | ||
| docker-compose.yml | ||
| global.json | ||
| LICENSE | ||
| PlaceMark.slnx | ||
| README.md | ||
| user-secrets.example.json | ||
PlaceMark
PlaceMark is a web app for bookmarking geographical places — a set of coordinates with a name and description. Places are organised into groups, and groups can be shared with other users, each member holding a role within the group (Owner, Editor or Viewer). Every place belongs to exactly one group; each user gets a personal default group when their account is created.
The app is currently being built from a groomed backlog. Not all of the structure below is populated yet.
Architecture
Three deployables — a Blazor WebAssembly front end, an ASP.NET Core Minimal API, and PostgreSQL — live in a single solution:
| Project | Kind | Responsibility |
|---|---|---|
PlaceMark.Domain |
Class library | Entities and domain rules. References nothing. |
PlaceMark.Infrastructure |
Class library | Data access: Dapper over Npgsql, hand-written SQL, snake_case schema. |
PlaceMark.Database |
Console command and library | The schema itself: .sql scripts embedded as resources, applied by DbUp either by running the project or by calling it, plus the seed command that fills a development database. References nothing. |
PlaceMark.Contracts |
Class library | Request and response DTOs shared by the API and the front end. No project or package references. |
PlaceMark.Api |
ASP.NET Core Minimal API | HTTP endpoints, authentication, authorisation. |
PlaceMark.WebUI |
Blazor WebAssembly | User interface; maps via Leaflet + OpenStreetMap. |
Dependency direction
PlaceMark.Api → PlaceMark.Infrastructure → PlaceMark.Domain
PlaceMark.Api → PlaceMark.Contracts ← PlaceMark.WebUI
Project references enforce the direction itself: PlaceMark.Domain and PlaceMark.Contracts have
no project references at all, so anything pointing back at them from the layer above closes a cycle
and fails the build. PlaceMark.Contracts has no NuGet dependencies either — it is the wire format
and nothing else.
That is the only part the build enforces. Three further rules matter just as much, and nothing but review enforces any of them:
PlaceMark.DomainandPlaceMark.Contractsnever reference each other. Both are leaves, so an edge either way is perfectly acyclic and would compile — the build has nothing to object to. The domain models the problem; contracts model the wire. They change for different reasons and must be free to differ.- Infrastructure concerns stay out of the domain. Adding a NuGet package such as Dapper or
Npgsql to
PlaceMark.Domaincompiles perfectly happily. - Domain entities are never exposed over the wire. Endpoints accept and return
PlaceMark.Contractstypes and map to and from entities explicitly.PlaceMark.ApireferencesPlaceMark.Contractsdirectly and reachesPlaceMark.Domaintransitively throughPlaceMark.Infrastructure, so both are in scope and returning an entity straight from an endpoint would build without complaint.
PlaceMark.WebUI is a separate deployable and does not reference the server-side projects. It
talks to PlaceMark.Api over HTTP through a single typed HTTP client, sharing only
PlaceMark.Contracts with it so that both sides compile against the same request and response
shapes rather than against two copies that can drift apart.
Adding an API endpoint
Program.cs composes the API; it does not describe it. Every route lives in a feature folder under
src/PlaceMark.Api/, holding one static class with one extension method on IEndpointRouteBuilder
that opens a MapGroup for the feature's prefix and maps its routes inside it.
Health/HealthEndpoints.cs is the worked example — copy its shape:
namespace PlaceMark.Api.Places;
internal static class PlacesEndpoints
{
public static IEndpointRouteBuilder MapPlacesEndpoints(this IEndpointRouteBuilder endpoints)
{
var places = endpoints.MapGroup("/places");
// Combined with the group's prefix, this is GET /places/{id}.
places.MapGet("/{id:guid}", async (Guid id, CancellationToken cancellationToken) => …);
return endpoints;
}
}
Then add one line — app.MapPlacesEndpoints(); — beside the others in Program.cs. The group is
the point: a policy, filter or rate limit declared on it is inherited by every route added to the
feature afterwards, so it cannot be forgotten. Anything that applies to the API as a whole rather
than to one feature belongs in Program.cs, so the feature files stay ignorant of it.
The endpoint returns PlaceMark.Contracts types, never entities — see the dependency rules above.
Validating a request
Rules live on the contract, as System.ComponentModel.DataAnnotations attributes:
public sealed record CreatePlaceRequest(
[property: Required][property: StringLength(120)] string Name,
[property: Range(-90d, 90d)] double Latitude,
[property: Range(-180d, 180d)] double Longitude);
A group whose routes accept a body declares the filter that enforces them, once, and every route added to the group afterwards inherits it:
var places = endpoints.MapGroup("/places").AddEndpointFilter<RequestValidationFilter>();
That is the whole wiring. A request that breaks a rule is refused before the endpoint runs, with the
400 described below carrying an errors member naming each field that failed. Nothing checks that a
group remembers the line, so this is one of the rules review has to hold —
ADR-0025 explains why it is a group-level
convention rather than something the framework applies for us, and what that choice cost.
A rule no attribute can state — one that needs another row, or two fields compared — is checked in
the endpoint, which throws ValidationFailedException itself for the same response.
Failing a request
An endpoint that must refuse a request throws, and the pipeline turns the exception into an
RFC 9457 Problem Details response. Three exceptions in src/PlaceMark.Api/Errors/ name the status
they want:
throw new NotFoundException("No group with that id exists."); // 404
throw new ValidationFailedException("Latitude must be between -90 and 90."); // 400
throw new ConflictException("That user is already a member of the group."); // 409
Their message is sent to the caller, as the detail of the response body — that is what they
are for, and it makes the message part of the API's public surface. Never put an internal
identifier, a connection string or a database error in one.
ValidationFailedException takes an optional dictionary of field names and messages as well, which
the response carries as an errors member alongside the detail. The validation filter above fills
it in, keyed by the name each field travels under on the wire — [JsonPropertyName] and all — so a
client can match a message to the input it came from. An endpoint refusing a request for a reason
that belongs to no particular field leaves the dictionary out. Either way the body is the same
shape.
A body the framework cannot bind at all — {"latitude":"nope"} — reaches neither the filter nor the
endpoint, and needs nothing written for it: Program.cs sets ThrowOnBadRequest, so it arrives at
the same handler and is answered with the same 400. The detail says only that the request could
not be read. What went wrong goes to the log instead, and only ever as names: the parameter, and
the JSON path that failed to bind. The parser's own message quotes the payload text it choked on, so
it is deliberately not logged.
A Content-Type naming a character set that does not exist is refused earlier still, by a
middleware before the endpoints, because that one has to be caught on the way in: an endpoint
taking no body never looks at the header, so it would otherwise fault on its own and there would be
no way to tell that fault from the caller's bad header. ADR-0025 records the measurement.
The refusals still answered with a bare status code and no body are an unsupported Content-Type
(415) and a body over Kestrel's size limit (413), because the framework refuses both without
throwing — see ADR-0025 and ticket #138.
Every other exception becomes a 500 whose body carries a status, a type, a title and the
request's traceId, and nothing drawn from the exception itself. This is the same in
Development, and there is no developer exception page — the handler answers first, deliberately,
so that one failure shape is exercised everywhere. What the page would have shown is in the console
instead: the exception, with its stack trace, logged at Error under the same traceId the
response body quotes, so pasting that id into the log finds the record.
ADR-0022 records why, what it costs, and
why the handler sits immediately inside UseHttpLogging in Program.cs.
Repository layout
PlaceMark.slnx
src/
PlaceMark.Domain/
PlaceMark.Infrastructure/
PlaceMark.Database/
Scripts/
PlaceMark.Contracts/
PlaceMark.Api/
PlaceMark.WebUI/
tests/
PlaceMark.Domain.Tests/
PlaceMark.Infrastructure.Tests/
PlaceMark.Api.Tests/
PlaceMark.WebUI.Tests/
Architectural decision records live in docs/adr/. The database schema —
tables, columns, constraints and indexes, with the reasoning behind each — is
docs/data-model.md.
Naming conventions
- Projects are named
PlaceMark.<Area>, where<Area>is the layer or deployable (Domain,Infrastructure,Contracts,Api,WebUI). The project name, its folder name and its root namespace are always identical. - Production projects live under
src/; test projects live undertests/. - Every production project that contains behaviour has exactly one corresponding test project,
named
<Project>.Tests— for examplePlaceMark.Domainis tested byPlaceMark.Domain.Tests. There are two exceptions.PlaceMark.Contractsdeclares the wire format and holds no behaviour, so there is nothing to assert about it in isolation; it acquiresPlaceMark.Contracts.Testson the day it acquires logic, and not before.PlaceMark.Databaseis tested fromPlaceMark.Infrastructure.Tests, because what its scripts produce is only observable through a real PostgreSQL — and that project already owns the container fixture, which applies those same scripts for its own tests. A second test project would mean a second fixture and a second container to assert against the same schema. - Test classes are named
<ClassUnderTest>Tests, and test methods follow<MethodName>_<Scenario>_<ExpectedResult>(for exampleGetPlace_PlaceNotFound_ReturnsNull). - Folders within a project are named for the feature or concern they hold, not the technical
pattern — API endpoints are grouped per feature via
MapGroupextension methods rather than a monolithicProgram.cs. - British English spellings are used throughout identifiers and prose (
authorisation,initialise,colour). Third-party and framework symbols keep their own spelling. - Database objects use
snake_case, written that way in the schema scripts and bound to PascalCase members by Dapper'sMatchNamesWithUnderscores. - Architectural decisions are recorded as ADRs in
docs/adr/; see ADR-0001 for what warrants one.
Getting started
Requires the .NET 10 SDK. Building needs nothing else.
dotnet test now needs Docker, and so does the database: the integration tests
start a throwaway PostgreSQL through Testcontainers, on the
same image docker-compose.yml pins. They fail rather than skip when no Docker daemon is
reachable — a database test that quietly passes without a database is worth less than no test.
# Restore and build everything
dotnet build
# Run the whole test suite
dotnet test
# Run the API (from the repository root; needs a connection string — see Database, below)
dotnet run --project src/PlaceMark.Api
# Run the front end
dotnet run --project src/PlaceMark.WebUI
Database
PostgreSQL runs locally under Docker Compose v2 — no database needs installing on the host.
docker compose up -d --wait # start, and block until it accepts connections
docker compose down # stop, keeping the data
docker compose down -v # stop and destroy the data
The data lives in the named volume placemark_postgres-data, so it survives docker compose down
and reboots; only -v throws it away. Nothing starts the container for you, so run up again after
a reboot — deliberate, so that the repository does not hold port 5432 on days you are not using it.
It listens on 127.0.0.1 only, so it is not reachable from other machines. The database, username
and password all default to placemark, on port 5432:
Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark
That string reaches the API through user secrets, and the API now refuses to
start without it — so a fresh clone that has not applied user-secrets.example.json fails at
start-up with an instruction rather than on the first request that needs a row. Nothing connects at
start-up, though: the data source is built lazily and opens a connection when something asks for
one.
Nothing wraps a retry policy or a circuit breaker around Npgsql, deliberately. At
ADR-0007's one instance and one small database
the realistic failure is an outage or a wrong credential, which a retry turns from a fast error into
a slow one; the full argument, and what would have to change to reverse it, is in the comment beside
the registration in src/PlaceMark.Infrastructure/Database/DatabaseServiceCollectionExtensions.cs.
The schema — tables, columns, constraints, indexes and the reasoning behind each — is designed in
docs/data-model.md, created by DbUp from the .sql scripts in
src/PlaceMark.Database/Scripts/ (ADR-0021) and queried
with Dapper (ADR-0020).
The database above starts empty, so apply the scripts to it once after docker compose up —
PlaceMark.Database is a command as well as a library, and it is the same command CI and any
deployment run (ADR-0023):
# The connection string is the one printed above — change it here too if .env changes the port,
# the password or the database name.
ConnectionStrings__PlaceMark='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
dotnet run --project src/PlaceMark.Database
That variable is the only place this command looks. It reads no appsettings.json and no user
secrets, so ADR-0014's layering — which is the API's —
does not apply to it, and it is spelt out as a decision in
ADR-0023 rather than left as an oversight.
The reason is that this command alters a database: a layered lookup would let it run with nothing
set and silently upgrade whichever database some other layer named, which is a bad way to find out
which one you were pointed at. Unset, it does nothing and says which variable to set.
It prints the full resource name of each script it applied, or says the database is already up to
date, and exits non-zero naming the failing script if one fails. Run it again after pulling a
change that adds a script; running it when there is nothing to do is a no-op, because DbUp
journals what it has applied in the schemaversions table. To open a session:
docker compose exec postgres psql -U placemark -d placemark
Seed data for development
The same project has a second command, which fills a development database with something to look at: three users, a personal group each, one group they share carrying every role and an unanswered invitation, and eight places spread across both hemispheres (ADR-0026).
ConnectionStrings__PlaceMarkSeedTarget='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
dotnet run --project src/PlaceMark.Database -- seed
That is a different variable from the one above, deliberately. Nothing a host or a deployment already sets points this command anywhere, so seeding a database is an act of its own rather than one extra word typed in a shell that still holds production's credential. It is also the guard: the command refuses a database holding any account it did not create, so a database with real users in it is left alone.
Apply the schema first — the seed says so rather than reporting an undefined table if you have not.
Running it twice is safe and changes nothing: every identifier is a constant, so a second run
inserts nothing and reports that it did. It tops up rather than resets, though, so a seeded row
you have edited stays edited, and one you have replaced — a personal group deleted and recreated
under a new identifier, say — makes the next run fail on a unique index without writing anything;
docker compose down -v is how you start again. Note that the seeded accounts carry no password
hash — nothing hashes one yet — so they cannot sign in.
Nothing applies the schema when the API starts, deliberately — a database is upgraded by somebody running that command, not by a process restarting.
To change any of it — a port clash with a Postgres already on 5432, or a password you would rather
not run with — copy .env.example to .env and uncomment what you need; the file explains each
key. .env is gitignored, and the defaults live in docker-compose.yml, so a fresh clone works
without one. Postgres reads the credentials only when it first initialises the volume, so
changing them afterwards means docker compose down -v first, which destroys the existing data.
Configuration
appsettings.json and appsettings.Development.json are committed, so they hold only values that
are safe to publish. Every secret — the connection string today, signing keys and client secrets
when ADR-0002 is built — is supplied from outside the
working tree. The reasoning, and what was rejected, is in
ADR-0014.
Locally that means user secrets, which live in your profile — ~/.microsoft/usersecrets/ on Linux
and macOS, %APPDATA%\Microsoft\UserSecrets\ on Windows — and not in the repository, so they
cannot be committed at all.
user-secrets.example.json, in the repository root, lists what the API expects, already filled in
with the defaults the database above serves. Apply it unedited — do not copy it into
the project directory, for the reason in the warning below:
dotnet user-secrets set --project src/PlaceMark.Api < user-secrets.example.json
dotnet user-secrets list --project src/PlaceMark.Api # what is currently set
dotnet user-secrets remove "ConnectionStrings:PlaceMark" --project src/PlaceMark.Api
PowerShell has no < redirection, so on Windows pipe it instead:
Get-Content user-secrets.example.json | dotnet user-secrets set --project src/PlaceMark.Api
If you changed the credentials or port in .env, set the one value rather than editing the
template, so that your real connection string never exists inside the working tree:
dotnet user-secrets set "ConnectionStrings:PlaceMark" "Host=..." --project src/PlaceMark.Api
Where a value comes from
Configuration sources are read in this order, and later sources win:
| # | Source | Use it for |
|---|---|---|
| 1 | appsettings.json |
values that are the same everywhere and safe to publish |
| 2 | appsettings.{Environment}.json |
values that differ by environment and are safe to publish |
| 3 | User secrets — Development only | anything sensitive, on your own machine |
| 4 | Environment variables | anything sensitive, in a deployed environment |
| 5 | Command-line arguments | overriding something for a single run |
So an environment variable beats both files and your user secrets, and a command-line argument beats everything. Nest keys with a double underscore in an environment variable, and with a colon on the command line:
ConnectionStrings__PlaceMark='Host=...' dotnet run --project src/PlaceMark.Api
dotnet run --project src/PlaceMark.Api -- --ConnectionStrings:PlaceMark='Host=...'
Two things about that order are worth knowing before they surprise you:
- User secrets are only read when the environment is Development. Running locally with
ASPNETCORE_ENVIRONMENT=Productiondoes not merely change whichappsettingsfile applies — it drops the entire user secrets store, silently, and every secret reads back as null. That is also what makes it safe to keep a working password in the store: a deployed instance ignores it. - The five rows are the sources worth setting a value in, not the whole chain. A debug view also
shows
PlaceMark.Api.settings[.{Environment}].jsonprobed between rows 2 and 3, which PlaceMark does not use, and the host's own configuration chained in at the very end, which re-supplies theASPNETCORE_- andDOTNET_-prefixed variables and the command line that started the process. Neither changes where any key above comes from.
Deployed environments get their configuration from environment variables — the one mechanism
every host offers, and the reason nothing needs a committed file. A managed secret store that is
not exposed as environment variables would need its provider registering explicitly in
Program.cs; none is registered today.
Three ways to leak a secret
Nothing in the framework or the build will stop you doing any of these. They are conventions, enforced by review, and a credential that reaches the history is there permanently — it must be treated as compromised and rotated, not merely deleted:
- Putting a secret in
appsettings.jsonorappsettings.Development.json. It simply works — no warning, no error — and if your user secrets also set the key, your machine keeps using the secret and never shows you that the committed value is being used anywhere else. - Creating
src/PlaceMark.Api/secrets.json. User secrets belong in your profile, but this is not merely the wrong place for them: on a fresh clone it is a working one. The configuration system loads it, so the application runs, and the file is inside the repository — the.gitignoreentry forsecrets.jsonis what stops it being committed. The behaviour disappears as soon as you have run anydotnet user-secretscommand, so nobody who has used the tool can reproduce it or will catch it in review; ADR-0014 records exactly when it applies. Applyuser-secrets.example.jsonwith the command above rather than copying it anywhere. - Putting a secret in
PlaceMark.WebUI. The front end is WebAssembly: everything it ships, including anything underwwwroot, is downloaded to the browser and readable by the user. Configuration for the front end is therefore public by definition — an API base URL is fine, a key of any kind never is. Anything that must stay private stays inPlaceMark.Api.
Which browser origins may call the API
PlaceMark.WebUI is served from a different origin to PlaceMark.Api, so every call it makes is
cross-origin and the browser makes it only if the API has said that origin may. The allowlist is
configuration, under Cors:AllowedOrigins, and there is no wildcard in any environment:
| Environment | Where the origins come from |
|---|---|
| Development | src/PlaceMark.Api/appsettings.Development.json, already listing both ports the WebUI's launch profiles use |
| Deployed | Cors__AllowedOrigins__0, Cors__AllowedOrigins__1, … as environment variables |
An origin is a scheme, a host and a non-default port, and nothing else — https://placemark.uk,
not https://placemark.uk/. The policy compares it as text, so a trailing slash or a path matches
nothing; the API refuses to start on one rather than letting it present as a browser-only failure
weeks later. Configuring no origins at all is not an error: it means no browser may call the API,
and every other caller is unaffected.
Alongside the origins, the API permits four request headers — Authorization, Content-Type,
traceparent and tracestate — and no credentials, since authentication is a bearer token rather
than a cookie. ADR-0024 explains each of
those, and why the trace context headers matter to the logging below.
Logging
Both applications log through the built-in Microsoft.Extensions.Logging, to the console and
nowhere else. There is no file, no log server and no OpenTelemetry collector — the reasoning, and
what would justify adding one, is in
ADR-0015.
The API logs one record per request, with the method, path, status code and duration:
info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
=> SpanId:ec4ced0dfbc0f6a8, TraceId:5f287f0daad95f34ce00346c7ee240d0, ParentId:0000000000000000 => ...
Request and Response:
Method: GET
Path: /places/42
StatusCode: 404
Duration: 2.0788
Deployed environments get the same records as one JSON object per line instead, because
appsettings.json sets Logging:Console:FormatterName to json and
appsettings.Development.json overrides it to simple. That is the whole of the difference, and
neither is expressed in code.
TraceId is the correlation id. It comes from the W3C Trace Context traceparent header:
send one and the API adopts it, so every record it writes about that request — including the stack
trace of anything that fails — carries the caller's id. Nothing else needs to be threaded through,
and there is deliberately no X-Correlation-Id.
curl -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
http://localhost:5017/places/42
The front end does not send one yet, so a click in the browser cannot currently be traced to the request it causes. It is now allowed to: the header is on the API's CORS allowlist, which ADR-0015 named as one of the two things in the way. The other is the typed HTTP client that has still to be written, which is where the header would be attached.
Levels come from the Logging:LogLevel section, so they follow the same precedence as everything
else above — an environment variable beats the settings files:
Logging__LogLevel__Default=Debug dotnet run --project src/PlaceMark.Api
Writing a log statement
logger.LogInformation("…") will not compile. CA1848 is an error here
(ADR-0013), so every log statement goes
through the [LoggerMessage] source generator, which builds the delegate once instead of
formatting the message on every call:
using Microsoft.Extensions.Logging;
namespace PlaceMark.Api.Places;
internal static partial class PlaceLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "Place {PlaceId} added to group {GroupId}")]
public static partial void PlaceAdded(ILogger logger, Guid placeId, Guid groupId);
}
PlaceLog.PlaceAdded(logger, place.Id, group.Id);
Both the class and the method must be partial, and the method must be static and return
void; the generator writes the body. The named placeholders become the record's structured
fields, so PlaceId is queryable in the JSON output rather than buried in a sentence — which is
the point of the message template, not merely its syntax.
Two things that will cost you an afternoon
Microsoft.AspNetCoreis set toWarning, which would swallow the request log, since the middleware writes it atInformation.appsettings.jsontherefore namesMicrosoft.AspNetCore.HttpLoggingexplicitly. Delete that line and request logging stops, with no error and nothing in the output to say why.- Silencing
Microsoft.AspNetCore.Hostingsilences the correlation id, everywhere, including on the exception records. ASP.NET Core only starts a requestActivitywhen something is listening for it, and that logger being enabled is one of the things it counts — so setting the category toNoneleaves the request log intact but stripsTraceIdfrom every line, even when the caller supplied atraceparent. A test covers it.
And one for the front end: it reads wwwroot/appsettings.json, which is downloaded by the
browser. Log levels are all it may ever hold — see
ADR-0014. A deployed client defaults to Warning so
it stays quiet in a console the user can read; wwwroot/appsettings.Development.json turns it up,
and is fetched only when the .NET dev server tells the client it is running in Development.
Continuous integration
Every pull request to main, and every merge into it, runs
.forgejo/workflows/ci.yml on a Forgejo Actions runner: restore,
build in Release, test, apply the schema scripts to a throwaway PostgreSQL, then verify formatting
— five steps in a single job, in that order. Any of them failing fails the run, and a failing run
blocks the merge.
The last is the one that catches people out, because it fails on things the compiler is perfectly happy with: layout the formatting rules disagree with, or a file saved with a UTF-8 byte order mark. It is worth running before you push:
dotnet format --verify-no-changes
What the schema step does and does not prove
It starts a PostgreSQL service container, applies the scripts to it through the same command a
deployment runs, and then runs that command a second time. The first run must apply at least one
script under the journal key prefix PlaceMark.Database.Scripts. — a rename of the project, its
root namespace or the Scripts folder re-runs every script against a database that already has
them (ADR-0021), and this is the second thing to catch it,
after the unit test that pins the exact resource name — and the second run must find nothing
pending.
So a script that does not apply cleanly from empty fails on the pull request that introduces it. What the step does not do is deploy: there is no test environment and no production for it to reach, so nothing here proves a script applies to a database that already holds a previous schema and real data. ADR-0023 records why that half of the ticket was left unbuilt rather than written against environments that do not exist.
Why the workflow looks the way it does
It is written entirely out of run: steps, checking the repository out with git by hand and
installing the SDK with Microsoft's dotnet-install.sh. That is not a stylistic preference. The
runner on this instance is act_runner v6.4.0, which cannot execute Forgejo Actions actions at
all — it clones the action, then fails the step with no error message, and the post step reports
the action's own dist/index.js missing from the container. Five runs eliminated the node runtime
version, the host, the cache directory and the uses: syntax in turn; the header comment in the
workflow records what was ruled out, so nobody has to repeat it.
Two costs follow, and neither is small:
- There is no NuGet cache. This is not a shortcut taken for simplicity — the runner's cache
server is reachable only through the
actions/cacheprotocol, so with actions unusable the cache is unreachable by any means. Every run therefore pulls roughly 240 MB of packages, most of it the Blazor WebAssembly runtime packs. - The SDK is downloaded and installed on every run, because there is no tool cache in the job
container and no
setup-dotnetto populate one.
Together these make a run take minutes where it should take seconds. Both are recovered by a
single revert — back to actions/checkout, actions/setup-dotnet and actions/cache — the day
the runner is upgraded past this defect. Until then the pipeline is correct but slow, which is the
right way round.
Contributing
See CONTRIBUTING.md. main is protected: all changes arrive through a reviewed
pull request.
Licence
Released under the MIT Licence — see LICENSE.