Commit Graph
23 Commits
Author SHA1 Message Date
jaap-jan 7dc3b8950d Read the release id out of the front of the response, not the back
ci / build and test (push) Successful in 1m47s
ci / android head (push) Successful in 6m9s
ci / api image (push) Successful in 22s
The nightly release was created and the step said it had not been:

  Gitea accepted the release call and returned no id:
  {"id":1,"tag_name":"nightly",…,"author":{"id":-2,…},"assets":[]}

`sed -n 's/.*"id":\([0-9]*\).*/\1/p'` — the leading .* is greedy, so it walked past the release's own id
to the last "id": in the document, which belongs to the embedded author object and is -2. [0-9]* then
matched no digits at all and the answer was the empty string. The release is real; only the reading of
it was wrong, which is why the page exists and carries nothing but source tarballs.

It broke both readings and the other one silently. The existing-release lookup got the same empty id, so
the delete never fired, so the next run would have failed to create a release for a tag that already had
one — a rolling channel that works exactly once. Both now go through one function, and grep matching
left to right with [0-9]+ cannot reach the author's -2 at all.

And the step now asks for the release back and checks its own uploads are on it. The failure this
channel is exposed to is a release that exists and carries nothing: a phone reads that as a feed it can
never update from and a person reads it as a page offering source tarballs. The run before this one left
exactly that behind, and it would have reported success for every upload it never made.

Checked against the response body the failing run printed: the old expression answers empty, the new one
answers 1, and the verification rejects that release and accepts one with an asset on it. The bad
release is not cleaned up by hand — the next run on main deletes and recreates it, which is what the
rolling tag does anyway.
2026-08-05 07:44:48 +02:00
jaap-jan a18ca56fde Copy the checkout into the container, because a socket is not a shared filesystem
ci / build and test (push) Successful in 1m51s
ci / android head (push) Failing after 6m24s
ci / api image (push) Successful in 23s
The container started and could not see the repository:

  MSBUILD : error MSB1009: Project file does not exist.
  Switch: src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj

`-v "$PWD:/build"` cannot work here. This runner is itself a container holding the host's Docker
socket, so the workspace path it reports — /root/.cache/act/<hash>/… — exists in the runner and not on
the daemon's host, which is where Docker resolves a bind source. It finds nothing, creates an empty
directory, and mounts that. Nothing about the failure says so.

Nothing earlier in this workflow would have caught it either, and that is the part worth keeping: the
image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so neither
of the two places this repository already used Docker proves a bind mount would work. I read a working
daemon as a shared filesystem, and they are not the same claim.

`docker cp` goes over the same API and so does not care where the daemon lives. In with the whole
checkout, .git included, since MinVer and the versionCode both read it — the repository is well under a
megabyte packed. Out with the staged package. The NuGet cache becomes a named volume for the same
reason: it lives on the daemon and needs no path either side has to agree on.

The two container steps collapse into one, since with a copy in and a copy out there is nothing to be
gained by paying for both twice, and scripts/ci-android.sh is now what runs inside — a file that can be
read and executed on its own rather than a heredoc inside a workflow.

Exercised locally against a real clone, every step as the job runs it: create, cp in, start --attach,
cp out, parse the manifest on the outside.

  package: name='dev.dodotech.dodossh.nightly' versionCode='196' versionName='0.0.0-alpha.0.129'

The second run took 2m25s against the first run's 8m, which is the named volume doing its job.
2026-08-05 07:29:53 +02:00
jaap-jan 30a3edb1d4 Build the phone in a container, because this runner cannot build it at all
ci / build and test (push) Successful in 1m54s
ci / android head (push) Failing after 3m54s
ci / api image (push) Successful in 44s
The runner is Alpine, and .NET for Android does not work on musl. Not "needs setting up" — the SDK's
own MSBuild tasks pull glibc shared objects out of the workload pack into the build process, and a
musl-linked dotnet will not load one:

  error XARLP7000: Error relocating .../libZipSharpNative-3-3.so: __snprintf_chk: symbol not found

That is a glibc fortify symbol musl does not implement, reached through a DllImport rather than an
exec, so gcompat is no help: it gets a glibc *executable* started, which is a different problem. There
is no musl variant of the pack.

Everything this job did on the host to make Android work was therefore treatment of symptoms, mine
included. The missing aapt2 was present. The "unsupported version" was of a binary that had never run.
Both were this one sentence in a different accent, and the loader was the accent, not the sentence.

So the toolchain moves into build/android-build.Dockerfile — Microsoft's own sdk:10.0-noble plus a JDK,
the Android SDK and the workload — and the job keeps on the host only what the host is good at:
checkout, git, publishing. The daemon needed no arranging, since the image job already builds with it
and every Testcontainers suite reaches it over the socket. The image is tagged by the digest of the
Dockerfile that made it, so on a persistent runner every run after the first is a cache hit, and a
change to the toolchain is the only thing that buys a new one.

Built rather than pulled: a community image with the Android SDK already in it would put a stranger in
the path of a package this project signs and publishes. Eleven lines of apt and sdkmanager is the
cheaper trade.

Verified end to end in that image against a real clone rather than reasoned about, which after three
rounds of reasoning seemed the least I could do. Restore under locked mode, Release build, then
SignAndroidPackage:

  package: name='dev.dodotech.dodossh.nightly' versionCode='195' versionName='0.0.0-alpha.0.128'
  Signer #1 certificate SHA-256 digest: a9f067877724ddb0fdc04b637fbd5bfb97df753976616f100b48b522e132ba22

which is the keystore in build/. The versionName carries MinVer's height, so the csproj's target fires
in the container too, and the manifest the feed publishes parses back on the host.

Staging moves from RUNNER_TEMP to artifacts/, which is forced rather than preferred: the package is
made inside a container and read outside one, so it has to land under the bind-mounted checkout.
2026-08-04 23:28:47 +02:00
jaap-jan 65256fa337 Take the phone's aapt2 from the SDK the job installs rather than the workload's
ci / build and test (push) Successful in 1m49s
ci / android head (push) Failing after 7s
ci / api image (push) Successful in 30s
The android job got past restore and died in the .NET Android SDK's own tooling resolution:

  warning : An error occurred trying to start process
            '.../packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory
  error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux'

The error names the wrong problem. Nothing was found, so nothing had a version, and XA0111 points at
an Aapt2ToolPath in the project file that has never been set. The warning above it is the real message
and it is only a warning.

The pack was incomplete, and on this runner it would have stayed that way: act's host executor keeps
/usr/share/dotnet between runs, and `dotnet workload install` reads the installed-workload records and
does nothing when android is listed, whatever is on disk. So two changes, each of which stands alone.

The build and the packaging step are now given -p:Aapt2ToolPath pointing at build-tools, which this job
installs itself and can therefore vouch for. That is already the aapt2 the packaging step shells out to
for `dump badging`, so this makes one tool of what were two, and the manifest the feed publishes is now
read by the binary that wrote it. Checked rather than assumed: build-tools 36.0.0 answers aapt2 2.20 and
.NET for Android 36.1.43 builds and packages this head against it with no complaint. The version is
named once, in the step's env, because three things now depend on it agreeing with itself.

And the workload step probes for the pack file that went missing and repairs the workload when it is
absent. The probe is a witness rather than the point — the build no longer touches that binary — but a
5 MB file near the end of a 130 MB package is what a truncated extraction loses first, and r8.jar and
manifestmerger.jar are what it loses next.
2026-08-04 22:32:52 +02:00
jaap-jan b4a6c19ac1 Let the phone replace itself, and give CI a channel it may sign
ci / build and test (push) Successful in 1m53s
ci / android head (push) Failing after 32s
ci / api image (push) Successful in 28s
The Android head had no updater and no release path, and the two are one problem:
Android refuses an update signed by a different key, and CI generates a fresh debug
key in every container. An APK released from a workflow could be installed once and
never updated again — each new one an uninstall, which on this product means losing
the cache, the outbox and the device key.

So there are two channels, and they are two applications because the platform gives
no third option. dev.dodotech.dodossh is cut from a v* tag by a person running
scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners.
dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore
committed here in the open — a key everybody has cannot be stolen and grants nothing
by being held, which is why putting it in CI does not touch the rule. Neither can
update the other, by construction. See ADR 0014.

The android job assumed an image with a JDK and an Android SDK on it, which is what
a GitHub runner is and what this project's is not. It now installs a JDK, fetches
Google's command-line tools, accepts the licences and installs API 36 — each a no-op
where it is already satisfied, and each cached by the persistent runner's own disk
rather than by an action that would move a quarter of a gigabyte to rebuild a
directory that never left.

The client reads a small JSON manifest beside the APK, the counterpart of
releases.win.json, and compares Android's versionCode rather than a version name:
that integer is what the platform itself uses to accept or refuse an install, so
comparing anything else would offer updates the phone then rejects. It fetches, and
then asks Android to ask — the system draws its own confirmation, and from API 26
will not draw even that until unknown sources is on for this application.

IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files
and restarts, so the shell disposes the vault first and that is what zeroes the keys.
On the phone the install is a request and the answer may be no, so disposing first
would answer "not now" with a locked keychain and every shell closed — a punishment
for declining an update.

Two measured bugs found on the way, both older than this work and both invisible to
a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a
top-level PropertyGroup, so the target setting it from MinVer ran after the only
thing that reads it: every APK ever built here said versionName 1.0.0. And nothing
found so far varies the launcher name per channel — four mechanisms tried, all of
them recorded in platform-flags, none of them reaching the label the launcher shows.
The two channels share an icon name for now and are told apart by package name,
version, and what the preferences screen says.
2026-08-04 21:46:01 +02:00
jaap-jan 3ead865f01 Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
2026-08-04 17:52:57 +02:00
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00
jaap-jan ebb88c8ae4 Give the phone both pickers, and settle who signs the APK
The files screen could browse a remote and delete on it, and that was all: there
is no browsable local filesystem on Android for a second pane to show, so the
gesture the desktop is built around — choose on the left, press the arrow — has
nothing to stand on. What replaces it is the platform's own two pickers. ADD
FILES is ACTION_OPEN_DOCUMENT, so a document is pointed at wherever it lives and
goes to the directory showing; SAVE FILE is ACTION_CREATE_DOCUMENT for the
selected row.

Both stage through the application's cache, and that copy is a requirement
rather than a shortcut. android-port.md predicted a picked document would be a
third IRemoteFileStore beside SFTP and S3; it cannot be. FileTransferQueue seeks,
because an upload resumes from the byte the last attempt reached, and a
content:// URI has no path behind it, no length worth trusting, no promised seek
and no grant that survives the document being edited underneath it. Copying
first costs one class in the head and nothing at all in the shared layers, where
the alternative was every resume rule rewritten around a stream that cannot
rewind. The copy is deleted when the transfer completes, kept while it is stopped
so RESUME still has something to read, and swept at the next launch — which is
the one moment emptying that directory is provably safe, since nothing has
queued anything yet.

Coming out had a decision going in did not: when to ask where it goes. The save
picker is raised before the transfer, so the download runs into the same staging
directory and hands its bytes to a callback the head supplied, held against the
transfer id so a RETRY still lands where the person pointed. Asking afterwards
would put the picker minutes from the button that caused it and, on a phone,
usually while the application is backgrounded and Android will not show one at
all. The cost is that the picker creates its file when it is dismissed, so a
download that then fails leaves an empty one there; that is said on the screen,
in the README and in the manual checks rather than left to be discovered. A
delivery that fails keeps the staged bytes for the sweep instead of throwing away
the one copy of something just fetched over somebody's network.

The foreground service counts transfers now, which is the half of it that
matters most here: a shell survives backgrounding because somebody is looking at
it, and an upload has to survive precisely when nobody is. Queued counts as
active, so putting five files in and locking the phone moves five files. The
seam was built for this and wired to () => 0 because nothing could fill the
queue.

Alongside it, ADR 0010 answers the second question android-port.md left open,
and it had to be answered before the first release rather than at upload time: a
new Play app must use App Bundles and therefore Play App Signing, and an
installed app can only be updated by a package signed with the same key, so the
first release picks an identity for good. The project holds the key, offline and
never in CI — the workflow's package step now says so where somebody would break
it — and a DodoSSH deployment never serves the client, because a download link on
your own server hands the binary that holds the plaintext to the party the whole
threat model is about.

The README's M1 gap note was stale in both halves and is replaced by what is
actually true: credentials have an editor and a REMEMBER tick, and the device key
registers into the TPM under a CNG policy that makes the consent dialog a
condition of using it. What is left is the floor rather than a gap — no TPM, or
no Windows, means the passphrase on every launch.
2026-08-04 10:07:16 +02:00
jaap-janandClaude Opus 5 ffab2be22a Drop the formatting step, which spent minutes agreeing with the build
ci / build and test (push) Successful in 1m11s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 43s
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to
reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig,
EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the
build step. What the separate step bought was hearing about it a few minutes earlier, and
it charged those minutes on every run.

Checked rather than assumed, because the whole justification rests on it: appending a
badly-spaced member to a source file produces three `error IDE0055` lines and a failed
build with no format step in sight.

Three places said the old arrangement out loud and would now be wrong on their own — the
comment on the IDE0055 line, the conventions list in the README, and a note in
platform-flags telling people to run dotnet format before pushing or CI would fail them.
They say the build enforces it now. dotnet format is still how to fix what the build
complains about; it just no longer gates anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:43:32 +02:00
jaap-janandClaude Opus 5 08a820adcf Stop the login step interpolating a comment I wrote in it
ci / build and test (push) Successful in 1m40s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 40s
The secrets were never the problem. The log shows both arriving masked, which is what a
runner does with a value it holds — so the repository secrets were configured correctly the
whole time, and the guidance about Actions Variables was wrong.

What broke was the guard added to diagnose them. Its comment contained an expression
delimiter written out literally to explain what an unset secret renders as, and a shell
comment is not a comment yet at that point: the runner substitutes the whole script before
any shell sees it, so it tried to evaluate an empty expression and failed the step with a
parse error carrying no line number. The step never ran, and push then reached the registry
with nothing to authenticate as — "no basic auth credentials", which looks precisely like
the missing-secret problem the guard was added to rule out.

The comment now describes the delimiter instead of containing one, and warns the next
person, since the failure is invisible to review and to every local check: the file is
valid YAML and the script is valid shell.

Verified with a scan for empty expressions across every run block in the file — one before,
none after — and by running the step's script with credentials set, which passes the guard
and gets a 401 from the real registry. That is the right answer for an invented password,
and it means the endpoint is reachable and the path through this step is sound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:01:06 +02:00
jaap-janandClaude Opus 5 2bc0d4d89f Say which credential is missing instead of letting docker guess
ci / build and test (push) Successful in 1m47s
ci / android head (push) Failing after 5s
ci / api image (push) Failing after 4s
The registry secrets are reportedly not arriving, and the job could not have told anybody
which one or why. An unset secret is not an error anywhere upstream: ${{ }} renders a
missing value as an empty string, so docker gets --username "" and replies with something
about credentials — which reads as the registry rejecting a login rather than as a value
that never left the settings page.

Checked before use now, and reported by length rather than by value. Gitea masks known
secret values in logs, but a mask is only as good as the runner's bookkeeping, and a length
answers the only question actually being asked: did anything arrive at all. The message
names the page to look at, and names the neighbouring one too, since Actions Variables and
Actions Secrets sit next to each other and only one of them is readable through the secrets
context.

This does not fix the credentials. It converts a confusing failure into a specific one, so
the next run distinguishes "the secret is empty here" from "the registry refused these" —
two problems with nothing in common that currently look identical.

Verified by running the step's script with both variables set empty, which is the reported
symptom: it names both, points at the settings page and exits 1 before docker is called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:51:12 +02:00
jaap-janandClaude Opus 5 a515a35804 Build the image with BuildKit rather than the builder Docker is retiring
ci / build and test (push) Successful in 1m38s
ci / android head (push) Failing after 4s
ci / api image (push) Failing after 21s
"DEPRECATED: The legacy builder is deprecated and will be removed in a future release."
Not a failure — the image was built and the job carried on — but a countdown, and one the
last commit walked straight into: Alpine's docker-cli package does not carry buildx, so
giving the job a working client left it building the old way.

Two packages instead of one now. With the plugin present `docker build` routes through
BuildKit on its own, which also stops the Dockerfile's independent stages being serialised,
so this is slightly faster as well as not deprecated.

buildx is wanted rather than required, and the difference is deliberate. Missing it costs a
warning and a slower build; the image is still correct. So each install branch ends in
`|| true` and the check afterwards reports instead of exiting — a distribution with no
package for it should not be able to turn a release into a red build over a plugin.

The comment above the build step said this job needed "no buildx plugin", which was true
when the build was the only thing being weighed and is not true now. It says what is
actually wanted, and what is still not: no QEMU, no builder instance to create and tear
down, no third-party action to re-pin.

Verified in Alpine containers with the socket mounted, in all three states this can be in:
nothing installed, the client present and buildx missing — which is exactly what produced
the warning — and buildx unavailable with no package manager to fix it, which warns and
exits 0. The API image builds through BuildKit with no deprecation notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:19:19 +02:00
jaap-janandClaude Opus 5 5ddbca49d3 Give the image job a docker client to go with the daemon it already had
ci / android head (push) Failing after 5s
ci / build and test (push) Successful in 1m50s
ci / api image (push) Failing after 1m6s
Exit 127, `docker: command not found`, from the build step of the image job. The daemon was
never the problem and never missing: Testcontainers speaks to /var/run/docker.sock from a
.NET library, so every integration suite in the build job had been starting PostgreSQL,
Keycloak and an sshd on this runner while `docker` was not a command on it at all. Having a
socket and having a client are two different things to have, and this runner had one.

It failed late for the same reason it was easy to miss. Node, git, the SDK and the tags all
came up fine, so the job looked healthy right until the line that actually needed the
binary.

The client only. There is a daemon answering on that socket already — installing an engine
would start a second one beside the one in use, which is a worse outcome than the error.

Verified by running this step's own script in an Alpine container with the socket mounted:
it installs docker-cli, the client then reports server 29.6.2 across the socket, and the
API image builds to completion from inside that container with the repository as its
context. Which is as close to the runner as this can be checked without being it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:14:28 +02:00
jaap-janandClaude Opus 5 208aca1191 Make a failing test run say what went wrong
ci / build and test (push) Failing after 1m38s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
Two suites fail on the runner and pass everywhere else, and every attempt to work out why
has been an inference from a filename. The runner prints the path of a log written to a
disk nobody has a shell on, and the log is where the exception type, the message and the
stack all live — so a red build has been a guess, and the last guess was wrong: 69 layout
failures looked like missing fonts and were a missing shared library instead.

This prints the log, and three facts about the machine that no log will ever carry: which
distribution it is and who the job runs as, whether docker answers, and — the one that
matters for the layout suite — ldd against the libSkiaSharp.so the test project carries,
filtered to its unresolved rows. A managed TypeInitializationException on SKImageInfo is a
symptom several missing libraries share; ldd names the library. The fontconfig step ahead
of this exits early when ldconfig already reports one, so if that is present and Skia still
will not load, the answer is a different dependency and this is what says which.

head rather than tail on the log, which is the whole trick. A suite that fails wholesale
writes one stack per test and they are the same stack; the first explains it and the last
two hundred lines are that sentence repeated.

if: failure() and exit 0, so it runs only on a red build and reports without becoming a
second failure on top of the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:32:32 +02:00
jaap-janandClaude Opus 5 43d76d0f2d Let the suite run on Linux, and fix the three things that stopped it
ci / build and test (push) Failing after 1m47s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
The pipeline finally reached the tests and found four failures. None was the pipeline's,
and only one of the four was a test being fussy about a platform rather than telling the
truth about one.

The local pane's roots bar was the real bug. LocalDirectory.Roots built it from
DriveInfo.GetDrives on every platform, and its own summary — "the drives on Windows, and
the root elsewhere" — had been describing an intention rather than the code for as long as
nobody ran it off Windows. On Unix that call answers with every mount the kernel holds:
/proc, /sys/fs/bpf, one per installed snap, /run/user/1000/doc, some forty on an ordinary
laptop. The transfers screen draws a button per root, so the bar ran to about five thousand
pixels inside an eight-hundred pixel window. Anybody running the Linux build has been
looking at that.

Filtering GetDrives is not the fix and the comment now says why at length, because it is
the obvious thing to try: DriveType answers Fixed for / and /home and equally for every
squashfs snap, for efivarfs and for tracefs, while /boot/efi comes back Removable, and
DriveFormat would need a hand-kept list of every virtual filesystem Linux might grow. So
Unix now names what somebody would want instead of subtracting what they would not — the
root, their home, and whatever is mounted under /run/media/<user>, /media, /mnt or
/Volumes. Anything else is still reachable by navigating from /, which is what the pane is
for. Windows is untouched.

ClientPathsTests looked for "odoSSH" in the profile directory. ClientPaths spells it
DodoSSH on Windows and dodossh on Unix deliberately, one per platform convention, and that
substring was clever enough to survive either spelling of the leading D while still only
ever matching one of them. Now OrdinalIgnoreCase.

WhyTheWindowItselfIsNeverShown asserted a COMException with HResult RPC_E_CHANGED_MODE,
which is WebView2 refusing an MTA thread — a Win32 component raising a COM error. On Linux
the adapter is a different implementation with no apartment to disagree about, so showing
the window works and Should.Throw catches nothing. Skipped there rather than loosened to
accept both outcomes: the assertion is the documentation in that test, and one that passed
everywhere would have stopped recording the constraint it exists to record.

The fourth was CI's alone, and the diagnosis is the useful part. All 69 layout tests failed
on the runner while 6 failed here, which looked like missing fonts and was not: Avalonia's
headless renderer is Skia, libSkiaSharp.so links against libfontconfig, and without it the
suite dies in HeadlessUnitTestSession with a TypeInitializationException on SKImageInfo
naming none of its actual subjects. The job installs the one library now. Verified in a
container where fc-list returns zero and the suite passes regardless, because the
application carries Inter itself — fonts were never the problem, only the thing that would
have looked for them.

The whole solution now passes on Linux: 19 suites, 1295 tests, 0 failures, 4 skipped, the
end-to-end Testcontainers suite included. README and platform-flags.md said testing was
Windows-only, which CI now contradicts on every push, so both say what is true instead and
the two findings are written down where the next person will look for them. macOS is still
untested and now says so on its own rather than hiding inside "not Windows".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:28:06 +02:00
jaap-janandClaude Opus 5 a6af93148b Give the runner a node before asking it to run an action
ci / build and test (push) Failing after 5s
ci / api image (push) Skipped
ci / android head (push) Failing after 3s
Every job died on its first line: "Cannot find: node in PATH", from actions/checkout.
act_runner executes each `uses:` action with node inside the job container, and the image
this runner is configured with has none — so nothing in the pipeline had run yet, including
the tests the image job gates on.

A `run:` step is shell rather than node, so one placed ahead of the first action can fix the
job it is in. It installs via apt-get, apk or dnf, whichever is there, and says plainly what
to do when none of them is. git goes in alongside, named in the step rather than smuggled
into it: checkout shells out to git the moment node has loaded it, so an image thin enough
to lack one usually lacks the other, and finding that out separately costs another round
trip through CI.

The version is warned about, not enforced. Distributions pin nodejs to whatever shipped
with the release — Ubuntu 24.04 still serves 18, past end of life and older than these
actions declare — but act_runner hands an action whichever node is on PATH regardless of
what it asked for, and it generally works. A warning is the right weight for something that
explains a later inexplicable failure without being one.

Repeated verbatim in all three jobs. It cannot be a local composite action, since that
needs the checkout it exists to unblock, and YAML anchors that would deduplicate it are
rejected by GitHub's parser. Byte-identical across the three so a diff shows drift.

This is still a workaround. The fix is one line of the runner's own config.yaml pointing
container.image at an image that ships node, as Gitea's default
catthehacker/ubuntu:act-latest does; the step then costs a version check and nothing else.
Kept regardless, because a pipeline that silently depends on a runner being configured
correctly elsewhere fails confusingly when it is not.

Verified by running the step's own script in ubuntu:24.04 and alpine:3.20, which have
neither, and node:20-bookworm, which has both: installs where needed, no-ops where not, and
warns only on the node 18 that Ubuntu gives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:04:50 +02:00
jaap-janandClaude Opus 5 8a568117df Give the API an image, and unbreak the restore that had to run first
registry-docker.dodotech.cloud/dodotech/dodossh-api, built and pushed by a third ci job
that needs the first. Gating on the tests costs a few minutes on every main commit and buys
the only thing worth having here: an image is not an artefact somebody inspects before
using it, so a red commit must not be able to produce one. Pull requests build the image
and stop, which is where a broken Dockerfile should be found.

Tags are :sha-<short> on every build, :main on main, and for a v* tag :1.2.3, :1.2 and
:latest — the last two only when the version has no prerelease suffix, since v1.3.0-rc1
sorts above v1.2.9 and would otherwise walk :latest onto somebody's server. Only sha- is
immutable, and it is the one to pin a deployment to.

No docker/* actions. The build is single-architecture, so it needs the daemon this runner
already has for the Testcontainers suites and nothing else — no buildx, no QEMU, and no
third-party action whose SHA has to be audited and re-pinned. Step outputs and secrets
reach the shell through env rather than ${{ }} interpolation, because a git tag may contain
a semicolon and interpolation is textual substitution performed before the shell parses the
line.

The image is chiseled: no shell, no package manager, uid 1654. Affordable because
Directory.Build.props already sets InvariantGlobalization, so the ICU and tzdata a normal
base carries are exactly what this product decided not to use. The cost is stated in the
Dockerfile rather than hidden — there is no HEALTHCHECK, because there is nothing to run
one with, and /healthz/ready is anonymous precisely so the orchestrator can ask instead.
Nothing migrates the schema from inside the container either; readiness fails while a
migration is pending and names it, which is the design.

And the restore that all of this depends on did not work. 7a3a521 committed lock files
carrying a net10.0/android-arm64 section into fourteen projects — written there by the
Android head's -p:RuntimeIdentifier=android-arm64 packaging build, which restores the
shared projects with a RID and updates their lock files as a side effect. Any restore
without that RID then fails NU1004 in locked mode, which is every other build there is:
`dotnet restore DodoSSH.slnx --locked-mode` has been failing for eleven projects on a clean
checkout of main since that commit. The sections are removed here and nothing else changed
— deletions only, ILLink.Tasks stays at 10.0.10.

Verified: the solution restores in locked mode, the image builds, and it runs. /healthz/live
answers 200 and /healthz/ready answers 503 naming the database it cannot reach, from a
67 MB image as uid 1654, configured entirely through DODOSSH_-prefixed variables.

The Android head's own lock file still carries the RID and is untouched, because that job
restores it separately and is outside DodoSSH.slnx. Whether its packaging step re-dirties
these fourteen on every CI run is worth a look; it is the same mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:12 +02:00
jaap-jan 81e7e6d939 Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
2026-07-31 21:09:42 +02:00
jaap-jan 66271faaae Update .github/workflows/ci.yml
ci / build and test (push) Failing after 16s
2026-07-31 08:44:19 +00:00
jaap-jan 9c3edb078e Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
2026-07-31 08:43:39 +00:00
jaap-jan f0002b683c Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
2026-07-31 08:39:25 +00:00
jaap-jan 34304b989b Make the end-to-end suite self-contained with Testcontainers
It needed a hand-started stack and an opt-in flag, so it ran on one machine
and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server
itself, applies the committed migrations and starts the API as a child
process, which makes it part of the ordinary test run at ~25s.

The API runs as a process rather than through WebApplicationFactory. The
client builds its own HttpClient for a URL the user typed, so there is no
seam to hand a test handler through without inventing one that exists only
for tests — and a test host would replace the entry point, Kestrel and the
content root, so it would never prove that Program.cs composes or that the
committed appsettings is found and layered in the documented order. Running
out of the API's own output directory is what makes its configuration real.

The suite still consumes what ships: the realm file from deploy/keycloak,
the EF migrations, the API's own appsettings. Only Oidc:Authority is
overridden, because the container's port is assigned at start. Falsified by
reintroducing the wildcard-port redirect URI the realm once had — Keycloak
rejects the authorization request and the suite fails at sign-in, which is
what proves the committed file is the one imported. Skipping the migration
step likewise fails, and the failure names the pending migration.

A fresh Keycloak per run also sidesteps the --import-realm trap: editing the
realm file and rerunning now always tests the edit.

DodoDbContextFactory gains a Create(connectionString) so the fixture and
dotnet ef place the migrations history table in exactly one place. If they
disagreed the API would report every migration pending, which is how the
readiness gate catches it.
2026-07-29 12:09:15 +02:00
jaap-jan 3a81f3c90b Restructure into src/tests and add build foundation (M0)
Moves the scaffold to src/DodoSSH.Api and establishes the repo conventions the rest
of the milestones build on.

Structure:
- src/{Contracts,Crypto,Domain,Infrastructure,Api}, tests/{Contracts,Crypto,Domain}.Tests
- DodoSSH.slnx rewritten with src/ and tests/ solution folders

Build:
- Directory.Build.props centralises TFM, nullable, deterministic builds and
  TreatWarningsAsErrors; Directory.Packages.props pins every version centrally
- packages.lock.json committed so CI restores in locked mode
- NuGet.config clears machine-level sources, which both fixes NU1507 under central
  package management and makes restore reproducible off this machine
- Microsoft.OpenApi pinned to 2.11.0: ASP.NET Core 10.0.10 resolves 2.0.0, which is
  covered by GHSA-v5pm-xwqc-g5wc (high, patched in 2.7.5)

Analyzers:
- AnalysisLevel is Recommended, not All. With warnings-as-errors, All turns opinionated
  naming rules into build breaks and trains people to blanket-suppress.
- BannedSymbols.txt bans DateTime.UtcNow (TimeProvider), Guid.NewGuid (CreateVersion7),
  sync-over-async, MD5/SHA1, PBKDF2 and SecureString
- CA1711/CA1724 disabled: both are .NET Framework CAS-era naming rules
- PublicApiAnalyzers on Contracts only, since that assembly is the client's real contract

API:
- weather-forecast template removed
- UseHttpsRedirection removed; TLS terminates at the reverse proxy and redirecting
  behind one causes loops
- /healthz/{live,ready,startup}. Liveness deliberately checks no dependencies so a
  transient database outage cannot restart the container and kill live SSH sessions.

Notes:
- No coverage collector yet. Microsoft.Testing.Extensions.CodeCoverage pulls an MTP 1.x
  MSBuild extension that throws TypeLoadException against the MTP 2.3.x xunit.v3 brings.
  Coverage gates are an M3 concern; revisit with an MTP 2.x-aligned version then.

Verified: dotnet build (0 warnings), 17 tests pass, format check clean, API serves
health and OpenAPI endpoints.
2026-07-28 12:25:34 +02:00