Files
espresso_frame/.gitea/workflows/firmware-release-build.yml
T
tfaour ad1fa99064 Fix firmware release workflow: build against ESP-IDF v6.0, not v5.3
The build container was pinned to espressif/idf:release-v5.3, but the
firmware is actually developed against v6.0.2 (this machine's
$IDF_PATH). That gap caused real compile failures in CI --
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown and
ESP_NETIF_CAPTIVEPORTAL_URI are both newer than v5.3 has, plus a
transitive stdbool.h include that changed between versions -- none of
it a real bug in the source.
2026-07-21 21:41:36 -04:00

184 lines
8.4 KiB
YAML

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