Compare commits

...
14 Commits
Author SHA1 Message Date
twislaandClaude Opus 5.5 873af31e21 R1 plan: pull requests, merged by rebase then a merge commit (Q161)
CI / build (push) Successful in 1m6s
CI / build (pull_request) Successful in 8m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:37:44 +02:00
twislaandClaude Opus 5.5 16345ed6b3 CI: the badges are published from main only
CI / build (push) Successful in 1m5s
CI / build (pull_request) Successful in 8m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:23:08 +02:00
twislaandClaude Opus 5.5 86ddb87f54 CI: a push runs the tests, a pull request also builds the firmware
CI / build (push) Successful in 1m6s
Rebuilding both firmwares on every push was more than anyone looked at.
A push now runs the host tests with their coverage (under two minutes);
a pull request adds the release firmware and the Debug Build, and is how
changes reach main; a tag still does everything before it releases.
Pull requests from forks don't run. scripts/ci.sh takes 'tests' or
'builds' for one half; coverage.sh now fails when a test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:19:56 +02:00
twislaandClaude Opus 5.5 5ecd5d003f Coverage of lib/ by the host tests, and badges in the README
CI / build (push) Successful in 9m9s
scripts/coverage.sh builds the host tests with coverage counters and
reports with gcovr: 94.6% of the 3,193 lines of lib/ today (lib/SD and
src/ have no host tests and aren't counted). CI runs it on every push,
puts the figure in the job's summary, and on main publishes a coverage
badge and a latest-release badge to the branch 'badges'. The README shows
them next to Gitea's own badge for the workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 13:56:08 +02:00
twislaandClaude Opus 5.5 edc140e30c 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
2026-10-06 13:51:19 +02:00
twislaandClaude Opus 5.5 6459ca5446 R1 plan: CI and releases are in place
CI / build (push) Successful in 8m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 13:51:18 +02:00
twislaandClaude Opus 5.5 a94c14f000 R1 plan: CI as built on the container runner
CI / build (push) Successful in 8m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:52:43 +02:00
twislaandClaude Opus 5.5 db7e6ccc18 CI: jobs run in a container, PlatformIO directly in it, the toolchains in a volume
CI / build (push) Successful in 8m5s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:39:37 +02:00
twislaandClaude Opus 5.5 ababfaf993 Release build: tags from before Firmware Updates carry no public key to check against
CI / build (push) Failing after 12m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:25:20 +02:00
twislaandClaude Opus 5.5 6b6bb975f5 R1 plan and ADR 0008: CI signs releases; README section on CI
CI / build (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:19:00 +02:00
twislaandClaude Opus 5.5 1fade6287b CI: tests and builds on every push, a signed release on a tag (#5)
CI / build (push) Successful in 11m26s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:06:57 +02:00
twislaandClaude Opus 5.5 661227cd2d CI: try the runner's label as it is registered
CI / probe (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 11:58:53 +02:00
twislaandClaude Opus 5.5 c481bb5191 CI: a first workflow, to see what the runner gives a job (#5)
CI / probe (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 11:56:10 +02:00
twislaandClaude Opus 5.5 9f65c75f01 Merge branch 'notes': the Notes App
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:11:31 +02:00
14 changed files with 458 additions and 3 deletions
+108
View File
@@ -0,0 +1,108 @@
# CI and releases (docs/milestones/R1.md).
# A push: the host tests, with their coverage of lib/. On main, the README's badges
# are published to the branch `badges`.
# A pull request: the same, then the release firmware and the Debug Build.
# A tag v*: all of it, 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: ['**', '!badges'] # badges holds what CI itself publishes
tags: ['v*']
pull_request:
workflow_dispatch:
inputs:
tag:
description: An existing tag to build and publish as a release
required: true
jobs:
build:
runs-on: ubuntu
# A pull request from a fork would run someone else's code on our runner: not without us (Q154).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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 gcovr
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/*' '+refs/pull/*/head:refs/remotes/pull/*'
git checkout -q --detach "${{ github.sha }}"
git describe --tags --always
- name: Host tests, and their coverage of lib/
if: github.event_name != 'workflow_dispatch'
run: scripts/coverage.sh
- name: The release firmware and the Debug Build
if: github.event_name == 'pull_request' || github.ref_type == 'tag'
run: scripts/ci.sh builds
# The README's badges are files on a branch of their own, replaced at each push to main.
- name: Publish the badges
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
rm -rf /tmp/badges && mkdir /tmp/badges
cp .pio/coverage/coverage.svg .pio/coverage/summary.json /tmp/badges/
scripts/coverage_badge.py --plain release "$(git describe --tags --abbrev=0)" /tmp/badges/release.svg
cd /tmp/badges
git init -q -b badges .
git add .
git -c user.name="roro9stack CI" -c user.email="ci@git.twis.la" commit -q -m "Coverage of ${{ github.ref_name }} at ${{ github.sha }}"
git push -q --force "$(echo "${{ github.server_url }}" | sed "s#://#://ci:${GITEA_TOKEN}@#")/${{ github.repository }}.git" badges
- 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
+17
View File
@@ -1,5 +1,7 @@
# roro9stack
[![CI](https://git.twis.la/twisla/roro9stack/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [![Coverage of lib/ by the host tests](https://git.twis.la/twisla/roro9stack/raw/branch/badges/coverage.svg)](#build-and-test-local-ci) [![Latest release](https://git.twis.la/twisla/roro9stack/raw/branch/badges/release.svg)](https://git.twis.la/twisla/roro9stack/releases/latest)
A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0.
- Domain language: [CONTEXT.md](CONTEXT.md)
@@ -18,8 +20,23 @@ scripts/ci.sh
This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`.
`scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure.
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 (`.gitea/workflows/ci.yml`, docs/milestones/R1.md) runs the host tests on every push, and on a pull request also builds the release firmware and the Debug Build: changes reach `main` through pull requests. Pushing a tag `v*` runs all of it and 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.
+1 -1
View File
@@ -5,7 +5,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends git build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio
RUN pip install --no-cache-dir platformio gcovr
# Toolchains and libraries are cached in a named volume mounted here.
RUN mkdir -p /pio && chmod 777 /pio
+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.
+42
View File
@@ -0,0 +1,42 @@
# 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 | A push, to any branch: the host tests (with their coverage). A pull request: the same and both builds; changes reach `main` through pull requests. A tag `v*`: all of it, then a release. (First: everything on every push, which rebuilt the firmware far more often than anyone looked at it.) |
| 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. |
| Q161 | **Changes reach `main` through pull requests, merged as "rebase, then a merge commit"**, the only style the repository allows: the branch's commits keep their messages, the merge commit marks the pull request, and what CI tested is what lands. No squash, no fast-forward. |
### 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).
+11
View File
@@ -46,3 +46,14 @@ build_flags =
platform = native
lib_ldf_mode = deep+
build_flags = -std=gnu++17
; The same tests, built to count which lines of lib/ they run (scripts/coverage.sh).
[env:native-coverage]
extends = env:native
build_flags =
${env:native.build_flags}
--coverage
-O0
extra_scripts =
${env.extra_scripts}
pre:scripts/coverage_link.py
+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")" \
+8 -1
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
# Local CI: run host unit tests, then build the firmware.
# Usage: scripts/ci.sh [tests|builds] one half only; both by default
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'
case "${1:-all}" in
tests) STEPS='pio test -e native' ;;
builds) STEPS='pio run -e cardputer-adv -e cardputer-adv-debug' ;;
all) STEPS='pio test -e native && pio run -e cardputer-adv -e cardputer-adv-debug' ;;
*) echo "Usage: scripts/ci.sh [tests|builds]" >&2; exit 1 ;;
esac
run_in_container bash -c 'git config --global --add safe.directory "$PWD" && '"$STEPS"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Runs the host tests and says how much of lib/ they run: they're built with coverage counters.
# Usage: scripts/coverage.sh [out folder, default .pio/coverage]
# Out: summary.json (gcovr), coverage.svg (the README's badge), index.html (line by line).
# What it measures: the lines of lib/ that compile on a PC. Not lib/SD (the card's driver) and not
# src/ (the Apps, the Services, everything that needs the device): those have no host tests.
set -euo pipefail
source "$(dirname "$0")/_docker.sh"
DOCKER_EXTRA=()
OUT="${1:-.pio/coverage}"
run_in_container bash -c '
set -eo pipefail # a failing test fails this script, tail or not
git config --global --add safe.directory "$PWD"
rm -rf .pio/build/native-coverage "'"$OUT"'"
mkdir -p "'"$OUT"'"
pio test -e native-coverage | tail -1
gcovr -r . --filter "lib/" --object-directory .pio/build/native-coverage \
--json-summary "'"$OUT"'/summary.json" --html-details "'"$OUT"'/index.html" --print-summary | tail -4
python3 scripts/coverage_badge.py "'"$OUT"'/summary.json" "'"$OUT"'/coverage.svg"
'
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Draws the README's coverage badge from gcovr's summary.
Usage: scripts/coverage_badge.py <summary.json> <out.svg>
scripts/coverage_badge.py --plain <label> <value> <out.svg> any other badge, in blue
Also prints one line, and appends a short table to $GITHUB_STEP_SUMMARY when CI sets it.
"""
import json
import os
import sys
def badge(label, value, color, title):
left, right = 6 * len(label) + 12, 7 * len(value) + 12
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{left + right}" height="20" role="img" aria-label="{label}: {value}">
<title>{title}</title>
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
<clipPath id="r"><rect width="{left + right}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)"><rect width="{left}" height="20" fill="#555"/><rect x="{left}" width="{right}" height="20" fill="{color}"/><rect width="{left + right}" height="20" fill="url(#s)"/></g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="{left / 2}" y="14">{label}</text><text x="{left + right / 2}" y="14">{value}</text></g></svg>
'''
def main():
if sys.argv[1] == "--plain": # any other badge: --plain <label> <value> <out.svg>
label, value, out = sys.argv[2:5]
open(out, "w").write(badge(label, value, "#007ec6", f"{label}: {value}"))
return
summary = json.load(open(sys.argv[1]))
lines, branches = summary["line_percent"], summary["branch_percent"]
label, value = "lib coverage", f"{lines:.0f}%"
color = "#4c1" if lines >= 90 else "#a4a61d" if lines >= 75 else "#dfb317" if lines >= 60 else "#e05d44"
svg = badge(label, value, color, f"{label}: {value} of the lines of lib/ are run by the host tests")
open(sys.argv[2], "w").write(svg)
print(f"coverage of lib/: {lines:.1f}% of {summary['line_total']} lines, {branches:.1f}% of branches, {len(summary['files'])} files")
step = os.environ.get("GITHUB_STEP_SUMMARY")
if step:
worst = sorted((f["line_percent"], f["filename"]) for f in summary["files"] if f["line_total"] >= 10)[:5]
with open(step, "a") as out:
out.write(f"### Coverage of `lib/` by the host tests\n\n**{lines:.1f}%** of {summary['line_total']} lines, {branches:.1f}% of branches.\n\n")
out.write("| Least covered | Lines |\n|---|---|\n" + "".join(f"| `{name}` | {pct:.0f}% |\n" for pct, name in worst))
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
# The coverage build must also link with --coverage (build_flags only reaches the compiler).
Import("env") # noqa: F821 (provided by PlatformIO)
env.Append(LINKFLAGS=["--coverage"]) # noqa: F821
+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()