11.1 KiB · text History e380962
name: ci

on:
  push:
    branches: [master]
    tags: ["v*"]
  pull_request:

jobs:
  test:
    # Self-hosted ARC runners (same as ../angler). Swap for `ubuntu-latest`
    # if you'd rather use GitHub-hosted runners. The Postgres service container
    # requires the runner to support service containers (Docker/dind).
    runs-on: arc-runner-set
    env:
      MIX_ENV: test
      # Under forgejo-runner/act the job runs in a container, so the postgres
      # service is reached by its service name, not localhost.
      POSTGRES_HOST: postgres
      # service is reached at its container port under forgejo-runner
      POSTGRES_PORT: 5432
      # Cap the test DB pool below the Postgres service's default
      # max_connections (100); big CI runners otherwise scale pool to cores×2
      # and exhaust connections.
      POOL_SIZE: "40"
    services:
      postgres:
        image: docker.io/postgres:16-alpine
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        # config/test.exs connects on 54329 (matches compose.yml), so publish
        # the service container's 5432 on that host port.
        ports:
          - 54329:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      # ARC's default runner image is bare; install the toolchain needed to
      # compile native deps.
      - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends make build-essential
      # Forge fork of erlef/setup-beam: defaults ImageOS to ubuntu-24.04,
      # since forgejo/act injects an unmappable ImageOS from the runs-on label.
      - uses: https://gg.neiam.co/neiam/action-setup-beam@v1
        with:
          # Match the build image in the Dockerfile (OTP >= 27.3.3; 27.2 breaks
          # `mix local.hex` against hex.pm's current TLS chain).
          elixir-version: "1.17.3"
          otp-version: "27.3.4.11"

      - uses: actions/cache@v4
        with:
          path: |
            deps
            _build
          key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
          restore-keys: ${{ runner.os }}-mix-

      - run: mix deps.get

      # `mix precommit` = compile --warnings-as-errors, deps.unlock --unused,
      # format, test (which creates + migrates the test DB).
      - run: mix precommit

  image:
    needs: test
    runs-on: arc-runner-set
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      # The forge's own registry, when this is running on one. Unlike the
      # three public registries below it needs no stored credentials: the
      # per-job token is already authorized to push to this repository's
      # own image path and nowhere else.
      #
      # `vars.CI_REGISTRY` is what says whether there is one -- on a forge
      # that doesn't set it, or on GitHub, every forge step below drops
      # out and the public registries are unaffected.
      - name: Resolve the forge registry
        id: forge
        env:
          CI_REGISTRY: ${{ vars.CI_REGISTRY }}
        run: |
          set -euo pipefail

          images=(
            "ghcr.io/$GITHUB_REPOSITORY"
            "docker.io/neiam/diogramos"
            "quay.io/neiam/diogramos"
          )

          if [ -n "${CI_REGISTRY:-}" ]; then
            forge_image="$CI_REGISTRY/$GITHUB_REPOSITORY"
            echo "publishing to the forge registry at $forge_image"

            images+=("$forge_image")
            echo "registry=$CI_REGISTRY" >> "$GITHUB_OUTPUT"
            echo "image=$forge_image" >> "$GITHUB_OUTPUT"
          else
            echo "No CI_REGISTRY variable set -- skipping the forge registry."
          fi

          {
            echo "images<<IMAGES_EOF"
            printf '%s\n' "${images[@]}"
            echo "IMAGES_EOF"
          } >> "$GITHUB_OUTPUT"

      # The token endpoint ignores the username but login-action requires
      # it to be non-empty.
      - name: Log in to the forge registry
        if: steps.forge.outputs.registry != '' && github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ steps.forge.outputs.registry }}
          username: gitgud-actions
          password: ${{ github.token }}

      # GHCR uses the built-in token. Docker Hub and Quay creds come from
      # org-wide secrets (DOCKERHUB_USER/DOCKERHUB_TOKEN, QUAY_USERNAME/QUAY_TOKEN).
      - name: Log in to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ vars.GHCR_USER || github.actor }}
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: docker.io
          username: ${{ secrets.DOCKERHUB_USER }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Log in to Quay
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: quay.io
          username: ${{ secrets.QUAY_USERNAME }}
          password: ${{ secrets.QUAY_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.forge.outputs.images }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}

      # The layer cache lives in the forge registry alongside the image.
      # `type=gha` is not an option here: the forge's RunnerJwt carries no
      # `ac` claim, so the Actions cache service rejects it. Writing the
      # cache is gated on a real push -- a pull request's token can read
      # that path but not write it.
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: ${{ steps.forge.outputs.image != '' && format('type=registry,ref={0}:buildcache', steps.forge.outputs.image) || '' }}
          cache-to: ${{ steps.forge.outputs.image != '' && github.event_name != 'pull_request' && format('type=registry,ref={0}:buildcache,mode=max', steps.forge.outputs.image) || '' }}

  # The Android client, published to the forge's own F-Droid repository.
  #
  # Needs two secrets, and skips cleanly without them rather than failing
  # a build for a package most forks will not publish:
  #
  #   ANDROID_KEYSTORE       base64 of the release keystore (.jks)
  #   ANDROID_KEYSTORE_PASS  its password
  #
  # Generate one once and keep it. Android checks the signing certificate
  # on every update, so losing it means every install has to be
  # uninstalled and redone by hand -- there is no rotation:
  #
  #   keytool -genkeypair -v -keystore release.jks -storetype PKCS12 \
  #     -alias diogramos -keyalg RSA -keysize 4096 -validity 10000 \
  #     -dname "CN=Diogramos, O=neiam, C=US"
  #   base64 -w0 release.jks
  #
  # This is the APK signing key, not the F-Droid index key -- that one is
  # per-owner, lives in OpenBao, and never comes near CI.
  android:
    needs: test
    runs-on: arc-runner-set
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - name: Resolve the forge URL
        env:
          CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
        run: |
          set -euo pipefail
          forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}

          if [ -z "$forge_url" ]; then
            echo "No forge URL available; set a CI_FORGE_URL variable." >&2
            exit 1
          fi

          echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"

      # A secret cannot be read in a job-level `if`, so the gate is a step
      # output that everything after it checks.
      - name: Unpack the signing keystore
        id: keystore
        env:
          KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
          KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
        run: |
          set -euo pipefail

          if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
            echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set -- skipping."
            echo "An unsigned or debug-signed APK is worse than none: the debug"
            echo "key is shared by every developer with an SDK, so publishing"
            echo "one would let anyone ship an 'update' to it."
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          umask 077
          printf '%s' "$KEYSTORE" | base64 -d > release.jks
          printf '%s' "$KEYSTORE_PASS" > keystore.pass
          echo "skip=false" >> "$GITHUB_OUTPUT"

      - name: Build and sign the APK
        if: steps.keystore.outputs.skip != 'true'
        env:
          RUN_NUMBER: ${{ github.run_number }}
        run: |
          set -euo pipefail

          # One stamp for the whole run, computed here rather than inside
          # Gradle, so every artifact of a run agrees on its version even
          # if the build is retried a step at a time.
          stamp=$(date -u +%s)
          echo "version: 0.1.0-dev-$stamp (versionCode $RUN_NUMBER)"

          docker buildx build \
            --target android-export \
            --build-arg GITHUB_RUN_NUMBER="$RUN_NUMBER" \
            --build-arg SOURCE_DATE_EPOCH="$stamp" \
            --secret id=keystore,src=./release.jks \
            --secret id=keystore_password,src=./keystore.pass \
            --output type=local,dest=./apk \
            --file android/Dockerfile android

      # Gone before anything else runs, so a later step or a failure
      # mid-job cannot leave it on a persistent runner.
      - name: Shred the keystore
        if: always()
        run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass

      - name: Publish to the F-Droid repository
        if: steps.keystore.outputs.skip != 'true'
        env:
          REPOSITORY: ${{ github.repository }}
          TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          curl --fail-with-body -sS \
            -u "diogramos-actions:$TOKEN" \
            -H "Content-Type: application/vnd.android.package-archive" \
            --data-binary "@apk/app-release.apk" \
            "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
          echo

      - name: How to install it
        if: steps.keystore.outputs.skip != 'true'
        env:
          OWNER: ${{ github.repository_owner }}
        run: |
          set -euo pipefail
          fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")

          cat <<EOF
          Add this repository in F-Droid (Settings -> Repositories -> +):

            $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint

          One entry covers every Android app $OWNER publishes. The
          fingerprint pins the index signing certificate; F-Droid refuses
          an index signed by anything else.
          EOF