From c481bb5191061bb135b42256df463226e214271e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 11:56:10 +0200 Subject: [PATCH 1/8] CI: a first workflow, to see what the runner gives a job (#5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- .gitea/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..c147d8e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,14 @@ +name: CI +on: [push] + +jobs: + probe: + runs-on: ubuntu + steps: + - name: What the runner gives a job + run: | + set +e + echo "shell: $0"; id; uname -a; cat /etc/os-release | head -3 + nproc; free -m | head -2; df -h / | tail -1 + for t in git python3 pip node docker curl openssl gcc make; do printf '%s: ' $t; command -v $t || echo none; done + env | grep -E '^(GITHUB|GITEA|RUNNER|ACTIONS|CI)' | grep -vi token | sort From 661227cd2dbb4af96146e55c35b5ecdbb26c8911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 11:58:53 +0200 Subject: [PATCH 2/8] CI: try the runner's label as it is registered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c147d8e..9d14203 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,7 +3,7 @@ on: [push] jobs: probe: - runs-on: ubuntu + runs-on: "ubuntu://docker:ubuntu:resolute" steps: - name: What the runner gives a job run: | From 1fade6287b1608f28a7e17c4a6150441b7e051fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 12:06:57 +0200 Subject: [PATCH 3/8] CI: tests and builds on every push, a signed release on a tag (#5) The workflow runs on the runner's host and builds in the project's Docker image through scripts/ci.sh, as on a developer's machine. A tag v*, or a run by hand for an older tag, builds that tag's sources, signs the Update File with the key held in the repository's secrets, checks the signature against the public key in the sources, and publishes a Gitea release with the .ota, the factory image, the ELF and checksums. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- .gitea/workflows/ci.yml | 71 +++++++++++++++++++++++++++++++++----- scripts/ota_verify.py | 49 ++++++++++++++++++++++++++ scripts/release_build.sh | 55 +++++++++++++++++++++++++++++ scripts/release_publish.py | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 8 deletions(-) create mode 100755 scripts/ota_verify.py create mode 100755 scripts/release_build.sh create mode 100755 scripts/release_publish.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9d14203..b2e41c6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,14 +1,69 @@ +# 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 runner executes jobs on its own host, where Docker is: the steps are plain shell and the build +# runs in the project's image, exactly as scripts/ci.sh does on a developer's machine. No JavaScript +# actions (the host has no Node), so the checkout is done with git. name: CI -on: [push] +on: + push: + branches: ['**'] + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: An existing tag to build and publish as a release + required: true jobs: - probe: + build: runs-on: "ubuntu://docker:ubuntu:resolute" steps: - - name: What the runner gives a job + - name: Check out run: | - set +e - echo "shell: $0"; id; uname -a; cat /etc/os-release | head -3 - nproc; free -m | head -2; df -h / | tail -1 - for t in git python3 pip node docker curl openssl gcc make; do printf '%s: ' $t; command -v $t || echo none; done - env | grep -E '^(GITHUB|GITEA|RUNNER|ACTIONS|CI)' | grep -vi token | sort + find . -mindepth 1 -maxdepth 1 -exec rm -rf {} + + 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 ../release-src dist + git clone -q . ../release-src + git -C ../release-src checkout -q --detach "refs/tags/${{ steps.release.outputs.tag }}" + # The key exists as a file only while this step runs. + 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 ../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 diff --git a/scripts/ota_verify.py b/scripts/ota_verify.py new file mode 100755 index 0000000..7e07171 --- /dev/null +++ b/scripts/ota_verify.py @@ -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 [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(" +# 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-.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=() +run_in_container bash -c 'git config --global --add safe.directory /work && 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. +"$TOOLS/ota_verify.py" "$OUT/$NAME.ota" "$SRC/keys/ota-public.pem" +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" diff --git a/scripts/release_publish.py b/scripts/release_publish.py new file mode 100755 index 0000000..7ab0f26 --- /dev/null +++ b/scripts/release_publish.py @@ -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 +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() From 6b6bb975f5b5270e4bd2f7f5683385fb65021002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 12:19:00 +0200 Subject: [PATCH 4/8] R1 plan and ADR 0008: CI signs releases; README section on CI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 13 ++++++++++++ docs/adr/0008-ci-signs-releases.md | 17 ++++++++++++++++ docs/milestones/R1.md | 32 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 docs/adr/0008-ci-signs-releases.md create mode 100644 docs/milestones/R1.md diff --git a/README.md b/README.md index 2b989bc..0270dce 100644 --- a/README.md +++ b/README.md @@ -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-.ota`, the signed Update File; +- `roro9stack--factory.bin`, the whole flash image for a first install over USB; +- `roro9stack-.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 ` 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. diff --git a/docs/adr/0008-ci-signs-releases.md b/docs/adr/0008-ci-signs-releases.md new file mode 100644 index 0000000..10afa52 --- /dev/null +++ b/docs/adr/0008-ci-signs-releases.md @@ -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. diff --git a/docs/milestones/R1.md b/docs/milestones/R1.md new file mode 100644 index 0000000..1a82e6c --- /dev/null +++ b/docs/milestones/R1.md @@ -0,0 +1,32 @@ +# R1 — Releases + +**Status:** in progress (branch `ci`): CI and signed releases on Gitea (issue #5). The Issues App (#4) and updates from Gitea (#6) come after; #6 waits for this. + +**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-.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.** The runner (`runner0`) executes jobs on its own host, where Docker is, so the steps are plain shell and the build runs in the project's image through `scripts/ci.sh`, exactly as on a developer's machine. The toolchains live in the same `roro9stack-pio` Docker volume, on the runner: that is the cache. +- **No JavaScript actions:** the host has no Node. The checkout is four git commands. +- **The runner's label** is registered as `ubuntu://docker:ubuntu:resolute`, the whole string, and that is what `runs-on` has to say. It looks like `ubuntu:docker://ubuntu:resolute` was meant, which would run jobs in a container; the workflow would then need Docker inside that container, or a rewrite. As it is, it works. +- **`scripts/release_build.sh `** builds a tag's own sources with today's build image and signing tools, signs, verifies, 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. From ababfaf9938745b2794a451547a64a5d460ffaa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 12:25:20 +0200 Subject: [PATCH 5/8] Release build: tags from before Firmware Updates carry no public key to check against Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- scripts/release_build.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/release_build.sh b/scripts/release_build.sh index c421329..cc4c0ee 100755 --- a/scripts/release_build.sh +++ b/scripts/release_build.sh @@ -27,8 +27,10 @@ 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. -"$TOOLS/ota_verify.py" "$OUT/$NAME.ota" "$SRC/keys/ota-public.pem" +# 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) From db7e6ccc18e75fbf9376becc03030ba15817685d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 12:39:37 +0200 Subject: [PATCH 6/8] CI: jobs run in a container, PlatformIO directly in it, the toolchains in a volume The runner now gives each job a container. The workflow asks for python:3.12-slim, installs git, a compiler and PlatformIO, and mounts the roro9stack-pio volume as the cache; the scripts skip their own docker run when RORO_NO_DOCKER says they're in the build container already. A tag from before the framework was rebuilt gets the stock framework libraries back before it builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- .gitea/workflows/ci.yml | 33 ++++++++++++++++++++++++--------- scripts/_docker.sh | 8 +++++++- scripts/ci.sh | 2 +- scripts/release_build.sh | 7 ++++++- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b2e41c6..70669fb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,9 +3,9 @@ # 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 runner executes jobs on its own host, where Docker is: the steps are plain shell and the build -# runs in the project's image, exactly as scripts/ci.sh does on a developer's machine. No JavaScript -# actions (the host has no Node), so the checkout is done with git. +# 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: @@ -19,11 +19,26 @@ on: jobs: build: - runs-on: "ubuntu://docker:ubuntu:resolute" + 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/*' @@ -49,16 +64,16 @@ jobs: 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 ../release-src dist - git clone -q . ../release-src - git -C ../release-src checkout -q --detach "refs/tags/${{ steps.release.outputs.tag }}" - # The key exists as a file only while this step runs. + 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 ../release-src dist + scripts/release_build.sh /tmp/release-src dist - name: Publish the release if: steps.release.outputs.tag != '' diff --git a/scripts/_docker.sh b/scripts/_docker.sh index fefd935..cc38627 100755 --- a/scripts/_docker.sh +++ b/scripts/_docker.sh @@ -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")" \ diff --git a/scripts/ci.sh b/scripts/ci.sh index 8f018b4..4f289b7 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -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' diff --git a/scripts/release_build.sh b/scripts/release_build.sh index cc4c0ee..974c768 100755 --- a/scripts/release_build.sh +++ b/scripts/release_build.sh @@ -21,7 +21,12 @@ git -C "$SRC" describe --tags --exact-match >/dev/null 2>&1 || { echo "release: source "$TOOLS/_docker.sh" ROOT="$SRC" # _docker.sh mounts $ROOT as /work: the checkout to build, not necessarily this copy DOCKER_EXTRA=() -run_in_container bash -c 'git config --global --add safe.directory /work && pio run -e cardputer-adv' +# 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" From a94c14f0001ace220a67f968a64a6f33ace639c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 12:52:43 +0200 Subject: [PATCH 7/8] R1 plan: CI as built on the container runner Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/R1.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/milestones/R1.md b/docs/milestones/R1.md index 1a82e6c..1410850 100644 --- a/docs/milestones/R1.md +++ b/docs/milestones/R1.md @@ -25,8 +25,17 @@ Until now the tests, the builds, the signing and the flashing all happened on on ### As built -- **One workflow, `.gitea/workflows/ci.yml`, one job.** The runner (`runner0`) executes jobs on its own host, where Docker is, so the steps are plain shell and the build runs in the project's image through `scripts/ci.sh`, exactly as on a developer's machine. The toolchains live in the same `roro9stack-pio` Docker volume, on the runner: that is the cache. -- **No JavaScript actions:** the host has no Node. The checkout is four git commands. -- **The runner's label** is registered as `ubuntu://docker:ubuntu:resolute`, the whole string, and that is what `runs-on` has to say. It looks like `ubuntu:docker://ubuntu:resolute` was meant, which would run jobs in a container; the workflow would then need Docker inside that container, or a rewrite. As it is, it works. -- **`scripts/release_build.sh `** builds a tag's own sources with today's build image and signing tools, signs, verifies, 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. +- **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 `** 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). From 6459ca54461eb7331a9ea356f705e7a239bdb53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Tue, 6 Oct 2026 13:51:18 +0200 Subject: [PATCH 8/8] R1 plan: CI and releases are in place Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/R1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/milestones/R1.md b/docs/milestones/R1.md index 1410850..9447cf7 100644 --- a/docs/milestones/R1.md +++ b/docs/milestones/R1.md @@ -1,6 +1,6 @@ # R1 — Releases -**Status:** in progress (branch `ci`): CI and signed releases on Gitea (issue #5). The Issues App (#4) and updates from Gitea (#6) come after; #6 waits for this. +**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.