espressif/idf:release-v5.3 has no Node.js, and actions/checkout (a Node action) runs inside whatever container: the job specifies -- it failed immediately with "node: not found" on the first real run. Checkout now runs on the plain runner; only the two build steps spin up the ESP-IDF image themselves via docker run, using the runner's already-mounted docker socket.
171 lines
7.6 KiB
YAML
171 lines
7.6 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 via `docker run` -- 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 a volume owned by the
|
|
# runner's host user; idf.py shells out to git for component/
|
|
# version bookkeeping even though the embedded app version itself
|
|
# comes from version.txt, not git describe.
|
|
- name: Build (devkit -- ESP32-C6-DevKitC-1)
|
|
run: |
|
|
docker run --rm -v "$PWD:/workspace" -w /workspace/firmware espressif/idf:release-v5.3 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
|
|
'
|
|
|
|
- name: Build (xiao -- Seeed XIAO ESP32-C6)
|
|
run: |
|
|
docker run --rm -v "$PWD:/workspace" -w /workspace/firmware espressif/idf:release-v5.3 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
|
|
'
|
|
|
|
- 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
|