Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CrateBuilds onWhy
shared/any OSProtocol types only (serde). No I/O.
server/any OS (deployed on Linux)axum + tokio + libSQL. Normal async stack.
installer/Windows, MSVC target onlyWin32 GUI + WinHTTP, under a hard 700 kB gate.
tools/mdbook-repo-links/any OSThe 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)

  1. Rust — https://rustup.rs → rustup-init.exe, accept the defaults (stable-x86_64-pc-windows-msvc).

  2. MSVC build tools — rustup offers to install them; otherwise Visual Studio Build Tools with the “Desktop development with C++” workload. Needed for link.exe and for the resource compiler that embed-resource drives over res/installer.rc.

  3. PowerShell 7 (pwsh) — every script under installer/tests/ is run as pwsh -File … and they invoke each other the same way. Windows PowerShell 5.1 is not enough.

  4. 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.

  5. 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 it cargo build and cargo test both fail.

    cd installer
    Copy-Item .env.example .env       # http://127.0.0.1:8080, a local server
    

    For a release build, set PACENOTES_DEFAULT_SERVER in 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/*.d2 to 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’s include directive on the original, at the same relative path (docs/src/installer/DESIGN.md includes installer/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 in docs/src/SUMMARY.md.
  • Links to source files cannot be book pages; tools/mdbook-repo-links points each one at the file on GitHub when the book is built.
  • The architecture chapters live in docs/src/architecture/, and their diagrams in docs/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.sh leaves it out unless INSTALLER_API names a directory holding it. Its default server address is always .env.example’s: the script sets PACENOTES_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:

#StepWhat it proves
1cargo fmt --checkThe installer is outside the workspace, so the root CI job never formats it.
2cargo clippySame, under -D warnings exactly as CI does.
3cargo testThe pure modules: game detection, the two scene models, sha256, the cache, the run log, size formatting. No Windows needed; CI runs these.
4Test-ReleaseVersion.ps1Builds 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).
5Test-BinarySize.ps1The 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.
6Test-HttpClient.ps1Builds 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.
7ui\Test-UiLayout.ps1Walks 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.
8ui\Test-WizardDownload.ps1Walks 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 the WINHTTP_FLAG_SECURE branch 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
FlagFor
--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.toml says version = "0.0.0" and nothing reads it.
  • Test-ReleaseVersion.ps1 builds from real tags and reads the version back out of the binary; CI runs it.
WorkflowTriggerResult
ci.ymlpush / PR to mastershared+server on Linux, installer on Windows (fmt, clippy, tests, release version, size gate, HTTP client)
cd-release-on-tag.ymla v/* tag is pushedRelease build, checks the .exe carries the tag’s version, publishes it as the GitHub release of that tag
cd-debugbuild-manually.ymlworkflow_dispatchDebug .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.md and server/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.md after 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.md in 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.