The runner's job workspace lives in a named Docker volume, not a real host path, so a nested docker run -v "\$PWD:/workspace" bind-mounted an empty directory when talking to the host daemon (which has no notion of paths inside the calling container) -- build_for_board.sh: No such file or directory. docker create/cp/start streams the checkout into the build container through the Docker API instead, which works regardless of what backs either side's workspace.
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-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
|
|
')
|
|
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-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
|
|
')
|
|
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
|