Firmware CI releases + Gitea auto-update in the server
Build and push server image / build-and-push (push) Successful in 39s
Build and push server image / build-and-push (push) Successful in 39s
New Gitea Actions workflow builds both board variants and publishes them as release assets whenever firmware/version.txt is bumped. The server can now poll that repo's releases (next to the existing manual upload) and either surface an "Update frame" button or, with "Automatically apply updates" checked, stage the new build itself -- the frame still only updates on its own next wake either way.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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
|
||||
Reference in New Issue
Block a user