Development: environment, build, test, release
Everything a person needs to get this repository building and to know that
what they changed still works. For what to build next see TASKS.md; for
the rules a change has to respect see the CLAUDE.md files.
1. How the repository is split
| Crate | Builds on | Why |
|---|---|---|
shared/ | any OS | Protocol types only (serde). No I/O. |
server/ | any OS (deployed on Linux) | axum + tokio + libSQL. Normal async stack. |
installer/ | Windows, MSVC target only | Win32 GUI + WinHTTP, under a hard 700 kB gate. |
tools/mdbook-repo-links/ | any OS | The documentation book’s preprocessor (§3.1). |
installer/ is deliberately excluded from the Cargo workspace, so a
plain cargo build at the root builds shared + server and nothing else.
That is why the installer has its own CI job, its own lint run and its own
test runner. Do not “fix” the exclusion.
Practical consequence: two target/ directories, and two places to run
cargo. The root, and installer/.
2. Prerequisites
2.1 Windows (installer work)
-
Rust — https://rustup.rs →
rustup-init.exe, accept the defaults (stable-x86_64-pc-windows-msvc). -
MSVC build tools — rustup offers to install them; otherwise Visual Studio Build Tools with the “Desktop development with C++” workload. Needed for
link.exeand for the resource compiler thatembed-resourcedrives overres/installer.rc. -
PowerShell 7 (
pwsh) — every script underinstaller/tests/is run aspwsh -File …and they invoke each other the same way. Windows PowerShell 5.1 is not enough. -
A desktop session — the GUI tests drive real dialogs. They cannot run over a service account, a container, or an SSH session with no desktop.
-
installer/.env, before the first build — the server the installer talks to by default comes from this file, which git ignores so that no deployment’s address is ever committed. Without itcargo buildandcargo testboth fail.cd installer Copy-Item .env.example .env # http://127.0.0.1:8080, a local serverFor a release build, set
PACENOTES_DEFAULT_SERVERin that file to the public HTTPS address. The release workflows write it from the repository variable of the same name, and fail when that variable is unset.--server <url>still overrides it at run time.
The Windows box also builds pacenotes-server: two of the installer tests
start a real server to talk to. Nothing extra to install for that — but it
means a server-side dependency that breaks on Windows breaks the installer
suite too (this has happened; see §8).
2.2 Linux (server and shared work)
sudo apt update && sudo apt install -y build-essential pkg-config git
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # defaults
source "$HOME/.cargo/env"
2.3 Editor
rust-lang.rust-analyzer, plus ms-vscode-remote.remote-ssh if you work on
both machines. Open the repo locally on Windows for installer work and
over Remote-SSH on Linux for server work; keep them in sync through git.
Editing one checkout over SMB/SSHFS is miserable — rust-analyzer and
target/ over a network mount are slow enough to change how you work.
3. Building
# Linux or Windows — shared + server
cargo build
cargo test
# Windows only — the installer
cd installer
cargo build --target x86_64-pc-windows-msvc # debug
.\tests\Test-BinarySize.ps1 # release + size gate
Test-BinarySize.ps1 is the normal way to make a release build: it builds
and then fails if the .exe is over 700 kB or imports a DLL Windows does not ship. Check the size after every
installer change — the budget is the reason the installer has no HTTP
crate, no TLS crate and no GUI framework.
3.1 The documentation site
docs/ is an mdBook that CI publishes to GitHub Pages
(.github/workflows/docs.yml): every Markdown document in the repository,
the architecture diagrams, and the rustdoc API reference. Build the whole
site locally with
docs/build.sh # Git Bash on Windows, or any shell elsewhere
and open docs/book/index.html. It needs, besides cargo:
- D2 — one self-contained binary from its
releases page. It renders
docs/diagrams/*.d2to SVG. The SVGs are build output and never committed. - mdBook — one binary from its
releases page, or
cargo install mdbook.
CI pins both versions in docs.yml; use the same ones.
How the book is put together, so a change lands in the right place:
- A document lives where it always did. Most chapters under
docs/src/are a single line using mdBook’sincludedirective on the original, at the same relative path (docs/src/installer/DESIGN.mdincludesinstaller/DESIGN.md), so the links between documents work in the book as they do on GitHub. A new document gets such a chapter and an entry indocs/src/SUMMARY.md. - Links to source files cannot be book pages;
tools/mdbook-repo-linkspoints each one at the file on GitHub when the book is built. - The architecture chapters live in
docs/src/architecture/, and their diagrams indocs/diagrams/. Change a diagram when the structure it shows changes. - The API reference is rustdoc with private items. The installer’s half
builds only on Windows; elsewhere
docs/build.shleaves it out unlessINSTALLER_APInames a directory holding it. Its default server address is always.env.example’s: the script setsPACENOTES_ENV_FILE, because rustdoc prints the address and a published page must never carry a real deployment’s. - There is no Python anywhere in this project, the documentation included.
4. Testing
4.1 The installer: one entry point
cd installer
pwsh -File tests\Run-AllTests.ps1 # everything
pwsh -File tests\Run-AllTests.ps1 -SkipUi # headless: steps 1-6 only
Eight steps, in order, and what each one is actually for:
| # | Step | What it proves |
|---|---|---|
| 1 | cargo fmt --check | The installer is outside the workspace, so the root CI job never formats it. |
| 2 | cargo clippy | Same, under -D warnings exactly as CI does. |
| 3 | cargo test | The pure modules: game detection, the two scene models, sha256, the cache, the run log, size formatting. No Windows needed; CI runs these. |
| 4 | Test-ReleaseVersion.ps1 | Builds from a v/X.Y.Z tag and from none, and reads the version back out of the binary: file properties and user agent. Then checks every malformed tag fails the build (§6). |
| 5 | Test-BinarySize.ps1 | The 700 kB release gate, and that the exe needs no Visual C++ redistributable or any other DLL Windows does not ship. Also produces the .exe the next three steps run. |
| 6 | Test-HttpClient.ps1 | Builds and starts a real pacenotes-server, then drives the installer’s --selftest-http against it: health, anonymous vs bearer, 401/403/404, unreachable, a body over the 8 MiB buffering cap (from a loopback listener: the real server never sends one), and --selftest-download through a stand-in that fails it once (retried) or for good (not), and that the server’s access log names this build’s user agent. WinHTTP cannot be unit tested, so this is the only honest check of it. Needs no desktop, so CI runs it. |
| 7 | ui\Test-UiLayout.ps1 | Walks scenes 1-3 against a deliberately dead server (--server http://127.0.0.1:1), once per shipped language, asserting every caption against what res/installer.rc defines. Also a folder this account cannot write (scene 3 must stop and say why), a restart as administrator resuming on scene 3, and the keyboard: where focus starts, scene 3’s Tab order and its Enter, through the dialog manager. |
| 8 | ui\Test-WizardDownload.ps1 | Walks the whole wizard, scenes 1-7, against a live server holding a nested catalogue, with real downloads into a throwaway cache. Asserts both gates, the lists, the summary, the cache on disk and the run log’s header and times. Later passes: Configure, a route switch, Restore, a failing /health, downloads that fail once or for good, and the game running from this folder or another. |
Steps 7 and 8 need an interactive desktop. CI skips them; run them locally before anything that touches the UI.
Two more scripts, neither of them gates:
-
tests\Test-PublicEndpoint.ps1— MANUAL CHECK against the real deployment over HTTPS. It is the only test that takes theWINHTTP_FLAG_SECUREbranch and exercises the OS TLS stack, which is the justification for shipping no TLS crate. Run it when the deployment changes. A red result often says something about the server, not the code. -
tests\ui\Get-UiScreenshot.ps1— AGENT / DEVELOPER AID. Captures a PNG of each scene so a person (or an agent) can look at it. Asserts nothing and always exits 0. Assertions cannot see a clipped label, two overlapping controls or a dead area; a z-order bug once survived a fully green run and was caught only here. Use it whenever you change geometry or add a scene.# scenes 1-5 offline, English then Polish pwsh -File tests\ui\Get-UiScreenshot.ps1 pwsh -File tests\ui\Get-UiScreenshot.ps1 -Language 1 # scenes 6-7 need a real catalogue AND really download it pwsh -File tests\ui\Get-UiScreenshot.ps1 -Scenes 7 ` -Server https://[Replace with IP or domain] -CacheDir $env:TEMP\shotcache
Before you add a test, read installer/tests/README.md. It carries two
rules (no arbitrary inputs; expected text comes from the resource, not from a
copy) and a numbered list of traps in driving Win32 from PowerShell — each
one cost real debugging time, and each is commented at its call site.
4.2 The server and shared
cargo test # unit tests + server/tests/api.rs
cargo clippy --workspace --all-targets
cargo fmt --all -- --check
server/tests/api.rs is the auth matrix: anonymous, a credential with no
grants, one directory, several; plus expired, revoked, ungranted, 404 and
traversal attempts.
5. Running things by hand
5.1 A local server
mkdir -p data/rbr/packages/public/style data/motd
cp something.zip data/rbr/packages/public/style/Descriptive.zip
echo "hello" > data/motd/en.txt
cargo run -p pacenotes-server -- manifest gen --data ./data
cargo run -p pacenotes-server -- serve --listen 127.0.0.1:8080 --data ./data
curl http://127.0.0.1:8080/health # -> ok
--db-url is independent of --data: it defaults to file:./data/auth.db,
relative to wherever you ran the command, so a serve started from the
repository root drops an auth.db there whatever --data says. /data/ is
gitignored for that reason — never commit one, it is a credential store.
The folder tree is the installer’s UI tree — public/ is an access
directory and a level of its own, and the segments below it become category,
sub-levels and leaf. manifest gen prints one line per package and then its
complaints (mis-filed levels, a name that needs percent-encoding, a
dependency that does not exist). Read them. The full layout, access keys and
MOTD are in deploy/README.md.
Credentials, when you need a private directory:
cargo run -p pacenotes-server -- token mint --label "dev" --grant premium
cargo run -p pacenotes-server -- token list
A key is printed once; the server stores only its sha256. The credential
store defaults to ./data/auth.db, and --db-url belongs to token, before
the subcommand (token --db-url file:… mint …) — putting it after mint
is a usage error.
5.2 The installer against that server
cd installer
$exe = "target\x86_64-pc-windows-msvc\release\pacenotes-installer.exe"
& $exe --server http://127.0.0.1:8080 --cache $env:TEMP\pcn-cache
| Flag | For |
|---|---|
--server <url> | Point at a local or staging server. Default is the public HTTPS endpoint. |
--cache <dir> | A throwaway download cache. Use it for anything test-shaped, or you read back whatever a previous run left in your own profile. |
--selftest-http <url> [--token T] [--out FILE] | One real GET through http.rs, reported to a file or a message box. |
--selftest-download <game> [--largest] [--server U] [--token T] [--out F] | Fetch a catalogue and download its smallest (or largest) package: stream, hash, verify, promote, index. |
--selftest-report <game> [--server U] [--out F] | POST a fabricated conflict report through api::report_conflicts. The only thing the installer ever sends, and a real one needs a catalogue where two packages write the same file. |
--selftest-panic [--out F] | Panic on purpose, with the crash window suppressed, to prove the panic hook writes a report naming a source file and line. |
The four --selftest-* flags exist because the paths they cover are the ones
most likely to be wrong against a real server, and walking the GUI to reach
them makes every attempt slow and flaky. Plain HTTP is fine for development;
WinHTTP only negotiates TLS when the URL says https.
Where a run leaves things, when --cache is not given:
<game dir>\.pacenotes\cache\<sha256>.zip verified payloads
<game dir>\.pacenotes\cache\cache.json what each one is
<game dir>\.pacenotes\logs\install-<stamp>.log one log per run
<game dir>\.pacenotes\installed.json the ledger
<game dir>\.pacenotes\backup\, restore\<stamp>\ what was replaced
Everything, under the folder the installer was pointed at. Deleting the
game folder removes every trace of the tool. --cache <dir> overrides the
cache location for development; nothing else can be moved, and nothing is
written to %LOCALAPPDATA% or anywhere else in the profile.
6. Releasing
A release is a pushed tag, and the tag is the only place a version is written. Nothing is bumped by hand:
git tag v/1.2.3
git push origin v/1.2.3
cd-release-on-tag.yml builds from it. The tag reaches the build as
PACENOTES_RELEASE_TAG, and installer/build/release_version.rs turns it
into everything that shows a version: the file properties (all four
VERSIONINFO fields, through a header it writes for res/installer.rc), the
user agent the server logs, and the version a conflict report carries.
- The shape is exactly
v/X.Y.Z, each part 0..=65535 (VERSIONINFO stores 16-bit words). Anything else —v1.2.3,v/1.2,v/1.2.3-rc1— FAILS THE BUILD, so a mistyped tag cannot become a release. - Without the variable a build is a development one,
0.0.0-dev. That is every local build, every CI run and the debug-build workflow, and it is how a dev build is told apart in the server’s access log. installer/Cargo.tomlsaysversion = "0.0.0"and nothing reads it.Test-ReleaseVersion.ps1builds from real tags and reads the version back out of the binary; CI runs it.
| Workflow | Trigger | Result |
|---|---|---|
ci.yml | push / PR to master | shared+server on Linux, installer on Windows (fmt, clippy, tests, release version, size gate, HTTP client) |
cd-release-on-tag.yml | a v/* tag is pushed | Release build, checks the .exe carries the tag’s version, publishes it as the GitHub release of that tag |
cd-debugbuild-manually.yml | workflow_dispatch | Debug .exe + .pdb as a build artifact, no release |
Merging into master publishes nothing; tagging does.
Deploying the server is a separate thing and lives in
deploy/README.md: a Podman Quadlet that builds straight from git, plus a
Caddy site block for TLS.
7. Conventions
- Follow the task lists.
TASKS.md(root milestones) →installer/TASKS.mdandserver/TASKS.md. Tick items as you go. - One commit per task-list item, not per file touched. Say in the message what changed and why, including what you decided not to do.
- Record the installer’s size in
installer/TASKS.mdafter a change that moves it; the table there is the history of the budget. - A protocol change is
shared/first, then both sides, in the same PR. - Prefer std over a dependency everywhere, and especially in
installer/. - Read the
CLAUDE.mdin the directory you are about to change. They carry the rules that are not obvious from the code — the size budget, the “installer never interprets package content” rule, the auth model, and the reason the folder layout on the server is the UI tree.
8. Things that will bite you
The installer suite builds the server, so a Linux-only dependency breaks
Windows. libsql once lost the feature that transitively enabled
libsql-sys/rusqlite, and without it libsql-sys compiles
std::os::unix::ffi::OsStrExt — on Windows. server/Cargo.toml now declares
libsql-sys with rusqlite explicitly to pin that down.
RUSTFLAGS leaks between crates. Run-AllTests.ps1 sets -D warnings
to match CI, and saves/restores it around the run — a stray value forces a
full rebuild of the server’s dependency tree and applies the installer’s lint
policy to a crate with a different one.
cargo test at the root does not test the installer, and
cargo clippy --workspace does not lint it. That is what steps 1-3 of the
runner are for.
The GUI tests are not optional for UI work. They are the only thing that
catches a caption bound to the wrong id, a gate that does not gate, or a
control that never got its text. But they are also not sufficient: run
Get-UiScreenshot.ps1 and actually look at the result.