c3ef4bd8b46ce5e791bded472bd59adb2e882b9a
30
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
174ef7c420 | Merge branch 'claude/android-release' | ||
|
|
af0e29a98b |
Give the desktop a nightly channel, the way the phone has one
ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so the two heads had different answers to the same question — how does somebody try what is on main? — for no reason except the order the work happened in. This is the desktop's answer: CI publishes a build from main on every push, and it installs beside the release one rather than over it. The phone gets its separation from the platform. Android refuses an update signed by a different key, so its two channels cannot replace one another whatever anybody does. Nothing refuses anything here: Velopack applies what its feed serves and verifies no signature. So all of it is construction, and there are four separations because each closes a different door. A pack id each, so the two install in different directories and neither feed's package can be applied to the other's install. A Velopack channel each — win and win-nightly — so neither build ever reads the other's release index; the name reaches the wire as releases.win-nightly.json, which is why the constant in VelopackUpdateChannel and the argument in ci.yml have to agree or the channel answers nothing forever with no error. A prerelease flag, so the release channel cannot see the nightly even by accident. And a profile directory each, which is the one that is easy to skip and would hurt most: the cache schema is migrated on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the release build then opens. Both are installed at once by design, so that is an ordinary Tuesday rather than a corner case. The prerelease flag turns out to be load-bearing across heads as well. The phone's release channel reads releases/latest, which skips prereleases — so a desktop nightly published as a stable release would become the newest release in this repository and every phone on the release channel would start failing its check against a release carrying no Android manifest. Which build this is arrives as assembly metadata, the same mechanism and the same reasoning as the Android head: the updater needs the string rather than a branch, and a value baked into the assembly is one a crash report can be asked for. Three things read it — the feed, the prerelease flag, and the profile — and one more shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes the two is somewhere nobody is looking while typing a passphrase into one of them. The version needed a floor and it is applied to the whole build rather than to the packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses anything below 0.0.1, so the job lifts the patch digit and keeps the height — through MinVerVersionOverride, so the assemblies carry the same number the installer does. Packing a version the assembly disagreed with would put one string on the preferences screen and another in the feed, which is the screen somebody reads when asked which nightly they are on. Two things found by running it rather than reading it. -t:MinVer needs a restore first, because the target arrives with the package and MSB4057 on a clean checkout reads like a typo in the workflow rather than a missing restore; the release script had the same gap and now restores before it reads. And vpk rejects an empty --packVersion loudly, which is how a broken version handoff announces itself rather than shipping a package called 1.0.0. Rule 3 is untouched. The release channel still has no job, no token and no runner, and the two channels cannot see each other. What a nightly costs is written where somebody reads it before installing one: whoever can write a release here can put a build on every nightly machine, which is fine for a build being tried and is not fine for a build holding somebody's infrastructure credentials. Verified by running the job's own steps against a clone in a Linux container: DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step not exercised — it needs a real forge and a write token, and check 16.10 is what walks the half no runner can. |
||
|
|
b4619db8d2 | Merge branch 'claude/android-release' | ||
|
|
3d3d0bc95f |
Package the Windows client in CI, on the runner that could not
The build job published a win-x64 tree and stopped there, so the half of a release that fails in ways a compile cannot see was proved by nobody until a person was midway through cutting one. It now packs as well: vpk opens the published binaries and verifies the main executable really calls VelopackApp.Build().Run(), which is the check worth having — a refactor that drops that call compiles, tests green, and produces an application that silently never updates itself. The file said this was impossible on Linux, and also said it was fine, in comments forty lines apart. The claim that vpk needs Windows tooling to stamp the Setup.exe stub is the one that was wrong: vpk cross-compiles when told to, and the telling is a bracketed directive before the verb rather than a flag. Plain `vpk pack --runtime win-x64` on a Linux host refuses outright and says so in the message that names the fix. `[win]` must be quoted, or the shell reads it as a glob matching any one of w, i and n. Only signing needs Windows, and nothing here is signed yet. Fixing that does not move ADR 0013 rule 3 an inch, which is why the two reasons were recorded separately in the first place. What may not live on a runner is the token, not the build: Velopack clients apply what their feed serves without verifying a signature, so whoever can write a release can ship an update every install runs. The packages go to RUNNER_TEMP and die with the job. They are not offered as workflow artefacts either — an installer nobody has run should not sit somewhere that invites passing it on. Written out as shell rather than by calling scripts/release-windows.ps1. That script is a person's procedure and holds things a runner must not have and must not skip: it refuses a dirty tree, insists HEAD is tagged, downloads the previous release for deltas, and asks for the forge token. Calling it would mean either weakening it with CI switches or having CI satisfy conditions that only make sense at a desk. The constants the two now share — pack id, title, authors, channel, icon — are a contract with VelopackUpdateChannel and with every installed client, and both sides say so. Two things fell out of running the steps rather than reading them, and both were in code nothing had ever executed: dotnet msbuild -getProperty:Version answers 1.0.0. Without a target named it evaluates the project and runs nothing, and MinVer computes inside a target — so the read comes back as the SDK default on a full checkout with every tag present. That line is the tag check in this file, which is `if:` a tag ref, and there are no tags yet: the first release ever cut would have been refused by its own guard, which would then have blamed fetch-depth. release-windows.ps1 had the same line and would have demanded HEAD be tagged v1.0.0. Both now pass -t:MinVer. And MinVer answers 0.0.0-alpha.0.N until that first tag exists, which vpk rejects outright as below 0.0.1 — so packing the true version could not have worked on any build made today. The patch digit is lifted for the throwaway package only. The release script gets no such floor and must not: its version is the one users compare against, and there the refusal is the right outcome. Verified by extracting both steps from this file and running them against a real clone in a dotnet SDK container: Setup.exe, the portable zip, the .nupkg and releases.win.json, from a machine that is not Windows. |
||
|
|
205f946450 |
Give the nightly's publisher a curl to publish with
The android job builds its APK in a glibc container and then does the publishing on the host, and the host is Alpine — busybox wget, and no curl anywhere on it. So the step that talks to the forge six times died on the first of those calls not written to tolerate a failure, with `curl: command not found` and exit 127, after the whole build had already been paid for. It took until the fourth call to say so, and that is the part worth reading. The lookup for an existing release and the tag delete before it both end in `|| true` with stderr discarded, which is exactly right for "there is no nightly yet" and indistinguishable from "there is no curl on this machine". Installing it is what makes those two different again. Dropping the `|| true` instead would fail the step on the first run of a channel that has never published, which is the one state that shape is there to handle. Not busybox wget in curl's place. The asset upload is a multipart -F, which busybox wget cannot send — so that reading ends in a release created with no APK on it, which is the failure the verification at the end of the step exists to catch, arrived at on purpose this time. Conditioned exactly like the step it serves, main only, because nothing else in this job wants curl and a pull request should not pay for a package it will not use. That is a coupling rather than a tidiness, and it is in the comment: the two conditions have to move together, and loosening the publish on its own puts the job back at exit 127 several minutes in. Unproven until the next main build, since the install path only runs there. What is checked is that the workflow still parses, that the script is valid under sh as well as bash like the ensure steps beside it, and that the two conditions read identically once parsed. |
||
|
|
253c72d2b7 |
Name the organisation the repository actually lives in
It moved to DodoTech-Public, and every address in the product still said DodoTech. That looked like it worked, which is the part worth writing down: Gitea leaves a 301 at the old path and HttpClient follows a redirect on a GET, so both update channels would have kept polling through it. What a 301 does not survive is a POST. `vpk upload gitea` publishes the desktop release by POSTing to that URL, so the stale address would have failed at the one step the whole feature depends on — and a redirect is a thing an operator can delete, which turns "works today" into the same silent outage this session has already spent two commits on. So both channel constants, both release scripts, the workflow's REPO, the image's source label and the curl in phase 16 all name the live path. The local remote too, which had been printing a redirect warning on every push. Measured after the move: the org, the repo and the nightly release all answer 200 anonymously, and that release now carries both assets — the manifest and a 54 MB APK. The upload going through also answers the open question about the reverse proxy's body-size limit, which nothing local could test. |
||
|
|
1bcf422bbe |
Build the phone once per run, and stop rebuilding the toolchain image
The android job compiled everything twice. The plain `dotnet build` before the packaging step looked like a cheap check ahead of an expensive one and was neither: SignAndroidPackage depends on Build, so the packaging line compiles everything anyway — and the build above it ran with no -p:DodoChannel, which means it ran as the *release* channel. Different application id, different version, different assembly metadata; MSBuild treats a different set of global properties as a different project instance, so not one output was reused. It was a full second compile of the reference closure, producing an APK for the one channel this job must never build, thrown away unread. Measured in the toolchain image with a warm package volume, same commit: two builds 2m52 + 2m26 5m21 total one build 3m01 3m04 total Byte for byte the same artefact out of both — versionCode 203, versionName 0.0.0-alpha.0.136, dev.dodotech.dodossh.nightly. The toolchain image is now built once per Dockerfile rather than once per run. The tag is the Dockerfile's own digest and docker applies a tag only on success, so an existing tag is by construction the right image and `docker image inspect` is a sound check rather than a guess. What that trades away is the JDK from apt drifting; everything that decides what is in the image is pinned in the Dockerfile, so anything that matters changes the digest. It is a build tool, not something shipped — the image job takes the opposite trade with --pull, because what it builds is what users run. And the build job now says whether its package cache did anything. setup-dotnet's cache: true is actions/cache underneath, which needs a cache server act_runner ships and can have turned off — and when it is off it does nothing and says nothing about it. A cold restore and a perfect cache look identical from outside: both are green, and the difference is minutes. One `find` before anything writes to the folder turns that from a belief into a line in the log. It does not fail the build, because a runner without a cache server is slow rather than wrong. What is deliberately not cached: the apt installs in each job's preamble, which need the runner's image fixed rather than a workflow change and already say so; the Testcontainers pulls and the API image's layers, which the daemon already caches on a persistent runner; and the android obj/bin, which would not help — the source arrives by `docker cp` with fresh timestamps, so MSBuild rebuilds it whatever is in there. |
||
|
|
7dc3b8950d |
Read the release id out of the front of the response, not the back
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.
|
||
|
|
a18ca56fde |
Copy the checkout into the container, because a socket is not a shared filesystem
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. |
||
|
|
30a3edb1d4 |
Build the phone in a container, because this runner cannot build it at all
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. |
||
|
|
65256fa337 |
Take the phone's aapt2 from the SDK the job installs rather than the workload's
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.
|
||
|
|
b4a6c19ac1 |
Let the phone replace itself, and give CI a channel it may sign
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
ffab2be22a |
Drop the formatting step, which spent minutes agreeing with the build
`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> |
||
|
|
08a820adcf |
Stop the login step interpolating a comment I wrote in it
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> |
||
|
|
2bc0d4d89f |
Say which credential is missing instead of letting docker guess
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>
|
||
|
|
a515a35804 |
Build the image with BuildKit rather than the builder Docker is retiring
"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> |
||
|
|
5ddbca49d3 |
Give the image job a docker client to go with the daemon it already had
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> |
||
|
|
208aca1191 |
Make a failing test run say what went wrong
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> |
||
|
|
43d76d0f2d |
Let the suite run on Linux, and fix the three things that stopped it
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> |
||
|
|
a6af93148b |
Give the runner a node before asking it to run an action
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> |
||
|
|
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.
|
||
|
|
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. |
||
|
|
66271faaae |
Update .github/workflows/ci.yml
ci / build and test (push) Failing after 16s
|
||
|
|
9c3edb078e |
Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
|
||
|
|
f0002b683c |
Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
|
||
|
|
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. |
||
|
|
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.
|