name: ci

on:
  push:
    branches: [main]
    # Release tags run the whole workflow, not only the image job that gates on it. A tag
    # is the one build nobody is watching, so it is the last place to take the tests on
    # trust.
    tags: ['v*']
  pull_request:
    branches: [main]

# Actions are pinned to commit SHAs, not tags: a tag can be moved to point at new code,
# which would let a compromised action run with this workflow's permissions.
permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  DOTNET_NOLOGO: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
  CI: true

jobs:
  build:
    name: build and test
    runs-on: [linux]
    steps:
      # act_runner runs every `uses:` action with node inside the job container, and this
      # runner's image has none — the run died on the first line of actions/checkout with
      # "Cannot find: node in PATH". A `run:` step is shell rather than node, so this one
      # can go first and unblock the rest.
      #
      # This is a workaround and the real fix is one line of the runner's own config.yaml:
      # point container.image at an image that ships node, the way Gitea's default
      # catthehacker/ubuntu:act-latest does. Kept anyway, because a pipeline that depends
      # on a runner being configured correctly somewhere else fails confusingly when it is
      # not, and because it costs nothing on a runner that is.
      #
      # git as well as node, and said in the step name rather than smuggled in: checkout
      # shells out to git the moment node has loaded it, so an image thin enough to lack
      # one usually lacks the other, and learning that costs a whole second CI round trip.
      #
      # Repeated verbatim in all three jobs, which is not laziness. It cannot be a local
      # composite action — that would need the checkout it exists to unblock — and YAML
      # anchors, which would deduplicate it, are rejected by GitHub's parser and would make
      # this file portable to nothing. Change one copy, change all three.
      - name: ensure node and git
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          missing=""
          command -v node >/dev/null 2>&1 || missing="$missing nodejs"
          command -v git  >/dev/null 2>&1 || missing="$missing git"

          if [ -z "$missing" ]; then
            echo "node $(node --version), git $(git --version)"
            exit 0
          fi

          echo "Installing:$missing"
          if command -v apt-get >/dev/null 2>&1; then
            $SUDO apt-get update -qq
            $SUDO apt-get install -y --no-install-recommends $missing
          elif command -v apk >/dev/null 2>&1; then
            $SUDO apk add --no-cache $missing
          elif command -v dnf >/dev/null 2>&1; then
            $SUDO dnf install -y $missing
          else
            echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
            echo "job. Point the runner's container.image at something that ships node." >&2
            exit 1
          fi
          echo "node $(node --version), git $(git --version)"

          # Warned about rather than failed on. Distributions pin their nodejs package to
          # the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
          # of life and older than the runtime these actions declare. It generally runs
          # them anyway, since act_runner uses whichever node is on PATH regardless of what
          # the action asked for, so this is a note for when one of them misbehaves in a
          # way that makes no sense, not a reason to stop a build that is probably fine.
          major="$(node --version | sed 's/^v//; s/\..*//')"
          if [ "$major" -lt 20 ]; then
            echo "::warning::node $major is older than the runtime these actions target;" \
                 "give the runner an image with node 20 or newer if actions misbehave."
          fi

      # fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
      # nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
      # it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
      # whether an installed client is out of date by comparing versions, which makes a plausible wrong
      # answer here a client that never updates.
      #
      # Repeated in all three jobs, like the node preamble above and for the same reason. Change one
      # copy, change all three.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      # cache: true is actions/cache underneath, keyed on the lock files. That needs a cache server,
      # which act_runner ships and can be turned off — and when it is off, this does nothing at all and
      # says nothing about it. See the step below, which is how you find out.
      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      # ◆ ONE LINE, AND IT EXISTS BECAUSE THE ALTERNATIVE IS ASSUMING.
      #
      # A restore that is cold every single run and a cache working perfectly look identical from
      # outside: both end in a green build, and the only difference is minutes. This job's whole
      # package cache is the step above, so whether that step works is worth knowing as a fact rather
      # than as a belief — and it is one `find` before anything has written to the folder.
      #
      # It does not fail the build. A runner with no cache server is slow rather than wrong, and a
      # pipeline that refused to run on one would be a worse trade than the minutes.
      - name: what the package cache brought
        run: |
          set -eu

          root="${NUGET_PACKAGES:-$HOME/.nuget/packages}"

          if [ -d "$root" ]; then
            echo "$root holds $(find "$root" -mindepth 1 -maxdepth 1 -type d | wc -l) package(s) already."
          else
            echo "$root is not there, so this restore is cold."
            echo "If every run says that, the cache above is doing nothing: check that this runner's"
            echo "config.yaml has the cache server enabled, and that ACTIONS_CACHE_URL reaches it."
          fi

      # Locked mode fails if packages.lock.json does not match the project files, so a
      # dependency cannot change without the lock file change being reviewed.
      - name: restore
        run: dotnet restore DodoSSH.slnx --locked-mode

      # The version comes from the tag, so on a tag build there are two ways to say the same number
      # and they can disagree — a moved tag, a tag on the wrong commit, or a checkout that somehow
      # still lost its history. What makes that worth a step of its own is that the disagreement is
      # silent everywhere else: MinVer answers 0.0.0-alpha.0.N rather than failing, the build goes
      # green, the package is cut, and the symptom arrives weeks later as clients that never update.
      #
      # -getProperty evaluates without building, so this costs a second and runs before the build.
      #
      # ◆ -t:MinVer IS NOT OPTIONAL, AND WITHOUT IT THIS STEP FAILED EVERY TAG BUILD.
      #
      # -getProperty on its own evaluates the project and runs no targets, and MinVer sets Version
      # from inside a target — so what came back was the SDK's default 1.0.0, for any tag, on any
      # commit, in a clean checkout with every tag present. The step would have failed the first
      # release ever cut and blamed fetch-depth for it, which is the one explanation that is not it.
      # Naming a target makes -getProperty report the value after that target has run.
      #
      # Never exercised, because no v* tag exists yet: this whole step is `if:` a tag ref. It is the
      # shape of bug a guard is prone to — the guard is the thing nothing tests, and it only speaks
      # on the one build nobody is watching, which is the reason this step exists at all.
      - name: the tag and the version agree
        if: startsWith(github.ref, 'refs/tags/v')
        run: |
          set -euo pipefail
          tag="${GITHUB_REF#refs/tags/v}"
          declared="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
                        -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"

          if [ "$tag" != "$declared" ]; then
            echo "The tag says v$tag and MinVer computed $declared." >&2
            echo >&2
            echo "These come from the same place, so a mismatch means the checkout did not see the" >&2
            echo "tag it is building — most likely fetch-depth, which must stay 0 in every job here." >&2
            exit 1
          fi

          echo "v$declared"

      # No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
      # the build did, for minutes, to check something the build already checks: IDE0055 is an
      # error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
      # the build step below on its own. What the separate step added was the ability to say
      # so a few minutes earlier, and it cost more than that on every run.

      # Avalonia's headless renderer is still Skia, and libSkiaSharp.so — which the layout
      # test project copies into its own output — links against libfontconfig. Without that
      # one library every test in DodoSSH.Client.App.Layout.Tests dies inside
      # HeadlessUnitTestSession before it measures anything, and 69 tests fail for a reason
      # none of their names or assertions mention.
      #
      # The library, and not fonts. Verified in a container where fc-list returns zero and
      # the suite passes anyway: the application carries Inter itself, so nothing here needs
      # a typeface installed — only the thing that would have gone looking for one.
      - name: ensure skia's native dependency
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          if ldconfig -p 2>/dev/null | grep -q 'libfontconfig\.so\.1'; then
            echo "libfontconfig present"
            exit 0
          fi

          echo "Installing fontconfig"
          if command -v apt-get >/dev/null 2>&1; then
            $SUDO apt-get update -qq
            $SUDO apt-get install -y --no-install-recommends libfontconfig1
          elif command -v apk >/dev/null 2>&1; then
            $SUDO apk add --no-cache fontconfig
          elif command -v dnf >/dev/null 2>&1; then
            $SUDO dnf install -y fontconfig
          else
            echo "No package manager here, so Skia cannot be given its dependency and the" >&2
            echo "layout suite will fail to start. Add fontconfig to the runner's image." >&2
            exit 1
          fi

      - name: build
        run: dotnet build DodoSSH.slnx --no-restore --configuration Release

      - name: test
        run: dotnet test DodoSSH.slnx --no-build --configuration Release

      # The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
      # failure mode is a restore graph or a native asset that resolves for net10.0 and not for
      # net10.0/win-x64, which nobody would see until a person was halfway through cutting a release
      # on a Windows machine.
      #
      # RestoreLockedMode=false for this command only, and it is not a loosened gate. The committed
      # lock files are deliberately RID-free: declaring win-x64 on the desktop head writes a
      # net10.0/win-x64 target into every project it references transitively, which includes
      # DodoSSH.Contracts and DodoSSH.Crypto — and the API's Dockerfile restores those with no RID
      # under locked mode, so the image job would fail NU1004. The gate is the locked solution
      # restore at the top of this job, which is unchanged.
      #
      # It rewrites the lock files as it goes; nothing after it reads them, and the runner's
      # checkout is thrown away. The release script does the same thing and puts them back, because
      # there the tree is somebody's working copy.
      #
      # After the tests rather than before them, so a red suite does not first spend a hundred
      # megabytes pulling a win-x64 runtime pack. main and tags only, for the same reason: a break
      # found by the person about to release is found early enough.
      #
      # The output directory is read by the packaging step below, so the two names have to agree.
      - name: publish the windows desktop client
        if: github.event_name != 'pull_request'
        run: >
          dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
          --configuration Release --runtime win-x64 --self-contained true
          -p:RestoreLockedMode=false
          --output "$RUNNER_TEMP/win-x64"

      # ◆ THE WINDOWS INSTALLER IS BUILT HERE, ON LINUX, AND IS DELIBERATELY THROWN AWAY.
      #
      # Packaging rather than only publishing, for the same reason the android job packages an APK
      # nobody installs: the failures a release is most exposed to are the ones only the packager
      # finds. vpk opens the published binaries and verifies that the main executable really calls
      # VelopackApp.Build().Run() — so a refactor that drops that call, which compiles, tests green
      # and produces an application that silently never updates itself, fails here instead of
      # shipping. A publish alone cannot see that, and the person who would otherwise see it first is
      # the one midway through a release.
      #
      # ◆ NOT WITH scripts/release-windows.ps1. That script is a person's release 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 to build deltas against, and in its second phase asks
      # for the forge token. Calling it from here would mean either weakening it with CI switches or
      # having CI meet conditions that only make sense at a desk. So the packaging arguments below
      # are written out again in shell, and the duplication is accepted and stated: the pack id, the
      # title, the authors, the channel and the icon are a contract with VelopackUpdateChannel and
      # with every installed client. Change one, change the other.
      #
      # ◆ AND CROSS-COMPILING NEEDS THE OS DIRECTIVE, WHICH IS EASY TO MISS.
      #
      # vpk picks its target from the host: on Linux, plain `vpk pack` builds a Linux release and
      # refuses a Windows RID outright — "the target rid must be Linux (actually was Windows)". The
      # bracketed [win] before the verb is what turns it into a cross-compile, and it must be quoted
      # or the shell reads it as a glob matching any one of w, i and n. With it, the runner writes
      # DodoSSH.Desktop-win-Setup.exe, the portable zip, the full .nupkg and releases.win.json,
      # exactly as a Windows machine does. Only signing needs Windows tooling, and nothing here is
      # signed — see ADR 0013 rule 8 for the trigger that changes.
      #
      # ◆ NOTHING IS UPLOADED, AND NOTHING IS EVEN OFFERED FOR DOWNLOAD.
      #
      # The output goes to RUNNER_TEMP and dies with the job. That is the whole point rather than an
      # omission: ADR 0013 rule 3 puts the capability to ship somebody a build on a machine which is
      # not a runner, because Velopack clients apply what their feed serves without verifying a
      # signature. Publishing a CI-built package as a workflow artefact would not cross that line by
      # itself, and it would put an installer nobody has run in a place that invites passing it on —
      # so it is not done either. What reaches users is built, installed and walked through Phase 16
      # of docs/manual-checks.md by a person first.
      - name: package the windows desktop client
        if: github.event_name != 'pull_request'
        run: |
          set -euo pipefail

          # vpk comes from the tool manifest, at the version committed there, which is the same one
          # the release script uses. Restored here rather than in a step of its own because this is
          # the only job that needs a tool at all.
          dotnet tool restore

          # The same value the release script packs with, read the same way. A version vpk disagrees
          # with the assemblies about is a client that compares wrongly, so it is read from MSBuild
          # rather than reconstructed from the ref — a main build has no tag to reconstruct from
          # anyway, and MinVer's answer there carries the prerelease height that tells two of them
          # apart.
          #
          # -t:MinVer for the reason spelled out on the tag check above: without a target named,
          # -getProperty answers 1.0.0 and every package would carry the same version forever, which
          # Velopack reads as "no update available".
          version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
                       -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"

          if [ -z "$version" ]; then
            echo "Could not read the version from MSBuild." >&2
            exit 1
          fi

          # ◆ AND A FLOOR UNDER IT, BECAUSE MINVER'S PRE-FIRST-TAG ANSWER IS ONE vpk REFUSES.
          #
          # Until a v* tag exists MinVer answers 0.0.0-alpha.0.N — correct, documented, and rejected
          # outright by vpk: "Invalid package version '0.0.0-alpha.0.143', it must be >= 0.0.1". So
          # today, and on every main build until the first release, packing the true version cannot
          # work at all.
          #
          # The patch digit is lifted and the prerelease part kept, so the stand-in still sorts below
          # anything real and still differs between commits. Sound only because this package is
          # thrown away: what is being proved here is that the desktop client packages, and no client
          # will ever see this number. The release script is not given a floor and must not be — its
          # version is the one users compare against, and if MinVer ever answered 0.0.0 there the
          # right outcome is the refusal.
          packVersion="$version"
          case "$packVersion" in
            0.0.0*)
              packVersion="0.0.1${packVersion#0.0.0}"
              echo "MinVer says $version, which vpk will not pack; packaging as $packVersion."
              echo "This disappears the moment a v* tag exists, and never reaches a release."
              ;;
          esac

          releases="$RUNNER_TEMP/win-releases"

          # --skip-updates because vpk otherwise asks nuget.org whether a newer vpk exists on every
          # run, which is a network call whose answer this build must not act on: the version that
          # packs is the one in the tool manifest.
          #
          # No `vpk download`, where the release script has one. That fetches the previous release so
          # deltas can be built against it, which needs the feed and produces an artefact nobody
          # applies. A full package proves the packaging.
          dotnet vpk '[win]' pack \
            --skip-updates \
            --packId DodoSSH.Desktop \
            --packVersion "$packVersion" \
            --packDir "$RUNNER_TEMP/win-x64" \
            --packTitle DodoSSH \
            --packAuthors DodoTech \
            --mainExe DodoSSH.exe \
            --icon src/DodoSSH.Client.App/Assets/dodossh.ico \
            --runtime win-x64 \
            --channel win \
            --outputDir "$releases"

          # Asked for rather than assumed. vpk fails loudly and this costs a line, but the artefact
          # whose existence is the entire question — a Windows setup stub built on a machine that is
          # not Windows — is worth naming rather than inferring from an exit code.
          setup="$releases/DodoSSH.Desktop-win-Setup.exe"
          if [ ! -s "$setup" ]; then
            echo "vpk reported success and there is no setup executable at $setup." >&2
            ls -la "$releases" >&2 || true
            exit 1
          fi

          ls -la "$releases"
          echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."

      # This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
      # server through Testcontainers and runs the API as a child process — so it needs a
      # Docker daemon and gets one here. That is why the tests run on ubuntu rather than
      # macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
      # dominate a cold run.

      # A failing run says only that tests failed and names a log file on a machine nobody
      # has a shell on. Every diagnostic thing — exception type, message, stack — is inside
      # that file, so a red build was a filename and a guess. This prints it.
      #
      # head rather than tail, and that is the whole trick: when a suite fails wholesale it
      # writes one stack per test and they are all the same stack. The first is the one that
      # explains it, and the last two hundred lines are the same sentence repeated.
      - name: what actually failed
        if: failure()
        run: |
          set +e
          echo "=== distro ==="
          cat /etc/os-release 2>/dev/null | head -3
          id

          echo "=== what Skia needs, and whether it is here ==="
          # ldd against the copy the test project carries. Its unresolved rows are the
          # answer whenever the layout suite dies in HeadlessUnitTestSession, and asking
          # here beats inferring it from a managed TypeInitializationException.
          skia="$(find tests -name 'libSkiaSharp.so' 2>/dev/null | head -1)"
          if [ -n "$skia" ]; then
            echo "$skia"
            ldd "$skia" 2>&1 | grep -Ei 'not found|fontconfig|freetype' || echo "  all resolved"
          else
            echo "  libSkiaSharp.so was not in the test output at all"
          fi
          ldconfig -p 2>/dev/null | grep -ci fontconfig | sed 's/^/fontconfig entries in ldconfig: /'

          echo "=== docker, for the Testcontainers suites ==="
          docker version --format '{{.Server.Version}}' 2>&1 | head -2

          echo "=== test logs ==="
          find tests -path '*/TestResults/*.log' 2>/dev/null | while read -r log; do
            echo "----- $log"
            head -n 120 "$log"
          done
          exit 0
  android:
    name: android head
    runs-on: [linux]
    # Writes the nightly release at the end of the job; see that step for what the capability is and why
    # it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
    permissions:
      contents: write
    steps:
      # Duplicated from the build job; see the comment there for why it cannot be factored
      # out. Any change here has to be made in all three.
      - name: ensure node and git
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          missing=""
          command -v node >/dev/null 2>&1 || missing="$missing nodejs"
          command -v git  >/dev/null 2>&1 || missing="$missing git"

          if [ -z "$missing" ]; then
            echo "node $(node --version), git $(git --version)"
            exit 0
          fi

          echo "Installing:$missing"
          if command -v apt-get >/dev/null 2>&1; then
            $SUDO apt-get update -qq
            $SUDO apt-get install -y --no-install-recommends $missing
          elif command -v apk >/dev/null 2>&1; then
            $SUDO apk add --no-cache $missing
          elif command -v dnf >/dev/null 2>&1; then
            $SUDO dnf install -y $missing
          else
            echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
            echo "job. Point the runner's container.image at something that ships node." >&2
            exit 1
          fi
          echo "node $(node --version), git $(git --version)"

          # Warned about rather than failed on. Distributions pin their nodejs package to
          # the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
          # of life and older than the runtime these actions declare. It generally runs
          # them anyway, since act_runner uses whichever node is on PATH regardless of what
          # the action asked for, so this is a note for when one of them misbehaves in a
          # way that makes no sense, not a reason to stop a build that is probably fine.
          major="$(node --version | sed 's/^v//; s/\..*//')"
          if [ "$major" -lt 20 ]; then
            echo "::warning::node $major is older than the runtime these actions target;" \
                 "give the runner an image with node 20 or newer if actions misbehave."
          fi

      # fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
      # nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
      # it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
      # whether an installed client is out of date by comparing versions, which makes a plausible wrong
      # answer here a client that never updates.
      #
      # Repeated in all three jobs, like the node preamble above and for the same reason. Change one
      # copy, change all three.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      # A job of its own, because DodoSSH.Client.Android is deliberately not in DodoSSH.slnx.
      # Adding it there would make the android workload and a full Android SDK a prerequisite of
      # `dotnet build DodoSSH.slnx` for everyone — including the build job above, which needs
      # neither and would grow several minutes for a head it does not compile.
      #
      # The cost of keeping it out is that nothing in the main job would notice this head
      # breaking, which for a project sharing view models with the desktop one is a matter of
      # when rather than whether. This job is that notice.
      #
      # ============ ◆ WHY THIS HEAD IS BUILT IN A CONTAINER AND THE OTHER TWO ARE NOT ============
      #
      # .NET for Android cannot build on this runner. Not "needs setting up" — cannot. The runner is
      # Alpine, and the SDK's own MSBuild tasks load glibc shared libraries out of the workload pack
      # into the build process, which a musl-linked dotnet will not do:
      #
      #   error XARLP7000: Error relocating …/libZipSharpNative-3-3.so: __snprintf_chk: symbol not found
      #
      # There is no musl variant of that pack and no shim for a library loaded in-process — gcompat
      # gets a glibc *executable* started and is no help whatsoever here. Everything this job used to
      # do on the host to make Android work was therefore treatment of symptoms: an aapt2 reported
      # missing while being present, an "unsupported version" of a binary that had never run. All of
      # them were this one sentence in a different accent, and docs/platform-flags.md has the history
      # so the next reader does not repeat the three rounds it took to hear it.
      #
      # So the toolchain moves into an image that is glibc, and the job keeps on the host only what the
      # host is good at: checkout, git, and publishing. The daemon needs no arranging — the image job
      # builds with it and every Testcontainers suite in the build job reaches it over the socket.
      #
      # There is no setup-dotnet here any more, and its absence is the point: nothing outside the
      # container compiles anything, so a dotnet on the host would be a second toolchain with nothing
      # to do and a version nobody checks.
      - name: ensure the docker cli
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          # Only the client. The socket is already there and a daemon is already answering on it, so
          # installing an engine would start a second one beside the one in use. The image job carries
          # the same step and the same reasoning; change one, look at the other.
          if ! command -v docker >/dev/null 2>&1; then
            echo "Installing the docker cli"
            if command -v apk >/dev/null 2>&1; then
              $SUDO apk add --no-cache docker-cli
            elif command -v apt-get >/dev/null 2>&1; then
              $SUDO apt-get update -qq
              $SUDO apt-get install -y --no-install-recommends docker.io
            elif command -v dnf >/dev/null 2>&1; then
              $SUDO dnf install -y docker-cli
            else
              echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
              echo "inside the job. Add it to the runner's image." >&2
              exit 1
            fi
          fi
          docker --version

      # Tagged by the digest of the Dockerfile that made it, so the tag moves exactly when the toolchain
      # does and never otherwise. This runner is persistent, so the first build pays for a JDK, an
      # Android SDK and the android workload — several minutes — and every run after it is a cache hit
      # that prints two lines. A change to that Dockerfile is what buys a new one.
      # ◆ BUILT ONCE PER DOCKERFILE, NOT ONCE PER RUN.
      #
      # The tag is the Dockerfile's own digest, so a tag that exists is by construction an image built
      # from exactly this Dockerfile — and docker applies a tag only when the build succeeded, so it
      # cannot be a half-built one. That makes `docker image inspect` a sound cache check rather than a
      # guess, and it skips the context upload and the layer walk that a no-op `docker build` still does.
      #
      # The staleness this trades away is real and bounded. Everything that decides what is *in* the
      # image — the command-line tools zip, the build-tools version, the workload — is pinned in the
      # Dockerfile, so changing any of them changes the digest and rebuilds. What drifts is the JDK from
      # apt. This is a build tool rather than something shipped, so that is the right way round; the
      # image job takes the opposite trade with --pull, because what it builds is what users run.
      #
      # Deleting the image, or editing the Dockerfile, is how you force it.
      - name: the android toolchain image
        run: |
          set -eu

          tag="dodossh-android-build:$(sha256sum build/android-build.Dockerfile | cut -c1-16)"

          if docker image inspect "$tag" >/dev/null 2>&1; then
            echo "$tag is already on this runner"
          else
            docker build -t "$tag" -f build/android-build.Dockerfile build
          fi

          echo "ANDROID_BUILD_IMAGE=$tag" >> "$GITHUB_ENV"
          echo "$tag"

      # ◆ COPIED IN AND COPIED OUT, NOT BIND-MOUNTED, AND THAT IS NOT A PREFERENCE.
      #
      # `-v "$PWD:/build"` fails here, and it fails quietly: this runner is itself a container with the
      # host's Docker socket handed to it, so the workspace path it reports — /root/.cache/act/<hash>/…
      # — is a path inside the runner, not on the daemon's host. Docker resolves a bind source on the
      # daemon side, finds nothing there, helpfully creates an empty directory and mounts that. The
      # container then starts perfectly and answers:
      #
      #   MSBUILD : error MSB1009: Project file does not exist.
      #
      # Nothing in that says "empty mount", and nothing earlier in the job would have caught it: the
      # image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so
      # neither of them proves a bind mount would work.
      #
      # `docker cp` goes over the same API and so does not care where the daemon lives. The repository
      # is well under a megabyte packed, so copying the whole checkout in — .git included, because
      # MinVer and the versionCode both read it — costs a moment.
      #
      # The NuGet cache is a named volume for the same reason. It lives on the daemon, needs no path
      # either side can agree on, and turns the restore into a cache hit on every run after the first.
      - name: build and package the nightly
        id: nightly
        run: |
          set -eu

          staged=artifacts/android-nightly
          rm -rf "$staged"

          # Created rather than run, because the copy has to happen between creating and starting: the
          # script being executed arrives with it.
          cid="$(docker create \
            -e CI=true \
            -v dodossh-nuget:/root/.nuget/packages \
            "$ANDROID_BUILD_IMAGE" bash /build/scripts/ci-android.sh)"

          trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT

          # ./. rather than . — with the trailing dot it is the directory's *contents* that land in
          # /build, and without it the whole directory lands as /build/<name> and nothing is where the
          # script expects it.
          docker cp ./. "$cid:/build"

          # --attach streams the build log and exits with the container's own status, so a failure in
          # there is a failure here.
          docker start --attach "$cid"

          mkdir -p artifacts
          docker cp "$cid:/build/$staged" artifacts/

          # Read out of the manifest the container wrote rather than out of anything it printed. A
          # container's stdout is the build log as well as its result, so a value parsed from it is one
          # stray MSBuild line away from being wrong.
          name="$(sed -n 's/.*"versionName":"\([^"]*\)".*/\1/p' "$staged/android-nightly.json")"
          if [ -z "$name" ]; then
            echo "The container produced no usable manifest." >&2
            exit 1
          fi

          echo "staged=$staged" >> "$GITHUB_OUTPUT"
          echo "version=$name" >> "$GITHUB_OUTPUT"
          ls -la "$staged"


      # This runner is Alpine, whose image carries busybox wget and no curl at all — and the step below
      # talks to the forge six times, so it died on the first call that was not written to tolerate a
      # failure: `curl: command not found`, exit 127, after the whole APK had been built.
      #
      # It took that long to say so because the two lookups before it end in `|| true` with stderr
      # discarded, which is the right shape for "no nightly release exists yet" and indistinguishable
      # from "there is no curl on this machine". Installing it here is what makes those two different
      # again; the alternative of dropping the `|| true` would fail the step for a first-ever run.
      #
      # Not busybox wget instead. The asset upload is a multipart -F, which busybox wget cannot send,
      # so the channel would publish a release with no APK on it — the exact failure the verification
      # at the end of the step below exists to catch.
      #
      # Conditioned exactly like the step it serves, since nothing else in this job wants curl. The two
      # conditions have to stay the same: loosen that one without loosening this one and the publish is
      # back to exit 127, several minutes into the job.
      - name: ensure curl
        if: github.ref == 'refs/heads/main'
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          if ! command -v curl >/dev/null 2>&1; then
            echo "Installing curl"
            if command -v apk >/dev/null 2>&1; then
              $SUDO apk add --no-cache curl
            elif command -v apt-get >/dev/null 2>&1; then
              $SUDO apt-get update -qq
              $SUDO apt-get install -y --no-install-recommends curl
            elif command -v dnf >/dev/null 2>&1; then
              $SUDO dnf install -y curl
            else
              echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
              echo "Add curl to the runner's image; the nightly cannot be published without it." >&2
              exit 1
            fi
          fi
          curl --version | head -1

      # ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
      #
      # Whoever can write a release here can put a build on every nightly phone, because the client fetches
      # from this feed and Android's only check is that the signature matches — and this channel's key is
      # in the repository for everybody. That is the same capability as the signing key, reached through a
      # different door, and it is exactly what ADR 0011 rule 1 keeps off runners.
      #
      # It is acceptable here for one reason: this is not that channel. A nightly is signed by a key with
      # no secrecy to lose, installs under its own package id, and cannot update the application anybody
      # is trusting with their credentials. The release channel has none of this — no job, no token, no
      # key on a runner — and the two are separate applications so that no mistake here can reach it.
      #
      # main only. A tag build must not touch this: a v* tag is the release channel's, and cutting it is a
      # person's job.
      - name: publish the nightly
        if: github.ref == 'refs/heads/main'
        env:
          FORGE: https://git.dodotech.cloud
          REPO: DodoTech-Public/DodoSSH
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
          STAGED: ${{ steps.nightly.outputs.staged }}
          VERSION: ${{ steps.nightly.outputs.version }}
        run: |
          set -euo pipefail

          if [ -z "${TOKEN:-}" ]; then
            echo "No token, so the nightly was built and not published." >&2
            echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
            echo "without it, and the job's contents: write permission is what asks for it." >&2
            exit 1
          fi

          api="$FORGE/api/v1/repos/$REPO"
          auth="Authorization: token $TOKEN"

          # ◆ THE FIRST "id" IN THE DOCUMENT, AND NOT THE LAST ONE A GREEDY MATCH LANDS ON.
          #
          # This was `sed -n 's/.*"id":\([0-9]*\).*/\1/p'`, and the leading .* is greedy, so it walked past
          # the release's own id to the last "id": in the response — 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. A
          # release had been created and the step failed saying none had.
          #
          # It broke both readings, and the other one silently: the existing-release lookup below got the
          # same empty id, so the delete never fired, so the second run would have failed to create a
          # release for a tag that already had one. A rolling channel that works exactly once.
          #
          # grep matches left to right and [0-9]\+ needs a digit, so the author's -2 cannot match at all
          # and head -1 takes the release's. jq would say this in four characters and is not on this
          # runner; anything parsing JSON with a regex should say which assumption it is making, and this
          # one is: the release id is the first "id": with digits after it.
          release_id() {
            grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
          }

          # Deleted and recreated rather than updated in place. A rolling tag has to move, and moving one
          # through this API is two calls with no atomic form either way — so the shape with the fewest
          # states is to remove both and make them again. The window where no nightly exists is a few
          # seconds and the client's answer to it is the same as to an unreachable forge: try later.
          existing="$(curl -fsS -H "$auth" "$api/releases/tags/nightly" 2>/dev/null || true)"
          if [ -n "$existing" ]; then
            id="$(printf '%s' "$existing" | release_id)"
            if [ -n "$id" ]; then
              echo "Removing the previous nightly release $id"
              curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
            else
              echo "A nightly release exists and its id could not be read:" >&2
              printf '%s\n' "$existing" >&2
              exit 1
            fi
          fi
          curl -fsS -X DELETE -H "$auth" "$api/tags/nightly" >/dev/null 2>&1 || true

          created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
            -d "$(printf '{"tag_name":"nightly","target_commitish":"%s","name":"Nightly %s","prerelease":true,"body":"Built from %s by CI, signed with the public nightly key. Installs beside the release build, never over it. See docs/adr/0014-android-updates.md."}' \
                    "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
            "$api/releases")"

          release="$(printf '%s' "$created" | release_id)"
          if [ -z "$release" ]; then
            echo "Gitea accepted the release call and returned no id:" >&2
            printf '%s\n' "$created" >&2
            exit 1
          fi

          # The APK first and the manifest last, which is the ordering the client depends on: it reads the
          # manifest and then fetches what the manifest names, so a manifest published before its APK is a
          # few seconds in which every phone is told to download something that is not there yet.
          names=""
          for file in "$STAGED"/*.apk "$STAGED"/android-nightly.json; do
            name="$(basename "$file")"
            echo "Uploading $name ($(wc -c < "$file") bytes)"
            curl -fsS -X POST -H "$auth" \
              -F "attachment=@$file" \
              "$api/releases/$release/assets?name=$name" >/dev/null
            names="$names $name"
          done

          # Asked for back rather than assumed, because the failure this whole channel is exposed to is a
          # release that exists and carries nothing — which is what a phone sees as a feed it can read and
          # never update from, and what a person sees as a release page offering source tarballs. That is
          # exactly what the run before this one left behind, and it reported success for every upload it
          # never made.
          published="$(curl -fsS -H "$auth" "$api/releases/$release")"
          for name in $names; do
            if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
              echo "The release was created but $name is not on it:" >&2
              printf '%s\n' "$published" >&2
              exit 1
            fi
          done

          echo "Published nightly $VERSION with$names"

  desktop:
    name: desktop nightly
    # Gated on the tests, like the image job and for a stronger version of its reason: this one is
    # installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
    # commit whose suite was red.
    needs: [build]
    runs-on: [linux]
    # main only, and the whole job rather than its last step. A v* tag belongs to the release channel,
    # which no runner may publish — ADR 0013 rule 3 — and the desktop head is already built and packed
    # on tags by the build job above, so there is nothing here a tag build would gain.
    if: github.ref == 'refs/heads/main'
    # Writes the rolling nightly release at the end of the job. Job-scoped, so no other job in this file
    # gains it; see the publish step for what the capability is and why this channel may hold it.
    permissions:
      contents: write
    steps:
      # Duplicated from the build job; see the comment there for why it cannot be factored out. There
      # are four copies now, and any change has to be made in all four.
      - name: ensure node and git
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          missing=""
          command -v node >/dev/null 2>&1 || missing="$missing nodejs"
          command -v git  >/dev/null 2>&1 || missing="$missing git"

          if [ -z "$missing" ]; then
            echo "node $(node --version), git $(git --version)"
            exit 0
          fi

          echo "Installing:$missing"
          if command -v apt-get >/dev/null 2>&1; then
            $SUDO apt-get update -qq
            $SUDO apt-get install -y --no-install-recommends $missing
          elif command -v apk >/dev/null 2>&1; then
            $SUDO apk add --no-cache $missing
          elif command -v dnf >/dev/null 2>&1; then
            $SUDO dnf install -y $missing
          else
            echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
            echo "job. Point the runner's container.image at something that ships node." >&2
            exit 1
          fi
          echo "node $(node --version), git $(git --version)"

          major="$(node --version | sed 's/^v//; s/\..*//')"
          if [ "$major" -lt 20 ]; then
            echo "::warning::node $major is older than the runtime these actions target;" \
                 "give the runner an image with node 20 or newer if actions misbehave."
          fi

      # fetch-depth 0 for the reason the other three jobs give, and here it decides what gets published:
      # MinVer's answer is this build's version and the number a nightly client compares against.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: '**/packages.lock.json'

      # Before the version is read, and that ordering is not tidiness. MinVer arrives as a package, so its
      # target does not exist until a restore has written obj/*.nuget.g.targets — and `-t:MinVer` on an
      # unrestored project fails MSB4057 "the target does not exist", which reads like a typo in this file
      # rather than like a missing restore. The build job's tag check is safe because it runs after that
      # job's own restore; this job has none, so it needs this one.
      #
      # Locked, like the solution restore in the build job. The RID-specific restore the publish needs is
      # unlocked and asks for that itself, exactly as the build job's publish does.
      - name: restore
        run: dotnet restore src/DodoSSH.Client.App/DodoSSH.Client.App.csproj --locked-mode

      # ◆ THE VERSION IS DECIDED ONCE HERE AND THEN FORCED ON EVERYTHING.
      #
      # MinVer's own answer is not usable as it stands: until a v* tag exists it is 0.0.0-alpha.0.N, and
      # vpk refuses to pack anything below 0.0.1. The floor is applied to the *whole build* rather than to
      # the packaging alone, through MinVerVersionOverride, and that is the part worth understanding.
      #
      # Packing a version the assemblies disagree with would put one number in the installer and another
      # on the preferences screen — the screen a person reads when asked which nightly they are on, and
      # the number they would then quote into an issue that nobody can match to a build. MinVer sets both
      # Version and InformationalVersion from the override, so the two cannot drift.
      #
      # Monotonic across the boundary, which is what a feed needs: heights keep rising within a floored
      # version, and the first real tag moves the whole number up past every floored one.
      - name: the nightly version
        id: version
        run: |
          set -euo pipefail

          # -t:MinVer for the reason the tag check in the build job spells out: without a target named,
          # -getProperty answers the SDK default and every nightly would claim the same version forever.
          version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
                       -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"

          if [ -z "$version" ]; then
            echo "Could not read the version from MSBuild." >&2
            exit 1
          fi

          case "$version" in
            0.0.0*)
              floored="0.0.1${version#0.0.0}"
              echo "MinVer says $version, which vpk will not pack; this nightly is $floored."
              version="$floored"
              ;;
          esac

          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "$version"

      # DodoChannel=nightly is what makes this a different application rather than the same one built
      # again: it puts the channel in the assembly, which is where DesktopChannel reads it to pick the
      # feed to poll, whether to accept prereleases, and which profile directory to keep a cache in.
      #
      # RestoreLockedMode=false for the RID, exactly as the build job's publish does — see the long note
      # there. This checkout is thrown away, so the lock files it rewrites go nowhere.
      - name: publish the nightly
        env:
          VERSION: ${{ steps.version.outputs.version }}
        run: >
          dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
          --configuration Release --runtime win-x64 --self-contained true
          -p:RestoreLockedMode=false
          -p:DodoChannel=nightly
          -p:MinVerVersionOverride="$VERSION"
          --output "$RUNNER_TEMP/nightly-win-x64"

      # ◆ A DIFFERENT PACK ID, A DIFFERENT CHANNEL, A DIFFERENT TITLE. ALL THREE, AND NONE IS COSMETIC.
      #
      # packId decides where Velopack installs and what an installed client matches an update against, so
      # DodoSSH.Desktop.Nightly is what makes this install *beside* the release build rather than over it,
      # and what stops either feed's package being applied to the other's install.
      #
      # channel decides the name of the index file on the feed — releases.win-nightly.json — and it is a
      # contract with VelopackUpdateChannel.NightlyChannel. Disagree on this word and the channel answers
      # nothing, forever, with no error anywhere.
      #
      # title is what a person reads in the Start menu and in Add/Remove Programs, and it is the only one
      # of the three they will ever see. Two entries both called DodoSSH would be the whole benefit of
      # installing side by side, thrown away at the last step.
      #
      # No `vpk download` and so no deltas: this channel deletes its previous release on every push, so
      # there would be nothing on the feed for a delta to be applied against. A nightly update is a full
      # download, which is the honest cost of a rolling channel that keeps exactly one build.
      - name: package the nightly
        env:
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          set -euo pipefail

          dotnet tool restore

          dotnet vpk '[win]' pack \
            --skip-updates \
            --packId DodoSSH.Desktop.Nightly \
            --packVersion "$VERSION" \
            --packDir "$RUNNER_TEMP/nightly-win-x64" \
            --packTitle 'DodoSSH Nightly' \
            --packAuthors DodoTech \
            --mainExe DodoSSH.exe \
            --icon src/DodoSSH.Client.App/Assets/dodossh.ico \
            --runtime win-x64 \
            --channel win-nightly \
            --outputDir "$RUNNER_TEMP/nightly-releases"

          ls -la "$RUNNER_TEMP/nightly-releases"

      # ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
      #
      # Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
      # from this feed and applies what it finds without verifying a signature. That is the same capability
      # as a signing key reached through a different door, and ADR 0013 rule 3 keeps it off runners.
      #
      # It is acceptable here for the reason the android nightly gives, and only for that reason: this is
      # not that channel. A nightly is a separate application with its own pack id, its own install
      # directory and its own profile, and it cannot update the build anybody is trusting with their
      # credentials — the release channel reads a different index and refuses prereleases, so it cannot
      # even see this one. Anybody installing a nightly is trusting everyone who can write to this
      # repository, which is a thing to know rather than a thing to discover; the README says so.
      #
      # ◆ DELETED AND RECREATED RATHER THAN ADDED TO.
      #
      # A rolling channel has to keep exactly one build, and every asset here is version-named, so merging
      # would grow the release by a hundred and twenty megabytes per push until the forge said no. There is
      # no atomic form of this in the API, so the shape with the fewest states is to remove both the
      # release and its tag and make them again. The window where no nightly exists is a few seconds, and
      # the client's answer to it is the same as to an unreachable forge: the timer swallows it and tries
      # later; a pressed CHECK NOW says so.
      - name: publish the nightly release
        env:
          FORGE: https://git.dodotech.cloud
          REPO: DodoTech-Public/DodoSSH
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.version.outputs.version }}
          TAG: nightly-desktop
        run: |
          set -euo pipefail

          if [ -z "${TOKEN:-}" ]; then
            echo "No token, so the nightly was built and not published." >&2
            echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
            echo "without it, and the job's contents: write permission is what asks for it." >&2
            exit 1
          fi

          api="$FORGE/api/v1/repos/$REPO"
          auth="Authorization: token $TOKEN"

          # The first "id" with digits after it, for the reason the android job records at length: a
          # greedy .* walks past the release's own id to the author's, which is -2, and yields nothing.
          release_id() {
            grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
          }

          existing="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null || true)"
          if [ -n "$existing" ]; then
            id="$(printf '%s' "$existing" | release_id)"
            if [ -n "$id" ]; then
              echo "Removing the previous $TAG release $id"
              curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
            else
              echo "A $TAG release exists and its id could not be read:" >&2
              printf '%s\n' "$existing" >&2
              exit 1
            fi
          fi
          curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true

          # vpk rather than curl, unlike the android job above, and the difference is what is being
          # uploaded. An APK is one file the client is told about by a manifest this repository writes; a
          # Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
          # index by hand would be reimplementing the tool that is already here.
          #
          # --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
          # prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
          # channel reads: 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 update check
          # against a release carrying no android manifest.
          dotnet vpk upload gitea \
            --skip-updates \
            --repoUrl "$FORGE/$REPO" \
            --token "$TOKEN" \
            --outputDir "$RUNNER_TEMP/nightly-releases" \
            --channel win-nightly \
            --tag "$TAG" \
            --releaseName "Nightly desktop $VERSION" \
            --targetCommitish "$GITHUB_SHA" \
            --pre \
            --publish

          # Asked for back rather than assumed, and the android job's history is why: it once created a
          # release, uploaded nothing, and reported success for every upload it never made. A nightly
          # desktop feed that exists and carries no index is a client that checks, finds nothing, and
          # reports itself up to date forever.
          published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"

          for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
            if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
              echo "The release was created but $name is not on it:" >&2
              printf '%s\n' "$published" >&2
              exit 1
            fi
          done

          echo "Published nightly desktop $VERSION as $TAG"

  image:
    name: api image
    # Gated on the tests rather than parallel with them, which costs a few minutes of wall
    # clock on every main commit and buys the thing worth having: no image reaches the
    # registry from a commit whose tests were red. An image is not a build artefact anyone
    # inspects — it is the thing that gets deployed.
    needs: [build]
    runs-on: [linux]
    steps:
      # Duplicated from the build job; see the comment there for why it cannot be factored
      # out. Any change here has to be made in all three.
      - name: ensure node and git
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          missing=""
          command -v node >/dev/null 2>&1 || missing="$missing nodejs"
          command -v git  >/dev/null 2>&1 || missing="$missing git"

          if [ -z "$missing" ]; then
            echo "node $(node --version), git $(git --version)"
            exit 0
          fi

          echo "Installing:$missing"
          if command -v apt-get >/dev/null 2>&1; then
            $SUDO apt-get update -qq
            $SUDO apt-get install -y --no-install-recommends $missing
          elif command -v apk >/dev/null 2>&1; then
            $SUDO apk add --no-cache $missing
          elif command -v dnf >/dev/null 2>&1; then
            $SUDO dnf install -y $missing
          else
            echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
            echo "job. Point the runner's container.image at something that ships node." >&2
            exit 1
          fi
          echo "node $(node --version), git $(git --version)"

          # Warned about rather than failed on. Distributions pin their nodejs package to
          # the release they shipped with — Ubuntu 24.04 still serves 18, which is past end
          # of life and older than the runtime these actions declare. It generally runs
          # them anyway, since act_runner uses whichever node is on PATH regardless of what
          # the action asked for, so this is a note for when one of them misbehaves in a
          # way that makes no sense, not a reason to stop a build that is probably fine.
          major="$(node --version | sed 's/^v//; s/\..*//')"
          if [ "$major" -lt 20 ]; then
            echo "::warning::node $major is older than the runtime these actions target;" \
                 "give the runner an image with node 20 or newer if actions misbehave."
          fi

      # fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
      # nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
      # it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
      # whether an installed client is out of date by comparing versions, which makes a plausible wrong
      # answer here a client that never updates.
      #
      # Repeated in all three jobs, like the node preamble above and for the same reason. Change one
      # copy, change all three.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0

      # The daemon and the client are two different things to have, and this runner had only
      # one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
      # .NET library, so every integration suite in the build job passed while `docker` was
      # not a command here at all — which surfaced as exit 127 from the build step below,
      # after the tags had been worked out and everything looked healthy.
      #
      # Only the CLI. The socket is already there and a daemon is already answering on it;
      # installing an engine would start a second one beside the one being used.
      - name: ensure the docker cli
        run: |
          set -eu
          SUDO=""
          [ "$(id -u)" -eq 0 ] || SUDO="sudo"

          if ! command -v docker >/dev/null 2>&1; then
            echo "Installing the docker cli"
            if command -v apk >/dev/null 2>&1; then
              $SUDO apk add --no-cache docker-cli
            elif command -v apt-get >/dev/null 2>&1; then
              $SUDO apt-get update -qq
              $SUDO apt-get install -y --no-install-recommends docker.io
            elif command -v dnf >/dev/null 2>&1; then
              $SUDO dnf install -y docker-cli
            else
              echo "No apt-get, apk or dnf here, so the docker client cannot be installed from" >&2
              echo "inside the job. Add it to the runner's image." >&2
              exit 1
            fi
          fi
          docker --version

          # buildx after the client, and wanted rather than required. Without the plugin
          # `docker build` falls back to the legacy builder, which still produces the image
          # and says on every run that it will not do so forever; with it the same command
          # routes through BuildKit and the Dockerfile's independent stages stop being
          # serialised. Alpine's docker-cli package does not carry it, which is why a job
          # that had just been given a working client still built the deprecated way.
          #
          # A distribution with no package for it should get a warning and an image, not a
          # failed release — so every branch here ends in `|| true` and the check below
          # reports rather than exits.
          if ! docker buildx version >/dev/null 2>&1; then
            echo "Installing buildx"
            if command -v apk >/dev/null 2>&1; then
              $SUDO apk add --no-cache docker-cli-buildx || true
            elif command -v apt-get >/dev/null 2>&1; then
              $SUDO apt-get update -qq || true
              $SUDO apt-get install -y --no-install-recommends docker-buildx || true
            elif command -v dnf >/dev/null 2>&1; then
              $SUDO dnf install -y docker-buildx || true
            fi
          fi

          if docker buildx version >/dev/null 2>&1; then
            docker buildx version
          else
            echo "::warning::buildx is unavailable, so this image was built by the legacy" \
                 "builder Docker has deprecated. Add a buildx package to the runner image."
          fi

      # No docker/* actions here, deliberately. The build is single-architecture, so it
      # wants the daemon this runner already has, a client, and BuildKit — all of which the
      # step above arranges with two packages. What it does not want is QEMU, a builder
      # instance to create and tear down, or a third-party action whose SHA has to be
      # audited and re-pinned on a schedule. Adding linux/arm64 later is where that trade
      # changes, and where setup-buildx-action starts earning its place.
      - name: work out the tags
        id: tags
        env:
          REGISTRY: registry-docker.dodotech.cloud
          IMAGE: dodotech/dodossh-api
        run: |
          set -euo pipefail
          repo="$REGISTRY/$IMAGE"
          short="$(git rev-parse --short HEAD)"

          # sha- prefixed, because a bare hex tag is ambiguous with a digest to both a human
          # and a fair amount of tooling. This one is on every build and never moves, which
          # makes it the only tag safe to pin a deployment to.
          tags="$repo:sha-$short"
          version="$short"

          case "$GITHUB_REF" in
            refs/tags/v*)
              v="${GITHUB_REF#refs/tags/v}"
              version="$v"
              tags="$tags $repo:$v"
              # The moving major.minor tag and :latest, but only for a release proper.
              # v1.3.0-rc1 sorts after v1.2.9 and would otherwise take :latest with it,
              # which is how a release candidate ends up on somebody's server.
              case "$v" in
                *-*) ;;
                *)
                  tags="$tags $repo:${v%.*}"
                  tags="$tags $repo:latest"
                  ;;
              esac
              ;;
            refs/heads/main)
              tags="$tags $repo:main"
              version="main-$short"
              ;;
          esac

          # The version MSBuild is allowed to see, which is not the same string as the one above.
          # `version` is a docker tag and is `main-<short sha>` on a main build; handing that to
          # -p:Version fails the publish with NETSDK1018. So this is set only when it is a real
          # version, and the Dockerfile leaves the SDK default alone when it is empty.
          assembly_version=""
          case "$GITHUB_REF" in
            refs/tags/v*) assembly_version="${GITHUB_REF#refs/tags/v}" ;;
          esac

          echo "tags=$tags" >> "$GITHUB_OUTPUT"
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "assemblyVersion=$assembly_version" >> "$GITHUB_OUTPUT"
          echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
          echo "Tagging: $tags"

      # Every value from a step output or the event goes through env rather than being
      # interpolated into the script text. A git tag may contain a semicolon, and
      # `${{ }}` is a textual substitution performed before the shell ever sees the line —
      # so an interpolated tag name is a command the workflow agreed to run.
      - name: build the image
        env:
          TAGS: ${{ steps.tags.outputs.tags }}
          VERSION: ${{ steps.tags.outputs.version }}
          ASSEMBLY_VERSION: ${{ steps.tags.outputs.assemblyVersion }}
          REVISION: ${{ github.sha }}
          CREATED: ${{ steps.tags.outputs.created }}
        run: |
          set -euo pipefail
          args=()
          for tag in $TAGS; do
            args+=(--tag "$tag")
          done
          # --pull rather than whatever the runner happens to have cached: the base images
          # are floating tags, and a runner that has held aspnet:10.0-noble-chiseled for a
          # month is a month of unapplied CVE fixes shipping in every image built on it.
          docker build \
            --pull \
            --file src/DodoSSH.Api/Dockerfile \
            --build-arg "VERSION=$VERSION" \
            --build-arg "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" \
            --build-arg "REVISION=$REVISION" \
            --build-arg "CREATED=$CREATED" \
            "${args[@]}" \
            .

      # Everything above runs on a pull request too. Building a fork's Dockerfile is safe —
      # nothing is pushed and no credential is in scope — and it means a change that breaks
      # the image fails on the PR rather than on main. Only these last two steps are held
      # back, and the condition is on the event rather than on the branch so that a PR
      # targeting main cannot reach them.
      - name: log in to the registry
        if: github.event_name != 'pull_request'
        env:
          REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        run: |
          set -eu

          # Checked before use, because an unset secret is not an error anywhere upstream of
          # here: an expression that resolves to nothing renders as the empty string, so
          # docker is handed --username "" and answers with something about credentials,
          # which sends people to the registry to debug a value that never left the
          # settings page.
          #
          # Note for anyone editing this comment: an expression delimiter written literally
          # here is interpolated even though this is a shell comment. The runner substitutes
          # the whole script before any shell sees it, so an empty one fails the step with a
          # parse error and no line number — which is how this very block broke the release
          # it was added to protect.
          #
          # Reported by length, and never 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 being asked: did anything arrive.
          missing=""
          [ -n "${REGISTRY_USERNAME:-}" ] || missing="$missing REGISTRY_USERNAME"
          [ -n "${REGISTRY_PASSWORD:-}" ] || missing="$missing REGISTRY_PASSWORD"

          if [ -n "$missing" ]; then
            echo "Empty or unset:$missing" >&2
            echo >&2
            echo "Both come from repository secrets, which in Gitea are at" >&2
            echo "  Settings -> Actions -> Secrets" >&2
            echo "and are a different page from Settings -> Actions -> Variables. A value" >&2
            echo "added as a variable is invisible to the secrets context and arrives here" >&2
            echo "as an empty string, which is exactly what this message means." >&2
            exit 1
          fi

          echo "username: ${#REGISTRY_USERNAME} characters; password: set"

          printf '%s' "$REGISTRY_PASSWORD" \
            | docker login registry-docker.dodotech.cloud \
                --username "$REGISTRY_USERNAME" --password-stdin

      - name: push
        if: github.event_name != 'pull_request'
        env:
          TAGS: ${{ steps.tags.outputs.tags }}
        run: |
          set -euo pipefail
          for tag in $TAGS; do
            docker push "$tag"
          done

      # The daemon is shared with every other job on this runner, and a credential left in
      # ~/.docker/config.json outlives the job that created it. always(), so a failed push
      # does not leave it behind.
      - name: log out
        if: always() && github.event_name != 'pull_request'
        run: docker logout registry-docker.dodotech.cloud

# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
# install directory and profile — and the distance between those two sentences is the whole design. What
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
#
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
#
# It used to say that vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling and
# so could not run on a Linux runner, and offered that as the smaller of two reasons. It was wrong, and
# worth recording as wrong because it is the kind of claim that discourages anybody from trying: vpk
# cross-compiles when told to, `vpk [win] pack` on this runner builds the setup stub, the portable zip
# and the .nupkg, and only signing needs Windows — which this repository does not do yet. That is what
# the packaging step now does on every main and tag build.
#
# The reason that stands is the one that was never mechanical. Velopack clients fetch from the release
# feed and do not verify a package signature when they apply it, so whoever can write a release on this
# repository can publish an update that every installed client downloads and runs. That is the same
# capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts that
# capability on a machine which is not a runner, because a workflow secret is held by everyone who can
# change a workflow file. A Windows runner would never have answered it, which is why fixing the
# mechanical half changes nothing about the division of labour.
# See docs/adr/0013-desktop-distribution-and-updates.md.
#
# What cuts a release is scripts/release-windows.ps1, run by a person. What this file does is prove the
# thing still builds and packages, which is the same division of labour the android job above already
# has: it packages an APK nobody installs, so that a link-time break fails here rather than later.
