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: runs-on: ubuntu-latest container: image: espressif/idf:release-v5.3 steps: - name: Checkout uses: actions/checkout@v4 - name: Trust the checkout (container user differs from the checkout's owner) run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - 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). - name: Build (devkit -- ESP32-C6-DevKitC-1) run: | . "$IDF_PATH/export.sh" cd firmware ./build_for_board.sh devkit set-target esp32c6 ./build_for_board.sh devkit build - name: Build (xiao -- Seeed XIAO ESP32-C6) run: | . "$IDF_PATH/export.sh" cd firmware ./build_for_board.sh xiao set-target esp32c6 ./build_for_board.sh xiao build - name: Collect binaries run: | mkdir -p /tmp/release-assets cp firmware/build/espresso_frame.bin /tmp/release-assets/firmware-devkit.bin cp firmware/build_xiao/espresso_frame.bin /tmp/release-assets/firmware-xiao.bin # 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