A component now declares its ways of being built in one list -- `modes: ["release", "debug"]`, the first the default -- and every command it runs is handed the mode as its last argument, so a project whose script takes `release` or `debug` names that script once. A field may instead be written per mode, which is the escape hatch for the commands that cannot take the word: cargo takes `--release` or nothing, and its profile for the unoptimised build is called `dev` while the directory it writes is called `debug`, so no single word serves as both the flag and the path. A command written per mode is not handed the word as well; it already is the answer, and a stray argument to a service binary is a process that will not start. One list rather than gathering names from whichever fields happened to mention them is what makes a mode missing from one part unsayable: every per-mode map is checked against it, so a gap is named rather than resolved to some other mode's command. Which mode to build in and whether to strip are the build machine's -- there is one checkout and one set of outputs, so a per-device mode would have two phones rebuilding over each other silently. Which of the finished builds a phone installs stays that phone's. Neither choice is part of the acceptance gate, so both have to be carried across the components list being rewritten, by acceptance and by the self entry's startup reconciliation alike; without the second a mode chosen for this server's own component would not survive the restart that applies it. A mode switch moves no commit, so `builtMode` is recorded beside `builtFrom`. Without it a component built in debug and switched to release reads as current and serves the debug build for ever -- and for an APK nothing else notices, because the "never built at all" check finds any variant under the component's directory. Each component card gains a settings sheet behind a gear at the row's right-hand end, holding the mode, which build to install, strip, and an Enrol button. The variant picker moved into it: on the card the two read as one choice, both saying debug and release, and they are not. The row's text is now bounded and truncates, so a long status can no longer push the log and settings buttons off the edge. `enroll:` is a declared command whose one line of stdout is a URL for the phone to open after installing -- generic on purpose, run per press since such a link is one-shot and carries a credential, and in the acceptance gate because it runs on the build machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test projects
Small fake projects for exercising Dev Updater against something other than Dev Updater. They are apps in the sense the server cares about -- a path with a declaration and a build that produces an APK -- and nothing else.
The point of having them is control. Dev Updater's job is installing other projects' builds, so trying it out needs something to install, and a real project only ever does what it happens to do: it builds, or it doesn't, and you cannot ask it to fail on the third component, produce two variants, or run a service that writes a coloured log. These can be asked for any of that.
Why they are not picked up automatically
scan_roots in server/src/discover.rs descends two levels below each
configured repo root, and stops at any directory carrying a
.dev-updater.ron -- a declaration says "this directory is the project",
so what is underneath is that project's own layout rather than more projects
to offer. This checkout carries one at its root, so with /home/bob/repos
as a root the scan considers dev-updater itself and descends no further.
Nothing in here is ever suggested.
To work with them, add .../dev-updater/test-projects as a repo root of its
own from the app's settings; each directory below is then a suggestion. Or
type one project's path into the Add screen, which is the escape hatch for
anything the scan does not reach.
The app/ directory each one builds into
find_apks matches APKs up to two directories below where it is
anchored, and it is anchored at a component's own directory --
project.join(cwd), which is the project path itself for a component
declaring no cwd, as all of these do except two-clients. A test project
building into test-projects/<name>/build/outputs/apk/ would therefore also
be found from the repository root -- offered as a build of
Dev Updater itself, and, being the newest, served as the default. Somebody
pressing Update on the Dev Updater card would get a stub app.
One more level down (test-projects/<name>/app/build/outputs/apk/) is out of
reach from the root while staying within reach from the test project, which
is why lib/build-apk.sh writes there. It is also what a real Gradle project
looks like, so nothing about it reads as a workaround. Check it after
changing any of this:
cd <repo root> && for p in build/outputs/apk/*/*.apk */build/outputs/apk/*/*.apk \
*/*/build/outputs/apk/*/*.apk; do [ -f "$p" ] && echo "$p"; done
Only app/androidApp/build/outputs/apk/debug/androidApp-debug.apk should
appear.
What is here
| project | what it is for |
|---|---|
hello-app |
The plain case: one APK, one variant. Declares no resources:, so Uninstall has to show its data and config toggles disabled and say why. |
two-variants |
Builds debug and release, so the phone gets a variant picker and resolve_apk has something to fall back from. |
breakable |
touch breakable/break-the-build and its next build fails, in colour, on stderr. rm it and it stops. |
service-and-app |
A Server beside an Apk: two components building at once, the whole service contract, a runtime log that is not this server's own, and a resources: declaration giving Uninstall real paths. |
two-clients |
Two Apk components in one checkout, each with its own cwd and package. The only fixture where two components produce an APK, which is what per-component discovery, the per-component "never built at all" check, and ?component= all need to be exercised against. tablet is slow on purpose so that building one rather than both is visible. |
All five are unaccepted when first added, so each is also a run through the acceptance gate.
The apps themselves
One screen showing the app's label, its package, and when the installed copy
landed -- lib/StubActivity.java, shared by all of them, because what
differs between these projects is what Dev Updater has to do with them, not
what the app is. The install time is PackageInfo.lastUpdateTime, which is
the exact value the freshness check compares an APK's mtime against, so the
screen shows what the card is reasoning about.
They are built by lib/build-apk.sh -- aapt2, javac, d8, apksigner, about
two seconds -- rather than by Gradle. A Gradle daemon is around a gigabyte
resident and four of these would be four daemons, which is how this machine
ran itself out of memory on 2026-08-30; and a fixture you wait forty seconds
for stops getting used. The cost is that the script is a small build system,
which is why it is one file and should stay one: a test project that needs
more than a screen with its own name on it wants a real project.
The signing key is generated once at
$XDG_DATA_HOME/dev-updater/test-projects/debug.keystore, outside the
repository -- the checkout is a mount shared with the host, and a stable key
is what lets a rebuild install over the copy already on a device. Delete it
to produce a signature-mismatch install failure on purpose.