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.
184 lines
8.4 KiB
YAML
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
|