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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 - name: verify formatting run: dotnet format DodoSSH.slnx --verify-no-changes --no-restore # 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 # 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] 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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. - name: install the android workload run: dotnet workload install android --skip-sign-check # 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. - name: install the android sdk platform run: | echo "y" | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ "platforms;android-36" "build-tools;36.0.0" - name: restore run: dotnet restore src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --locked-mode - name: build run: > dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --no-restore --configuration Release # 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. - name: package run: > dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj --no-restore --configuration Release -t:SignAndroidPackage -p:RuntimeIdentifier=android-arm64 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # 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 docker --version exit 0 fi 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 docker --version # No docker/* actions here, deliberately. The build is single-architecture, so it # needs the daemon this runner already has for the Testcontainers suites and nothing # else — no buildx plugin, no QEMU, and no third-party action whose SHA has to be # audited and re-pinned. Adding linux/arm64 later is where that trade changes. - 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 echo "tags=$tags" >> "$GITHUB_OUTPUT" echo "version=$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 }} 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 "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 -euo pipefail 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