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
            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

          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 -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
