# Build + push the API and web container images to the git.mancinas.io registry.
#
# Two images from this one repo:
#   git.mancinas.io/rmancinas/jorgecuadros-api
#   git.mancinas.io/rmancinas/jorgecuadros-web
#
# Comprehensive versioning (docker/metadata-action). Every build pushes a set
# of tags so an image is addressable at several granularities:
#   - vX.Y.Z / vX.Y      when the trigger is a git tag  vX.Y.Z   (releases)
#   - <branch>           the branch that was pushed (e.g. master, feat-foo)
#   - sha-<short>        immutable per-commit id, always present
#   - latest             only on the default branch (master)
# The same version string + commit + build date are baked into the image as
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
# container can report exactly what is deployed.
#
# Release flow:  git tag v1.2.0 && git push origin v1.2.0  -> versioned images
#                -> deploy-galactus.yml is dispatched automatically (see the
#                   `deploy` job at the bottom).

name: Build and Push Images

on:
  push:
    branches: [master]
    tags: ["v*"]
    paths:
      - "apps/**"
      - "packages/**"
      - "docker/**"
      - "package.json"
      - "pnpm-lock.yaml"
      - ".gitea/workflows/build.yml"
  workflow_dispatch:

env:
  REGISTRY: git.mancinas.io

jobs:
  build:
    name: Build ${{ matrix.image }}
    # release.yml pushes the release commit and its tag in a single `git push`,
    # so Gitea creates two runs for the same commit: one for master, one for the
    # tag. Only the tag run matters — it is the one that emits the X.Y.Z / X.Y
    # image tags, and it publishes `latest` and `sha-<short>` too, since it is
    # the same commit. Skip the branch run rather than racing or cancelling it.
    # Ordinary pushes to master (any message but `chore(release):`) still build.
    if: >-
      github.event_name != 'push' ||
      startsWith(github.ref, 'refs/tags/') ||
      !startsWith(github.event.head_commit.message, 'chore(release):')
    runs-on: docker
    container:
      image: docker:27-dind
      options: --privileged
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - image: jorgecuadros-api
            dockerfile: docker/api.Dockerfile
          - image: jorgecuadros-web
            dockerfile: docker/web.Dockerfile
    steps:
      - name: Install Node.js for actions
        run: apk add --no-cache nodejs npm

      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=ref,event=branch
            type=sha,format=short,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - uses: docker/build-push-action@v5
        with:
          context: .
          file: ${{ matrix.dockerfile }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          platforms: linux/amd64
          build-args: |
            APP_VERSION=${{ steps.meta.outputs.version }}
            GIT_SHA=${{ github.sha }}
            BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}

  # Chain the PROD deploy onto a green tag build.
  #
  # `needs: build` waits for BOTH matrix legs, so api and web at this tag are
  # both in the registry before anything is deployed — deploy-galactus.yml does
  # not build, it only pulls, and a half-pushed pair is exactly the state that
  # leaves prod running one new image and one old one.
  #
  # Why a dispatch and not a `workflow_run:` trigger, which Gitea does support
  # as of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places
  # (tag, scope, bootstrap, skip_migrate). Under workflow_run every one of them
  # is the empty string, so the deploy would silently run with no tag and
  # scope != 'full'. A dispatch keeps that workflow's contract intact and keeps
  # it hand-runnable for rollbacks, which is the whole point of it.
  #
  # Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut
  # the chain and go back to dispatching the deploy by hand. Anything else
  # (including unset) deploys.
  deploy:
    name: Deploy to galactus
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: docker
    container:
      image: node:20-alpine
    steps:
      - name: Preflight — RELEASE_TOKEN
        env:
          RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -eu
          if [ -z "${RELEASE_TOKEN:-}" ]; then
            echo "::error::Secret RELEASE_TOKEN is not set, so this build cannot"
            echo "::error::dispatch the deploy. The images ARE published — run"
            V=${GITHUB_REF#refs/tags/}
            echo "::error::'Deploy to galactus' by hand with tag=${V#v}."
            exit 1
          fi

      - name: Dispatch deploy-galactus.yml
        env:
          RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
          TAG_REF: ${{ github.ref }}
        run: |
          node -e '
            const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
            const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
            // refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
            // it does not depend on how Gitea populates GITHUB_REF_NAME.
            const tagRef = process.env.TAG_REF;
            // The git tag carries the leading v; the image tag does not.
            const version = tagRef.replace(/^refs\/tags\/v?/, "");
            const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

            // Every deploy-galactus run id visible right now. A dispatch is
            // only confirmed by an id that is NOT in here — a plain "is there a
            // deploy run" check is satisfied by the previous release run, and
            // would report success for a dispatch that never took.
            const deployRunIds = async () => {
              const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
              if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
              const body = await r.json();
              return new Set(
                (body.workflow_runs || [])
                  .filter((run) => String(run.path || "").includes("deploy-galactus.yml"))
                  .map((run) => run.id),
              );
            };

            (async () => {
              if (process.env.AUTO_DEPLOY === "false") {
                console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
                console.log(`Deploy by hand with tag=${version} when ready.`);
                return;
              }

              const before = await deployRunIds();

              // Dispatch against the TAG, not master: the deploy applies the
              // compose files under deploy/galactus/ from whatever ref it runs
              // on, and those must be the ones this release was cut with.
              const res = await fetch(
                `${base}/actions/workflows/deploy-galactus.yml/dispatches`,
                {
                  method: "POST",
                  headers: { ...headers, "Content-Type": "application/json" },
                  body: JSON.stringify({
                    ref: tagRef,
                    inputs: {
                      tag: version,
                      scope: "app",
                      bootstrap: "false",
                      skip_migrate: "false",
                    },
                  }),
                },
              );
              if (!res.ok) {
                console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
                console.log(`::error::Images for ${version} are published. Run`);
                console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
                process.exit(1);
              }

              // A 204 only means Gitea accepted the request. Confirm a NEW run
              // exists, the same way release.yml confirms this build started —
              // an accepted call that creates no run is the failure mode that
              // cost v1.0.3 its images.
              for (let i = 0; i < 3; i++) {
                await sleep(5_000);
                const now = await deployRunIds();
                const fresh = [...now].filter((id) => !before.has(id));
                if (fresh.length) {
                  console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
                  return;
                }
              }

              console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
              console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
              process.exit(1);
            })();
          '
