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 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' # 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. - 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 -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. vpk can pack a Windows package from Linux; only signing needs Windows, # and this repository signs nothing yet, so proving the publish here is worth the minutes. # # 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 this step 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. - name: the windows publish still resolves 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-check" # 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 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json cache: true cache-dependency-path: '**/packages.lock.json' # 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. # ============ the toolchain this job needs and a bare runner does not have ============ # # This job assumed an image with a JDK and an Android SDK already on it, which is what a GitHub # ubuntu-latest runner is and what this project's own runner is not. Every step below installs one # of the things that assumption was making, and each is a no-op where it is already satisfied. # # The order matters once: the SDK's licence acceptance and every sdkmanager call are Java programs, # so the JDK has to be first. - name: ensure a jdk run: | set -eu SUDO="" [ "$(id -u)" -eq 0 ] || SUDO="sudo" # An existing one is used whatever its provenance — the runner image's, or a previous run's. # 17 is the floor: .NET for Android 36 refuses to start javac below it, and says so in a # message that names a path rather than a version. if command -v javac >/dev/null 2>&1; then have="$(javac -version 2>&1 | sed 's/javac //; s/\..*//')" if [ "${have:-0}" -ge 17 ]; then echo "javac $(javac -version 2>&1)" exit 0 fi echo "javac $have is below 17; installing a newer one" fi if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update -qq $SUDO apt-get install -y --no-install-recommends openjdk-17-jdk-headless elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache openjdk17-jdk elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y java-17-openjdk-devel else echo "No apt-get, apk or dnf here, so a JDK cannot be installed from inside the job." >&2 echo "Add one to the runner's image, or point JAVA_HOME at one." >&2 exit 1 fi # Exported for every later step. dirname twice: `which javac` is .../bin/javac and JAVA_HOME is # the directory above bin. readlink -f because the packaged javac is a symlink into # /usr/lib/jvm, and the link is what the alternatives system points at rather than the real # home the Android SDK wants. home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" echo "JAVA_HOME=$home" >> "$GITHUB_ENV" echo "javac $(javac -version 2>&1) at $home" # The SDK, from Google's own zip rather than a package. There is no distribution package for it that # carries the platform this head needs, and unlike the JDK there is nothing to reuse: the archive is # the supported way to get cmdline-tools and it is what every CI image does behind the scenes. # # Cached by path rather than by an actions/cache, deliberately. This runner is persistent, so the # directory survives between runs and the check below turns the whole step into an echo; a cache # action would upload and download a quarter of a gigabyte to reproduce a directory that never left. - name: ensure the android sdk env: # Pinned, and the number is the commandline-tools release rather than an API level — they are # versioned separately and this one is the current stable. Floating it would make the toolchain # a moving part of every build. CMDLINE_TOOLS: commandlinetools-linux-11076708_latest.zip # Named once here because three things now depend on it being one version: what sdkmanager # installs, what the build is told to use for aapt2, and what the packaging step reads the # versionName back with. BUILD_TOOLS: 36.0.0 run: | set -eu SUDO="" [ "$(id -u)" -eq 0 ] || SUDO="sudo" for tool in curl unzip; do command -v "$tool" >/dev/null 2>&1 && continue echo "Installing $tool" if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update -qq && $SUDO apt-get install -y --no-install-recommends "$tool" elif command -v apk >/dev/null 2>&1; then $SUDO apk add --no-cache "$tool" elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y "$tool" fi done # Under the runner's own tool directory rather than /opt, so it needs no root and survives # between runs on a persistent runner. ANDROID_HOME and ANDROID_SDK_ROOT are both exported # because the .NET Android SDK reads one and sdkmanager reads the other, and a job that set only # one fails halfway through with a message about neither. sdk="${ANDROID_HOME:-$HOME/android-sdk}" mkdir -p "$sdk" if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then echo "Fetching $CMDLINE_TOOLS" curl -fsSL -o /tmp/cmdline-tools.zip \ "https://dl.google.com/android/repository/$CMDLINE_TOOLS" rm -rf /tmp/cmdline-tools-unpacked unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-unpacked # The archive unpacks to a directory called cmdline-tools, and sdkmanager insists on living # at cmdline-tools// — unpacking it in place gives cmdline-tools/cmdline-tools and # every later call fails with "Could not determine SDK root". mkdir -p "$sdk/cmdline-tools" mv /tmp/cmdline-tools-unpacked/cmdline-tools "$sdk/cmdline-tools/latest" rm -f /tmp/cmdline-tools.zip fi echo "ANDROID_HOME=$sdk" >> "$GITHUB_ENV" echo "ANDROID_SDK_ROOT=$sdk" >> "$GITHUB_ENV" export ANDROID_HOME="$sdk" ANDROID_SDK_ROOT="$sdk" # yes rather than echo y: there are several licences and each wants its own answer, so a single # y accepts the first and leaves the rest pending — which surfaces later as a package that # "failed to install" with no reason given. The pipe is allowed to break when sdkmanager exits # first, which is what the || true is for and is not hiding a failure: the install below is # what reports one. yes 2>/dev/null | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true # API 36 specifically, and it is not a preference: Avalonia.Controls.WebView ships only a # net10.0-android36.0 assembly, so anything lower cannot resolve it and the head loses its # terminal. See docs/android-port.md. platform-tools comes along because aapt2 and apksigner # are what the packaging step actually shells out to. "$sdk/cmdline-tools/latest/bin/sdkmanager" \ "platform-tools" "platforms;android-36" "build-tools;$BUILD_TOOLS" # ============ aapt2, which the build is told to take from here and not from the workload ============ # # .NET for Android ships its own aapt2 inside the workload pack, at # packs/Microsoft.Android.Sdk.Linux//tools/Linux/aapt2, and prefers it when Aapt2ToolPath is # unset. On this runner that binary could not be started at all: # # warning : An error occurred trying to start process '.../tools/Linux/aapt2' … No such file # error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux' # # The second line is what the build reports and it is misleading — nothing was found, so nothing # had a version. This runner is persistent (act's host executor, so /usr/share/dotnet survives # between runs), and `dotnet workload install` treats an already-listed workload as satisfied # whatever is actually on disk, so a pack left incomplete by an interrupted install stays # incomplete for every later run. That is a state no step here can detect and none can repair. # # So the build is pointed at the aapt2 from build-tools instead: Google's own, installed by the # line above, in a directory this job creates and can therefore vouch for. It is also 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 same binary that wrote it. # # Verified rather than assumed to be usable, because the failure mode above is precisely a file # that exists and will not run, and because a version check here says so in one line instead of # as an XA0111 four minutes into a build. aapt2="$sdk/build-tools/$BUILD_TOOLS" if ! "$aapt2/aapt2" version; then echo "aapt2 at $aapt2 will not run, so there is no usable one on this runner." >&2 exit 1 fi echo "AAPT2_TOOL_PATH=$aapt2" >> "$GITHUB_ENV" echo "Android SDK at $sdk" # After the SDK, because the workload's own first-run checks look for one and are quieter when they # find it. --skip-sign-check is for the workload package feed, not for anything this project signs. # # The repair is here because `install` is not one. It reads the installed-workload records and does # nothing when android is listed, whatever is on disk — so a pack left half-extracted by an # interrupted run stays half-extracted forever on a runner whose filesystem persists, and every # later build fails on whichever file happened not to make it. This runner has already produced # exactly that, with a missing tools/Linux/aapt2 (see the SDK step above). # # tools/Linux/aapt2 is the probe rather than the point: the build no longer uses that binary at all. # It is a 5 MB file near the end of a 130 MB package, which makes it a good witness for a truncated # extraction — and if it is absent then r8.jar and manifestmerger.jar, which the build does need, # are the next things to go. Repairing costs a re-download and only happens when something is # already wrong; the alternative is a mystery every few months. - name: install the android workload run: | set -eu dotnet workload install android --skip-sign-check root="$(dirname "$(readlink -f "$(command -v dotnet)")")" # tools/Linux/ and not tools/, because the pack's layout is host-shaped: the Linux pack puts its # host binaries under a named directory and the Windows one puts aapt2.exe straight in tools/. # This job is linux-only, so the Linux path is the one to look for and a probe that accepted # either would quietly pass on a pack for the wrong host. if [ -d "$root/packs" ] && ! ls "$root"/packs/Microsoft.Android.Sdk.*/*/tools/Linux/aapt2 >/dev/null 2>&1; then echo "::warning::The android workload pack is missing files; repairing it." dotnet workload repair fi - name: restore run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode # Aapt2ToolPath on both this and the packaging step, not on one: it is a directory the workload # would otherwise pick for itself, and a build that resolved a different aapt2 than the package # step would be a difference nobody could see until one of them failed. See the SDK step. - name: build run: > dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --no-restore --configuration Release -p:Aapt2ToolPath="$AAPT2_TOOL_PATH" # Packaging rather than only compiling, because the two failures this head is most exposed to # are both link-time: a native library with no android ABI, and a managed assembly that # resolves for net10.0 but has nothing to dex. Neither shows up in a compile. # # ◆ THE NIGHTLY CHANNEL, WHICH IS AN INSTALLABLE APPLICATION AND NOT THE ONE. It has its own package # id and is signed by a keystore committed to this repository in the open, so it can neither replace # nor be replaced by the release channel — see the csproj, and ADR 0014. The APK this produces is # meant to be installed; the release APK is cut from a v* tag by a person running # scripts/release-android.ps1, on a machine that holds the key ADR 0011 rule 1 keeps off runners. # # No RuntimeIdentifier, where this step used to pin android-arm64. That produced the smallest # possible build check and the least installable artefact: an arm64-only APK will not run on an # x86_64 emulator, which is what most people testing a nightly actually have. Every supported ABI # costs size on a package nobody ships to users. # # versionCode is the commit count, which is monotonic by construction and needs nobody to remember # anything. It is not a version and is never displayed; versionName carries MinVer's full answer # including the height, which is what tells two nightlies apart. - name: package the nightly id: nightly run: | set -euo pipefail code="$(git rev-list --count HEAD)" dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj \ --no-restore --configuration Release \ -t:SignAndroidPackage \ -p:DodoChannel=nightly \ -p:DodoNightlyVersionCode="$code" \ -p:Aapt2ToolPath="$AAPT2_TOOL_PATH" apk="$(find src/DodoSSH.Client.Android/bin/Release -name '*-Signed.apk' | head -1)" if [ -z "$apk" ]; then echo "The package step produced no signed APK." >&2 exit 1 fi # Read back out of the APK rather than recomputed, so what the feed advertises is what the # bytes say. A versionName derived a second time in shell is a second implementation of the # csproj's target, and the two would drift on the first change to either. badging="$("$AAPT2_TOOL_PATH/aapt2" dump badging "$apk")" name="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)" staged="$RUNNER_TEMP/android-nightly" rm -rf "$staged" mkdir -p "$staged" cp "$apk" "$staged/DodoSSH-nightly-$name.apk" # The channel manifest, which is what the client reads and the whole reason the feed is # machine-readable at all. versionCode is the comparison — it is the number Android itself uses # to accept or refuse an install, so comparing anything else would let the client offer an # update the platform then rejects. versionName is for the person reading the banner. cat > "$staged/android-nightly.json" <> "$GITHUB_OUTPUT" echo "version=$name" >> "$GITHUB_OUTPUT" ls -la "$staged" # ◆ 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/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" # 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" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)" [ -n "$id" ] && curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true 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" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)" 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. for file in "$STAGED"/*.apk "$STAGED"/android-nightly.json; do echo "Uploading $(basename "$file")" curl -fsS -X POST -H "$auth" \ -F "attachment=@$file" \ "$api/releases/$release/assets?name=$(basename "$file")" >/dev/null done echo "Published nightly $VERSION" 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-` 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 # There is no job here that publishes the desktop client, and there is not going to be one. Two # independent reasons, and both need saying because someone will fix one and think they are done. # # The smaller one is mechanical: vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows # tooling, and every job in this file is runs-on: [linux]. A Windows runner would answer that. # # The larger one is that a Windows runner would not answer the other. 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. 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.