name: Build and release firmware

# Fires when firmware/version.txt is bumped on main -- that's the deliberate
# "cut a release" signal (mirrors ESP-IDF's own convention of reading the
# embedded app version from this file), not every firmware/** push. Also
# runnable by hand for a one-off rebuild of the current version.
on:
  push:
    branches: [main]
    paths:
      - "firmware/version.txt"
  workflow_dispatch:

jobs:
  build-and-release:
    # Deliberately NOT a job-level `container: espressif/idf:...` -- that
    # image has no Node.js in it, and actions/checkout (like most marketplace
    # actions) is a Node action that gets exec'd *inside* whatever container
    # the job specifies, so checkout fails immediately with "node: not
    # found" (hit this on the first real run). Checkout instead runs on the
    # plain runner (which has Node), and only the two build steps below
    # spin up the ESP-IDF image themselves (docker create/cp/start, see the
    # comment on those steps for why not a plain `docker run -v`) -- the
    # runner already bind-mounts the host's docker socket, so docker-in-
    # docker works fine from an ordinary run: step (same mechanism the
    # existing server-docker-build.yml relies on for docker buildx).
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Read firmware version
        id: version
        run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"

      # Two board variants, two partition tables/flash sizes (see
      # firmware/README.md's "Building for the Seeed XIAO ESP32-C6"
      # section) -- build_for_board.sh gives each its own build dir/
      # generated sdkconfig so this never fights over shared state.
      # set-target first since a fresh checkout has no cached sdkconfig
      # (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
      # safe.directory guards against git's "dubious ownership" check,
      # since the container runs as root over content owned by a
      # different uid.
      #
      # Deliberately `docker create`/`docker cp`/`docker start`, NOT
      # `docker run -v "$PWD:/workspace"` -- the runner's own job
      # workspace lives in a named Docker volume, not a real host path
      # (confirmed from a failed run's container-inspect output), so a
      # nested `docker run` bind-mounting "$PWD" talks to the *host*
      # daemon about a path that only means something inside this job's
      # own container -- it silently bind-mounted an empty directory,
      # hence "build_for_board.sh: No such file or directory". `docker
      # cp` streams files through the Docker API instead, so it works
      # regardless of what backs either side's workspace.
      - name: Build (devkit -- ESP32-C6-DevKitC-1)
        run: |
          mkdir -p /tmp/release-assets
          cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
            git config --global --add safe.directory /workspace &&
            . "$IDF_PATH/export.sh" &&
            ./build_for_board.sh devkit set-target esp32c6 &&
            ./build_for_board.sh devkit build
          ')
          docker cp "$PWD/." "$cid:/workspace"
          docker start -a "$cid"
          docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit.bin
          docker rm "$cid"

      - name: Build (xiao -- Seeed XIAO ESP32-C6)
        run: |
          cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
            git config --global --add safe.directory /workspace &&
            . "$IDF_PATH/export.sh" &&
            ./build_for_board.sh xiao set-target esp32c6 &&
            ./build_for_board.sh xiao build
          ')
          docker cp "$PWD/." "$cid:/workspace"
          docker start -a "$cid"
          docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao.bin
          docker rm "$cid"

      # Plain stdlib urllib rather than `requests` -- not guaranteed to be
      # pip-installed in the IDF image, and this is simple enough not to
      # need it. Re-running this workflow for the same version.txt (e.g. a
      # manual re-dispatch) reuses the existing tag's release and replaces
      # its assets rather than failing on "release already exists".
      #
      # Requires a Gitea PAT with repository read/write (release) scope in
      # the `RELEASE_TOKEN` secret -- Repo Settings -> Actions -> Secrets.
      # A token that already has REGISTRY_TOKEN's scope may work too if it
      # covers repo contents, but keeping it separate keeps each secret's
      # blast radius obvious.
      - name: Publish Gitea release
        env:
          GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
          API_BASE: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
          TAG: v${{ steps.version.outputs.version }}
          COMMIT: ${{ gitea.sha }}
        run: |
          python3 - <<'PYEOF'
          import json
          import os
          import urllib.error
          import urllib.request

          api = os.environ["API_BASE"]
          token = os.environ["GITEA_TOKEN"]
          tag = os.environ["TAG"]
          commit = os.environ["COMMIT"]


          def req(method, path, body=None):
              data = json.dumps(body).encode() if body is not None else None
              headers = {"Authorization": f"token {token}"}
              if body is not None:
                  headers["Content-Type"] = "application/json"
              r = urllib.request.Request(f"{api}{path}", data=data, method=method, headers=headers)
              try:
                  with urllib.request.urlopen(r) as resp:
                      return resp.status, json.loads(resp.read() or b"{}")
              except urllib.error.HTTPError as e:
                  return e.code, json.loads(e.read() or b"{}")


          status, release = req("GET", f"/releases/tags/{tag}")
          if status == 404:
              status, release = req(
                  "POST",
                  "/releases",
                  {
                      "tag_name": tag,
                      "target_commitish": commit,
                      "name": tag,
                      "body": f"Automated build from commit {commit}.",
                      "draft": False,
                      "prerelease": False,
                  },
              )
              if status not in (200, 201):
                  raise SystemExit(f"Failed to create release {tag}: {status} {release}")
              print(f"Created release {tag} (id={release['id']})")
          elif status == 200:
              print(f"Release {tag} already exists (id={release['id']}), reusing it")
          else:
              raise SystemExit(f"Failed to look up release {tag}: {status} {release}")

          release_id = release["id"]
          existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}

          assets = [
              ("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
              ("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
          ]
          for name, path in assets:
              if name in existing_assets:
                  del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
                  print(f"Removed existing asset {name} (status {del_status})")

              boundary = "geafirmwareboundary"
              with open(path, "rb") as f:
                  file_bytes = f.read()
              body = (
                  f"--{boundary}\r\n"
                  f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
                  "Content-Type: application/octet-stream\r\n\r\n"
              ).encode() + file_bytes + f"\r\n--{boundary}--\r\n".encode()

              r = urllib.request.Request(
                  f"{api}/releases/{release_id}/assets?name={name}",
                  data=body,
                  method="POST",
                  headers={
                      "Authorization": f"token {token}",
                      "Content-Type": f"multipart/form-data; boundary={boundary}",
                  },
              )
              try:
                  with urllib.request.urlopen(r) as resp:
                      print(f"Uploaded {name}: status {resp.status}")
              except urllib.error.HTTPError as e:
                  raise SystemExit(f"Failed to upload {name}: {e.code} {e.read()}")
          PYEOF
