Build the Windows installer in CI

The fourth leg of build-and-test.yml, in the shape of the Android one:
an image workflow that builds docker/windows and pushes it tagged by
the directory's tree id, and a job inside that image that lints the
Windows target — the only place the cfg(windows) branches are ever
compiled by CI — builds, runs the smoke tests docs/windows.md §6
specifies, packages, installs and uninstalls under Wine, and uploads
the installer. Every step was run by hand in the same container first.

The spec's open list closes with this: the four §3.2 items, the
licence page, and the leg. What remains is what Wine cannot show, and
§10 now lists it as the first real Windows run's checklist.
This commit is contained in:
2026-09-12 07:34:10 +02:00
parent 6609aa9acf
commit 43f70c4765
5 changed files with 336 additions and 37 deletions
+106
View File
@@ -365,6 +365,112 @@ jobs:
path: target-android/apk/darkroom.apk
if-no-files-found: error
windows-image:
uses: ./.gitea/workflows/windows-image.yml
# TRACES: FR-PLAT-WIN-3
# The Windows executable and its installer, cross-built from Linux
# (docs/windows.md §7). No Windows machine anywhere in this job: what it
# can prove is that the binary links, is a Windows executable with no
# MinGW runtime imports, starts under Wine, and that the installer installs
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
# render, the secret store — is a release step on a real machine (§6).
windows:
runs-on: linux/amd64
name: Windows (x86_64, cross)
needs: windows-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_TARGET_DIR: target-windows
# Wine keeps its prefix under $HOME, which the image points at a
# directory that does not exist in a fresh container.
HOME: /tmp/home
steps:
- name: Checkout
uses: actions/checkout@v4
# Same step as the desktop leg: the models are LFS objects and the
# packager refuses pointers.
- name: Fetch the models
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull
ls -l models/face models/scene
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-windows
key: windows-${{ hashFiles('**/Cargo.lock') }}
# The cfg(windows) branches are linted here and nowhere else: the
# desktop leg's clippy never compiles them.
- name: Clippy for the target
run: cargo clippy --release --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
- name: Build
run: cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
- name: Smoke-test the executable
run: |
set -e
mkdir -p "$HOME"
EXE=target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe
file "$EXE"
file "$EXE" | grep -q 'PE32+' || { echo "FAIL: not a PE32+ executable"; exit 1; }
file "$EXE" | grep -q '(GUI)' || { echo "FAIL: not a GUI-subsystem executable"; exit 1; }
if x86_64-w64-mingw32-objdump -p "$EXE" | grep -iE 'libwinpthread|libgcc|libstdc'; then
echo "FAIL: the executable imports a MinGW runtime DLL"
exit 1
fi
x86_64-w64-mingw32-objdump -p "$EXE" | grep 'DLL Name' | sort -u
wineboot --init >/dev/null 2>&1 || true
OUT=$(wine "$EXE" --version 2>/dev/null)
echo "wine: $OUT"
echo "$OUT" | grep -q '^darkroom-desktop ' || { echo "FAIL: --version did not answer under Wine"; exit 1; }
- name: Package the installer
run: bash docker/windows/package.sh
- name: Smoke-test the installer
run: |
set -e
SETUP=$(ls target-windows/installer/DarkRoom-*-x86_64-setup.exe)
file "$SETUP" | grep -q 'PE32+' || { echo "FAIL: the installer is not 64-bit"; exit 1; }
wine "$SETUP" /S 2>/dev/null
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
ls "$INST"
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|| { echo "FAIL: the installed executable does not run"; exit 1; }
wine "$INST/uninstall.exe" /S 2>/dev/null
sleep 3
[ ! -e "$INST" ] || { echo "FAIL: uninstall left $INST behind"; ls -R "$INST"; exit 1; }
echo "OK: installed and uninstalled under Wine"
- name: Upload the installer
uses: actions/upload-artifact@v3
with:
name: darkroom-windows-x86_64-setup
path: target-windows/installer/DarkRoom-*-x86_64-setup.exe
if-no-files-found: error
layering:
runs-on: linux/amd64
name: Layer separation
+170
View File
@@ -0,0 +1,170 @@
name: '🐳 Windows image'
# Builds and pushes gitea.tourolle.paris/dtourolle/darkroom-windows, the job
# container for the Windows leg of build-and-test.yml.
#
# The same shape as android-image.yml, for the same reason that one exists:
# an image that lives only on a developer's laptop is a job that dies at
# `docker pull`. Built from docker/windows, tagged by that directory's tree
# id, skipped when the registry already has it.
#
# Called by build-and-test.yml on every push, and runnable by hand via
# workflow_dispatch. It is cheap when nothing changed — see the guard below.
on:
workflow_call:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
workflow_dispatch:
inputs:
force:
description: 'Rebuild even if the registry already has this image ("true"/"false")'
type: string
default: 'false'
# Gitea's act_runner mangles boolean workflow inputs passed through an
# expression — they arrive as false regardless of what was sent. Every input
# here is a string compared with == 'true', as in KPN's docker.yaml.
env:
IMAGE: gitea.tourolle.paris/dtourolle/darkroom-windows
jobs:
build:
runs-on: linux/amd64
name: Build and push
# Deliberately NOT in a container: this job needs the host Docker daemon to
# build an image, and the host's cached ~/.docker/config.json to push it.
# That is also why there is no `docker login` step — the runner host was
# authenticated to the registry during setup.
steps:
# The host has no Node, so the JS-based actions/checkout cannot run here.
# A minimal shallow fetch with plain git gets the same tree.
- name: Checkout
run: |
set -e
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: basic $(printf '%s' '${{ github.actor }}:${{ github.token }}' | base64 -w0)" \
fetch --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
# The image is tagged by the content of docker/windows, not by the commit
# that happened to touch it. `git rev-parse HEAD:<dir>` is the tree object
# id — it changes when and only when a file in that directory changes, so
# an unrelated push reuses the existing image and a Dockerfile edit can
# never silently keep serving a stale `latest`.
#
# Using the commit sha instead would rebuild 2.5 GB on every push; using a
# paths-filter action would need a container that has Node, and the only
# one this repo would reach for is the very image being built.
- name: Resolve image tag
id: tag
run: |
set -e
TREE=$(git rev-parse HEAD:docker/windows)
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
echo "docker/windows tree: $TREE"
# Skip the build when the registry already holds this exact content. This
# is what keeps the job a few seconds long on a normal push, and what
# makes it self-healing: if the tag is missing for any reason, including
# the image having never been pushed at all, it gets built here.
#
# The probe is curl against the registry API, NOT `docker manifest
# inspect`. The latter exits 1 on this registry even for tags that are
# demonstrably present — jellytau-builder:latest answers HTTP 200 to the
# API while `docker manifest inspect` reports "manifest unknown" for it.
# Trusting that would have rebuilt 7 GB on every single push.
#
# A HEAD request also gives the digest for free, which is how the repoint
# decision below is made without pulling any layers.
- name: Query registry
id: check
env:
# The runner's own credentials, so this does not depend on how the
# host's ~/.docker/config.json happens to be set up.
REG_USER: ${{ github.actor }}
REG_PASS: ${{ github.token }}
TREE: ${{ steps.tag.outputs.tree }}
run: |
set -eu
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
API="https://gitea.tourolle.paris/v2/dtourolle/darkroom-windows/manifests"
# Prints "<http-status> <digest-or-empty>" for a tag.
probe() {
curl -sI -u "$REG_USER:$REG_PASS" -H "Accept: $ACCEPT" "$API/$1" \
| tr -d '\r' \
| awk 'BEGIN{s="000";d=""} /^HTTP/{s=$2} tolower($1)=="docker-content-digest:"{d=$2} END{print s, d}'
}
read -r TREE_STATUS TREE_DIGEST <<EOF
$(probe "$TREE")
EOF
read -r LATEST_STATUS LATEST_DIGEST <<EOF
$(probe latest)
EOF
echo "tag $TREE -> HTTP $TREE_STATUS ${TREE_DIGEST:-(no digest)}"
echo "tag latest -> HTTP $LATEST_STATUS ${LATEST_DIGEST:-(no digest)}"
# Build unless the registry definitively confirms this content is
# already there. An auth failure or an unreachable registry lands
# here too, and rebuilding needlessly is the safe direction to fail —
# skipping a build that was needed is what breaks the Windows job.
if [ "${{ inputs.force }}" = "true" ]; then
echo "forced rebuild requested"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ "$TREE_STATUS" != "200" ]; then
echo "registry does not have this content — building"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
elif [ -n "$TREE_DIGEST" ] && [ "$TREE_DIGEST" = "$LATEST_DIGEST" ]; then
echo "registry is already correct — nothing to do"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=false" >> "$GITHUB_OUTPUT"
else
echo "content is present but latest points elsewhere — repointing"
echo "build=false" >> "$GITHUB_OUTPUT"
echo "repoint=true" >> "$GITHUB_OUTPUT"
fi
# Context is docker/windows, matching the README's build command. The
# Dockerfile COPYs nothing from the repo, so it needs no wider context —
# and a narrow context keeps the daemon from tarring up the whole tree,
# target/ included.
- name: Build
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker build \
-t "$IMAGE:${{ steps.tag.outputs.tree }}" \
-t "$IMAGE:latest" \
docker/windows
# Both tags are pushed: the tree tag is what the guard above looks for on
# the next run, and `latest` is what build-and-test.yml pulls.
- name: Push
if: ${{ steps.check.outputs.build == 'true' }}
run: |
set -e
docker push "$IMAGE:${{ steps.tag.outputs.tree }}"
docker push "$IMAGE:latest"
# A cache hit on the tree tag says nothing about where `latest` points — a
# reverted Dockerfile or a build from another branch can leave it on
# different content. This runs only when the digests above actually
# disagree, so the common case costs nothing; the layers are already in
# the registry, so the push that follows uploads a manifest, not 2.5 GB.
- name: Repoint latest
if: ${{ steps.check.outputs.repoint == 'true' }}
run: |
set -e
docker pull "$IMAGE:${{ steps.tag.outputs.tree }}"
docker tag "$IMAGE:${{ steps.tag.outputs.tree }}" "$IMAGE:latest"
docker push "$IMAGE:latest"
+11 -6
View File
@@ -45,12 +45,17 @@ Measured on the first build, 2026-09-12:
| `wine uninstall.exe /S` | Removes the directory and the key |
| Start Menu shortcut | **Not verifiable here.** `CreateShortcut` is `IShellLink` and does nothing under a headless Wine; the directory beside it is created. Check on Windows. |
None of this proves a Vulkan device opens, a render completes, fonts are found or the secret store
round-trips. Those are a Windows machine, once per release — and today the secret store *cannot*
round-trip, because its Windows implementation is still the loud placeholder (docs/windows.md
§3.2).
Also checked by running the application itself under Wine: its log lands in
`AppData\Local\darkroom\state`, and nothing is written outside `AppData` (FR-PLAT-WIN-1).
None of this proves a Vulkan device opens, a render completes, fonts are found, the secret store
round-trips or the browser opens for sign-in. Those are a Windows machine, once per release.
## In CI
Not yet wired. The image and the leg are specified in docs/windows.md §7 in the shape of the
Android ones, and every step they would run has been exercised by hand above.
`.gitea/workflows/windows-image.yml` builds this image and pushes it to
`gitea.tourolle.paris/dtourolle/darkroom-windows`, exactly as the Android image is handled — tagged
by the tree hash of `docker/windows/`, rebuilt when and only when a file here changes. The
`windows` job in `build-and-test.yml` then runs, inside it, the same commands listed above plus
`cargo clippy --target x86_64-pc-windows-gnu -- -D warnings`, and uploads the installer as an
artefact.
+3 -3
View File
@@ -22,9 +22,9 @@ permission to a package rather than after.
| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all |
| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs |
| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering |
| Windows | NSIS per-user installer, cross-built — [windows.md](windows.md) | **Specified, not built.** Not v1 | Known folders in place of XDG; no sandbox; unsigned until there is a certificate |
| Windows | NSIS per-user installer, cross-built — [windows.md](windows.md) | Built by CI, **untested on Windows**. Not v1 | Known folders in place of XDG; no sandbox; unsigned until there is a certificate |
Three of these six exist as recipes and three do not. That is stated rather than
Four of these six exist as recipes and two do not. That is stated rather than
smoothed over, because the value of writing the channels down is knowing which
constraints are already being met and which are promises.
@@ -247,7 +247,7 @@ packaging/
flatpak/
paris.tourolle.darkroom.yml the manifest, and where the permissions are argued
windows/
darkroom.nsi specified in windows.md §5; not yet written
darkroom.nsi the installer; docker/windows/package.sh drives it
```
`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local
+46 -28
View File
@@ -10,11 +10,12 @@ tree has to change to compile for the target, what the installer does, how the C
and — because there is no Windows hardware on the runner — exactly how much of the result can be
verified before a person double-clicks it.
**Written as a spec; §10 is the report.** §9's steps 1, 2 and 4 have since been run —
**Written as a spec; §10 is the report.** Every step of §9 has since been run —
[`docker/windows/`](../docker/windows/) is the container, [`packaging/windows/darkroom.nsi`](../packaging/windows/darkroom.nsi)
the installer, and §6's gate passes through row 4 under Wine. Four claims in the first draft were
wrong and are corrected in place with a note; §10 lists them. Where a claim still rests on reading
rather than running, it says so.
the installer, [`.gitea/workflows/windows-image.yml`](../.gitea/workflows/windows-image.yml) and
the `windows` job in `build-and-test.yml` the CI leg, and §6's gate passes through row 4 under
Wine. Four claims in the first draft were wrong and are corrected in place with a note; §10 lists
them. Where a claim still rests on reading rather than running, it says so.
---
@@ -146,21 +147,27 @@ in `core/` is touched, which is NFR-PORT-1 holding.
### 3.2 Required before the installer is worth shipping
Ordered by what blocks a first sign-in.
Ordered by what blocks a first sign-in. **All four are done**; each item says how.
1. **Secret store.** `keyring` has a Windows Credential Manager backend; enable its feature under a
`cfg(windows)` target dependency and add the third `impl SecretStore for PlatformSecretStore`
beside the two that exist. The feature's exact name has changed across `keyring` majors
(`windows-native` in 3.x) and is to be read from the 4.x changelog rather than assumed. FR-NC-2's
rule carries over unchanged: Credential Manager is always present on Windows, so there is no
degraded mode to state.
1. **Secret store.** *Done.* `keyring` 4's `v1` feature set — the one the workspace already
asks for — includes `windows-native-keyring-store`, so the Credential Manager backend needed
no new feature name, only the crate as a `cfg(windows)` target dependency and the existing
Secret Service implementation's `cfg` widened to include Windows. One implementation over
both, because `keyring::Entry` is the same API over either; the only difference is that
`is_available`'s probe always succeeds on Windows, which is correct — Credential Manager is
always present, so FR-NC-2's degraded mode does not arise.
2. **Paths.** FR-PLAT-LIN-1 says XDG, and the code says it in five places by reading `XDG_*_HOME`
and falling back to `$HOME/.local/...`. On Windows `HOME` is normally unset, so every one of
these degrades to a relative path from the working directory — which for a Start Menu launch is
`C:\Windows\System32`. The fix is one function per kind of directory in `dr-plat`, with the
Windows branch reading `%APPDATA%` (config; roams) and `%LOCALAPPDATA%` (data, cache, state;
does not), and the five call sites using it:
2. **Paths.** *Done* — [`platform/dr-plat/src/dirs.rs`](../platform/dr-plat/src/dirs.rs). FR-PLAT-LIN-1
says XDG, and the code said it in five places by reading `XDG_*_HOME` and falling back to
`$HOME/.local/...`. On Windows `HOME` is normally unset, so every one of these degraded to a
relative path from the working directory — which for a Start Menu launch is
`C:\Windows\System32`. Now one function per kind of directory in `dr-plat`, with the Windows
branch reading `%APPDATA%` (config; roams) and `%LOCALAPPDATA%` (data, cache, state; does
not), and the five call sites using it. The Android overrides (`set_state_dir`,
`set_data_dir`) stay where they were; only the fallback behind them moved. Both platforms'
rules are unit-tested on either host, and the Windows one was confirmed by running the
application under Wine: the log landed in `AppData\Local\darkroom\state` and nothing was
written anywhere else.
| Kind | Linux today | Windows |
|---|---|---|
@@ -171,15 +178,18 @@ Ordered by what blocks a first sign-in.
FR-PLAT-WIN-1 states this as the requirement. The catalog and thumbnail *formats* do not change,
so a library directory copied from a Linux machine opens.
3. **Face models.** `library::system_face_models_dirs` walks `$XDG_DATA_DIRS`, which does not
exist on Windows. The installer puts the models beside the executable (§5), so the Windows
branch returns `current_exe().parent().join("models")`. The user-directory lookups above it are
unchanged, so a hand-placed pair still outranks the installed one, exactly as on Linux.
3. **Face models.** *Done.* `library::system_face_models_dirs` walked `$XDG_DATA_DIRS`, which
does not exist on Windows. The rule moved to `dr_plat::system_data_dirs`: the installer puts
the models beside the executable (§5), so the Windows branch returns the executable's own
directory. The user-directory lookups above it are unchanged, so a hand-placed pair still
outranks the installed one, exactly as on Linux.
4. **Opening the sign-in URL.** `launch_ui.rs` shells out to `xdg-open`. Windows wants
`ShellExecuteW` with the `open` verb, or `cmd /C start "" <url>`; the `open` crate does the
`cfg` for every platform and is the conventional answer. Android has its own Intent path
already, so this is the third branch of a function that already has two.
4. **Opening the sign-in URL.** *Done.* `launch_ui.rs` shelled out to `xdg-open`. The Windows
branch runs `rundll32 url.dll,FileProtocolHandler <url>`, which is `ShellExecute` on the URL
and needs no crate — chosen over `cmd /C start`, whose quoting of `&` in a query string is a
known trap, and over the `open` crate, which would be a dependency for one line. Android has
its own Intent path already, so this is the third branch of a function that already had two.
*Not verified*: Wine has no browser to open.
5. **`std::os::unix` uses outside a `cfg`.** `diagnostics.rs` and `presets.rs` use
`PermissionsExt` for mode bits on written files. Most are inside `#[cfg(unix)]` blocks already;
@@ -463,6 +473,14 @@ the reasoning that produced each mistake is the thing a reader will otherwise re
And two things it did not know to say: NSIS's default stub is 32-bit (§5.5), and `CreateShortcut`
cannot be verified headless (§6).
**Still open**, in the order §3.2 gives them: the secret store, the known-folder paths, the
models lookup beside the executable, the sign-in URL. The binary that exists today starts, and
cannot sign in. Then §7's CI leg, whose every step has now been run by hand once.
**Closed since**, same day: all four §3.2 items (each says how), a `LICENSE` at the repository
root so the installer has its licence page, and §7's CI leg — `windows-image.yml` and the
`windows` job, every step of which was run by hand in the same container first. The Windows
target is also linted now, with `cargo clippy --target x86_64-pc-windows-gnu -- -D warnings` in
that job, which is the only place the `cfg(windows)` branches are ever compiled by CI.
**What the first real Windows run has to check**, in order, because Wine cannot: that a Vulkan
device opens on a real driver and a render completes; that fonts are found; that Credential
Manager round-trips a sign-in and the browser opens for Login Flow v2; that the Start Menu
shortcut exists; and that text is sharp on a scaled display (§3.3). The release notes for the
first build should say which of these were checked and on what machine.