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