Merge branch 'ci': CI on every push, a signed release on every tag
CI / build (push) Successful in 8m10s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 13:51:19 +02:00
co-authored by Claude Opus 5.5
9 changed files with 339 additions and 2 deletions
+84
View File
@@ -0,0 +1,84 @@
# CI and releases (docs/milestones/R1.md).
# Any push: host tests, then the release firmware and the Debug Build.
# A tag v*: the same, then a Gitea release with the signed Update File.
# Run by hand: the release of a tag that exists already (the ones from before CI).
#
# The job runs in a plain Python image, as scripts/ci.sh does on a developer's machine, with the
# toolchains in a Docker volume the runner allows (container.valid_volumes: roro9stack-pio): that
# volume is the cache. No JavaScript actions, so the image needs no Node: the checkout is git.
name: CI
on:
push:
branches: ['**']
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: An existing tag to build and publish as a release
required: true
jobs:
build:
runs-on: ubuntu
container:
image: python:3.12-slim
volumes:
- roro9stack-pio:/pio
env:
PLATFORMIO_CORE_DIR: /pio
RORO_NO_DOCKER: 1
steps:
- name: Tools
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends git build-essential openssl >/dev/null
pip install -q --no-cache-dir --root-user-action=ignore platformio
pio --version; df -h /pio | tail -1; ls /pio | head
- name: Check out
run: |
find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +
git config --global --add safe.directory '*'
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*'
git checkout -q --detach "${{ github.sha }}"
git describe --tags --always
- name: Host tests and both builds
if: github.event_name == 'push'
run: scripts/ci.sh
- name: Which release
id: release
run: |
if [ "${{ github.event_name }}" = workflow_dispatch ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
elif [ "${{ github.ref_type }}" = tag ]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
fi
- name: Build and sign the release
if: steps.release.outputs.tag != ''
env:
OTA_SIGNING_KEY: ${{ secrets.OTA_SIGNING_KEY }}
run: |
# The sources of the tag in a clone of their own; the tools are this commit's.
rm -rf /tmp/release-src dist
git clone -q . /tmp/release-src
git -C /tmp/release-src checkout -q --detach "refs/tags/${{ steps.release.outputs.tag }}"
# The key exists as a file only while this step runs, in a container that goes with the job.
umask 077
export RORO_OTA_KEY="$(mktemp)"
trap 'rm -f "$RORO_OTA_KEY"' EXIT
printf '%s\n' "$OTA_SIGNING_KEY" > "$RORO_OTA_KEY"
umask 022
scripts/release_build.sh /tmp/release-src dist
- name: Publish the release
if: steps.release.outputs.tag != ''
env:
GITEA_API: ${{ github.server_url }}/api/v1
GITEA_REPO: ${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: scripts/release_publish.py dist
+13
View File
@@ -20,6 +20,19 @@ This runs the host-side unit tests (`test/`, `native` environment), then builds
The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 4 minutes; later builds take under a minute.
## CI and releases
Gitea Actions runs the same thing on every push (`.gitea/workflows/ci.yml`, docs/milestones/R1.md). Pushing a tag `v*` also publishes a release on Gitea with:
- `roro9stack-<version>.ota`, the signed Update File;
- `roro9stack-<version>-factory.bin`, the whole flash image for a first install over USB;
- `roro9stack-<version>.elf.gz`, to decode crash reports from that build;
- `SHA256SUMS`.
CI signs with the project's key, held as a repository secret (ADR 0008). Debug Builds are built but never published: each carries its builder's Debug Console token.
`scripts/ota_verify.py <file.ota>` checks an Update File on a PC the way a device does. `scripts/release_build.sh` and `scripts/release_publish.py` are what the workflow runs; they work the same by hand.
## Flash
1. Connect the Cardputer by USB-C.
+17
View File
@@ -0,0 +1,17 @@
# CI signs releases with the project's key
A tag `v*` is built, signed and published by Gitea Actions with nobody at a keyboard. The signing key of ADR 0003 is therefore held twice: in `~/.config/roro9stack/ota-key.pem` on the development machine, as before, and as the repository secret `OTA_SIGNING_KEY`, which the release step writes to a file for as long as it runs.
We chose this over signing by hand after CI has built (a command per release, the key in one place), and over a second key for CI that the firmware would also trust. A release that needs a manual step isn't made on the day it's ready, and issue #6, the device installing releases by itself, needs releases that are always there and always signed.
## What it costs
- **Whoever can run a workflow in this repository can sign firmware every device accepts.** That means: anyone who can push to it, the runner's host and whoever administers it, and the Gitea instance with its database, where the secret is stored. Before, it took the development machine.
- The runner executes jobs **on its own host**, not in a container, as a user who can use Docker. A workflow is not confined.
- Pull requests from forks must never run with this secret. Gitea doesn't pass secrets to them; the workflow also only runs on pushes and by hand.
## What limits it
- The release step checks the signed file against the public key in the sources it built (`scripts/ota_verify.py`): a wrong or replaced secret stops the release instead of publishing a file no device takes.
- ADR 0003's way out stays: a firmware release can carry a new public key. If the secret is ever in doubt, make a new pair, ship it in a release signed with the old key, and replace the secret.
- A device still only installs what it's told to (until #6), keeps a new image on Probation, and rolls back one that doesn't hold.
+41
View File
@@ -0,0 +1,41 @@
# R1 — Releases
**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Next: updates from Gitea (#6), which waited for this, and the Issues App (#4).
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
## CI and releases (issue #5)
Until now the tests, the builds, the signing and the flashing all happened on one machine, through `scripts/ci.sh` and `scripts/flash.sh`. Nothing was published.
### Decisions (design round 2026-10-06)
| # | Decision |
|---|---|
| Q151 | Every push, to any branch: the host tests and both builds. A tag `v*`: the same, then a release. |
| Q152 | **CI signs.** The signing key is the repository secret `OTA_SIGNING_KEY`; a tag push makes a complete, signed release with no manual step (ADR 0008). |
| Q153 | The Debug Build is built in CI with a token of the runner's own, to prove it compiles, and **isn't published**: it would hand everyone its Debug Console token. |
| Q154 | Pull requests from forks don't start a run. |
| Q155 | A release carries `roro9stack-<version>.ota` (signed), `-factory.bin` for USB, `.elf.gz` to decode crashes, and `SHA256SUMS`. |
| Q156 | Its text is the tag's message, what the files are, and the commits since the tag before. |
| Q157 | No cache service to begin with: measure first. |
| Q158 | Reproducible builds aren't needed for signing any more (Q152); not pursued here. |
| Q159 | **The tags from before CI get their releases too**, v0.1.0 to v0.10.0, built from each tag's own sources by running the workflow by hand. |
| Q160 | Actions is switched on for the repository. |
### As built
- **One workflow, `.gitea/workflows/ci.yml`, one job**, on the runner `runner0` (label `ubuntu`). The job asks for a `python:3.12-slim` container, installs git, a compiler, openssl and PlatformIO, and runs the same scripts as a developer's machine. No Docker inside the job.
- **The cache is a Docker volume**, `roro9stack-pio`, mounted at `/pio`; the runner's `config.yaml` allows it under `container.valid_volumes`. A first run downloads about 1 GB and rebuilds the framework (17 minutes); with the volume filled, tests and both builds take about 6.
- **No JavaScript actions**, so the image needs no Node and nothing is fetched from GitHub: the checkout is four git commands.
- **`scripts/_docker.sh`** runs the command in place when `RORO_NO_DOCKER` is set (a CI job is already in a build container), and in the project's image otherwise. The Debug Build's token is made on the spot in CI and goes with the container.
- **`scripts/release_build.sh <checkout> <out>`** builds a tag's own sources with today's tools, signs, verifies against the public key in those sources, and writes the files and the release's text. **`scripts/release_publish.py`** creates the Gitea release or completes it; run twice, it replaces what's there. Both run the same on a developer's machine.
- **`scripts/ota_verify.py`** checks an Update File as a device does, on a PC.
- **The job's own token** (`secrets.GITEA_TOKEN`) is enough to create a release and upload its files.
- **Old tags.** v0.1.0 to v0.3.0 are from before the framework was rebuilt with our settings (ADR 0006) and can't link against a rebuilt one left in the cache: the release build puts the stock framework libraries back for them. v0.1.0 to v0.2.1 have no public key in their sources (Firmware Updates came with v0.3.0); their files are checked against today's.
### How it went
- **The runner's label took three tries.** Registered as `ubuntu://docker:ubuntu:resolute` and then as `ubuntu::docker://...`, Gitea took the whole string for the label's name; with the first, jobs ran on the runner's host itself. The first version of the workflow was written for that (plain shell, `docker run` for the build) and published v0.10.0 that way. `ubuntu:docker://docker.gitea.com/runner-images:ubuntu-latest` is the form that works.
- **Gitea 1.27's API can't cancel a run that isn't finished**, only delete a finished one; switching Actions off and on for the repository doesn't either. Runs queued for a label that no longer exists stay queued until cancelled in the web UI.
- **CI's image isn't byte-identical to a local build of the same tag** (same size, different bytes). Not pursued (Q158).
+7 -1
View File
@@ -1,8 +1,10 @@
# Shared helper: run a command inside the roro9stack build container.
# With RORO_NO_DOCKER set, the caller is in such a container already (a CI job): the command runs
# right here, in the checkout.
IMAGE=roro9stack-build
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
[ -n "${RORO_NO_DOCKER:-}" ] || docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null
# The Debug Console token (ADR 0004): made once, kept with the OTA key, never committed.
DEBUG_TOKEN_FILE="$HOME/.config/roro9stack/debug-token"
@@ -12,6 +14,10 @@ if [ ! -s "$DEBUG_TOKEN_FILE" ]; then
fi
run_in_container() {
if [ -n "${RORO_NO_DOCKER:-}" ]; then
(cd "$ROOT" && RORO_DEBUG_TOKEN="$(cat "$DEBUG_TOKEN_FILE")" "$@")
return
fi
docker run --rm \
-u "$(id -u):$(id -g)" -e HOME=/tmp \
-e RORO_DEBUG_TOKEN="$(cat "$DEBUG_TOKEN_FILE")" \
+1 -1
View File
@@ -4,4 +4,4 @@ set -euo pipefail
source "$(dirname "$0")/_docker.sh"
DOCKER_EXTRA=()
run_in_container bash -c 'git config --global --add safe.directory /work && pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug'
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug'
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Checks an Update File (.ota) the way the device does, on a PC: header, signature, image hash.
Usage: scripts/ota_verify.py <file.ota> [public key, default keys/ota-public.pem]
Exits 0 and prints the version if a device would accept the file.
"""
import hashlib
import os
import struct
import subprocess
import sys
import tempfile
HEADER_SIZE = 160
SIGNED_BYTES = 80
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
key = sys.argv[2] if len(sys.argv) > 2 else os.path.join(root, "keys", "ota-public.pem")
data = open(sys.argv[1], "rb").read()
if len(data) < HEADER_SIZE or data[:8] != b"RORO-OTA":
sys.exit("not an update file")
fmt, header_size, image_size = struct.unpack("<HHI", data[8:16])
if fmt != 1 or header_size != HEADER_SIZE:
sys.exit("unsupported update format")
image = data[HEADER_SIZE:]
if len(image) != image_size:
sys.exit(f"the header announces {image_size} bytes of image, the file has {len(image)}")
if hashlib.sha256(image).digest() != data[16:48]:
sys.exit("image corrupted (hash mismatch)")
version = data[48:80].split(b"\0")[0].decode()
(sig_len,) = struct.unpack("<H", data[80:82])
with tempfile.NamedTemporaryFile() as signed, tempfile.NamedTemporaryFile() as sig:
signed.write(data[:SIGNED_BYTES])
signed.flush()
sig.write(data[82:82 + sig_len])
sig.flush()
ok = subprocess.run(["openssl", "dgst", "-sha256", "-verify", key, "-signature", sig.name, signed.name],
capture_output=True).returncode == 0
if not ok:
sys.exit("bad signature (wrong key)")
print(f"{sys.argv[1]}: {version}, {image_size} bytes, signature good")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Builds what a release publishes, from a checkout of the repository at a tag (docs/milestones/R1.md).
# Usage: scripts/release_build.sh <checkout> <out folder>
# <checkout> a clone with its tags, at the commit to release: its own sources are built, with
# this copy's build image and signing tools, so an old tag can be released today.
# The signing key is read from $RORO_OTA_KEY (a file), as scripts/make_ota.py does.
# Out: roro9stack-<version>.ota (signed, checked), -factory.bin (USB), .elf.gz (to decode crashes),
# SHA256SUMS, and notes.md for the release's text.
set -euo pipefail
SRC="$(cd "$1" && pwd)"
mkdir -p "$2"
OUT="$(cd "$2" && pwd)"
TOOLS="$(cd "$(dirname "$0")" && pwd)"
VERSION="$(git -C "$SRC" describe --tags --always --dirty)"
case "$VERSION" in
*-dirty) echo "release: $SRC has uncommitted changes ($VERSION)" >&2; exit 1 ;;
esac
git -C "$SRC" describe --tags --exact-match >/dev/null 2>&1 || { echo "release: $VERSION is not a tag" >&2; exit 1; }
source "$TOOLS/_docker.sh"
ROOT="$SRC" # _docker.sh mounts $ROOT as /work: the checkout to build, not necessarily this copy
DOCKER_EXTRA=()
# A tag from before the framework was rebuilt with our settings (ADR 0006) can't link against a
# rebuilt one left in the toolchain cache: it gets the framework's libraries as they come.
if ! grep -q custom_sdkconfig "$SRC/platformio.ini"; then
run_in_container bash -c 'rm -rf "${PLATFORMIO_CORE_DIR:-/pio}/packages/framework-arduinoespressif32-libs"'
fi
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && pio run -e cardputer-adv'
BUILD="$SRC/.pio/build/cardputer-adv"
NAME="roro9stack-$VERSION"
"$TOOLS/make_ota.py" "$BUILD/firmware.bin" "$VERSION" "$OUT/$NAME.ota"
# A wrong key must stop the release here, not on a device: checked against the public key the
# sources being built carry (the tags from before Firmware Updates have none: today's, then).
PUBLIC="$SRC/keys/ota-public.pem"
[ -e "$PUBLIC" ] || PUBLIC="$TOOLS/../keys/ota-public.pem"
"$TOOLS/ota_verify.py" "$OUT/$NAME.ota" "$PUBLIC"
cp "$BUILD/firmware.factory.bin" "$OUT/$NAME-factory.bin"
gzip -9 -c "$BUILD/firmware.elf" > "$OUT/$NAME.elf.gz"
(cd "$OUT" && sha256sum "$NAME.ota" "$NAME-factory.bin" "$NAME.elf.gz" > SHA256SUMS)
# The release's text: what the tag says, then what went in since the tag before.
PREVIOUS="$(git -C "$SRC" describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || true)"
{
git -C "$SRC" tag -l --format='%(contents)' "$VERSION" | sed -e '/^-----BEGIN PGP/,$d'
echo
echo "## Files"
echo
echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`."
echo "- \`$NAME-factory.bin\`: the whole flash image, for a first install over USB at offset 0."
echo "- \`$NAME.elf.gz\`: the symbols, to decode a crash report from this build."
echo "- \`SHA256SUMS\`: checksums of the three."
if [ -n "$PREVIOUS" ]; then
echo
echo "## Changes since $PREVIOUS"
echo
git -C "$SRC" log --no-merges --format='- %s' "$PREVIOUS..$VERSION"
fi
} > "$OUT/notes.md"
echo "$VERSION" > "$OUT/version"
ls -l "$OUT"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Creates or completes a Gitea release from what scripts/release_build.sh made.
Usage: scripts/release_publish.py <folder>
Environment: GITEA_API (https://host/api/v1), GITEA_REPO (owner/name), GITEA_TOKEN.
Run again for the same version, it replaces the files and the text instead of failing.
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
API = os.environ.get("GITEA_API", "").rstrip("/")
REPO = os.environ.get("GITEA_REPO", "")
TOKEN = os.environ.get("GITEA_TOKEN", "")
def call(method, path, body=None, raw=None, content_type="application/json"):
data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
request = urllib.request.Request(f"{API}/repos/{REPO}{path}", data=data, method=method)
request.add_header("Authorization", f"token {TOKEN}")
if data is not None:
request.add_header("Content-Type", content_type)
try:
with urllib.request.urlopen(request, timeout=300) as response:
text = response.read()
return response.status, json.loads(text) if text else None
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")[:300]
def main():
if len(sys.argv) != 2 or not (API and REPO and TOKEN):
sys.exit(__doc__)
folder = sys.argv[1]
version = open(os.path.join(folder, "version")).read().strip()
notes = open(os.path.join(folder, "notes.md")).read()
files = sorted(f for f in os.listdir(folder) if f not in ("version", "notes.md"))
status, release = call("GET", f"/releases/tags/{urllib.parse.quote(version)}")
fields = {"tag_name": version, "name": f"roro9stack {version}", "body": notes, "draft": False, "prerelease": False}
if status == 200:
status, release = call("PATCH", f"/releases/{release['id']}", fields)
else:
status, release = call("POST", "/releases", fields)
if status not in (200, 201):
sys.exit(f"release: Gitea answered {status}: {release}")
for asset in release.get("assets") or []: # a second run replaces what the first uploaded
if asset["name"] in files:
call("DELETE", f"/releases/{release['id']}/assets/{asset['id']}")
for name in files:
with open(os.path.join(folder, name), "rb") as f:
status, answer = call("POST", f"/releases/{release['id']}/assets?name={urllib.parse.quote(name)}", raw=f.read(),
content_type="application/octet-stream")
if status != 201:
sys.exit(f"release: uploading {name}: Gitea answered {status}: {answer}")
print(f"uploaded {name} ({answer['size']} bytes)")
print(f"published {release['html_url']}")
if __name__ == "__main__":
main()