Skip to content

Merge pull request #216 from forgesworn/release/lud25-key-notes-beta24 #590

Merge pull request #216 from forgesworn/release/lud25-key-notes-beta24

Merge pull request #216 from forgesworn/release/lud25-key-notes-beta24 #590

Workflow file for this run

name: CI
# Gate every PR (and main) on: the banned-identity rule, the host-testable
# `common` logic, and a real firmware build that must still fit the 2 MB
# ota_0 slot. The firmware previously had NO automated safety net — a compile
# break or a size overflow was only ever caught by building locally.
on:
# Manual dispatch is the clean retry path when GitHub marks a push run as a
# runner startup failure before any job exists.
workflow_dispatch:
pull_request:
push:
branches: [main]
# Default to the least privilege any job here needs; no job in this workflow
# writes to the repo (release.yml scopes its own `contents: write`).
permissions:
contents: read
jobs:
# Server-side enforcement of the check-in identity rule (the local pre-commit
# hook can be bypassed with --no-verify; this gate cannot). The banned string
# is assembled at runtime so this file stays clean and never trips its own check.
identity-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Forbid the banned commit identity
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
banned="$(printf 'd%s' 'bowles')"
fail=0
if git grep -nIi "$banned" -- . ':(exclude).githooks/'; then
echo "::error::tracked content contains the banned identity"
fail=1
fi
if [ -n "${BASE:-}" ] && [ -n "${HEAD:-}" ]; then
range="$BASE..$HEAD"
else
range="HEAD~1..HEAD"
fi
if git log --format='%an <%ae>%n%cn <%ce>' "$range" | grep -i "$banned"; then
echo "::error::a commit in $range is authored/committed by the banned identity"
fail=1
fi
[ "$fail" -eq 0 ] && echo "OK: banned identity absent ($range)"
exit "$fail"
# Server-side twin of scripts/git-hooks/pre-commit's secret scan: the local
# hook only runs for contributors who installed it and is bypassable with
# --no-verify; this gate is neither. Scans every line a PR adds for 64-hex
# secrets, nsec keys, SECRET=<hex> assignments and the deny-listed
# credentials leaked in 97d3f954.
secret-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- name: Scan added lines for secrets
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
if [ -n "${BASE:-}" ] && [ -n "${HEAD:-}" ]; then
range="$BASE...$HEAD"
else
# Push to main: scan the tip commit (mirrors identity-guard).
range="HEAD~1...HEAD"
fi
python3 scripts/git-hooks/pre-commit --range "$range"
# Pure host-testable logic (auth/replay decisions, BIP-39 generation, policy,
# validation). Fast — no Xtensa toolchain. `+stable` overrides the repo's
# rust-toolchain.toml (channel = "esp") which would otherwise force the
# cross-compiler the host crate doesn't need.
common-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
# Pass 1 — secp256k1 backend (the firmware's curve) + BIP-39 mnemonic/derive,
# and LUD-25's note derivation on that same curve. `cash` has to run under
# BOTH backends: it is the only path here with an unhardened level, so it
# is the only one that touches a compressed public key, and the two
# backends compute that separately. A secret that differs between them is
# a note one build of this firmware can spend and the other cannot.
- run: cargo +stable test --manifest-path common/Cargo.toml --no-default-features --features mnemonic-gen,cash
# Pass 2 — k256 backend with the full data-plane crypto. Without this the
# nip44/nip46/nip04/backup modules stay feature-gated OFF, so their tests
# (the official NIP-44 vectors, NIP-04 round-trips, the synthetic nonce)
# never run anywhere in CI. ota-sign covers the OTA release-signature
# scheme (the on-device verifier and the CI signer share this module).
- run: cargo +stable test --manifest-path common/Cargo.toml --features nip44,nip46,nip04,ota-sign,device-identity,seed-encrypt,cash,compact-hashes
# Supply-chain gate over the host-buildable crates: RUSTSEC advisories,
# licence allow-list and source registries, enforced against the shared
# deny.toml. There is no workspace root here (each crate is standalone), so
# cargo-deny is pointed at each manifest with an explicit --config. The
# firmware crate cross-compiles for Xtensa/RISC-V and is covered by the
# per-board builds; its dependency surface is shared with `common`, which is
# scanned. serialport (provision/heartwoodd) needs libudev to resolve.
cargo-deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
- uses: taiki-e/install-action@16b05812d776ae1dfaabc8277e421fb6d2506419 # v2
with:
tool: cargo-deny
- name: cargo-deny (host crates)
run: |
set -eu
for crate in common heartwoodd provision ota ota-sign sign-test sprite-gen ui-preview; do
echo "::group::cargo-deny $crate"
cargo deny --manifest-path "$crate/Cargo.toml" \
check --config deny.toml advisories licenses sources bans
echo "::endgroup::"
done
# The host-side provisioning CLI (provision/) — the offline, air-gapped key
# tool (restore from phrase/nsec, generate, pair the bridge secret). Build +
# test it on the host so this security-critical path can't silently break.
# `+stable` (not the repo's `esp` channel); serialport needs libudev on Linux.
provision-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
- run: cargo +stable test --manifest-path provision/Cargo.toml
# Screen geometry (ui-preview/) — it includes firmware/src/{layout,palette,
# bigtext}.rs verbatim via #[path], so its tests are the firmware's own layout
# tests plus a scan of every show_error card in firmware/src. `show_error`
# does not wrap: five cards were silently clipped on the 128x64 OLED, two of
# them the ones that tell an owner whether their RNG is trustworthy. Nothing
# ran these before — the crate appeared only under cargo-deny.
ui-preview-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- run: cargo +stable test --manifest-path ui-preview/Cargo.toml
# Bench-script logic (scripts/**/*.test.mjs): the frame codec, bridge session
# auth, the partition-table parser, and the RNG self-test log classifier —
# which reads firmware/src/entropy.rs so a reworded verdict fails here rather
# than turning a stuck RNG into "inconclusive" at the bench. node:test only;
# none of these need serialport.
script-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
- run: node --test $(find scripts -name '*.test.mjs' | sort)
# The OTA release signing tool (ota-sign/) — keygen/sign/verify for the
# ed25519 release signatures the firmware enforces. Kept green here so the
# release pipeline's signing step can't be the first place it compiles.
ota-sign-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- run: cargo +stable test --manifest-path ota-sign/Cargo.toml
# The Pi-side daemon. Nothing built it here, so it sat broken on main for a
# while after a shared ConnectSlot field landed: the firmware and common crates
# compiled, the daemon did not. Build plus test, so a struct change in common
# cannot quietly break the Soft-mode backend again.
heartwoodd-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
- run: cargo +stable build --manifest-path heartwoodd/Cargo.toml
- run: cargo +stable test --manifest-path heartwoodd/Cargo.toml
# Real firmware build for every Xtensa esp-idf board, plus the app-slot fit
# check that has bitten us before (heavy crypto crates / opt level overflow the
# slot). heltec-v3/v4 are ESP32-S3 (2 MB ota_0); the T-Display is the classic
# ESP32 with a 4 MB single-OTA layout (3 MB factory slot). Building all of them
# here catches a board-specific break at PR time, not only when the release
# runs. The C6 (RISC-V) and ESP8266 (lx106) firmwares need different toolchains
# and so have their own jobs below.
firmware-build:
strategy:
fail-fast: false
matrix:
include:
- board: heltec-v4
target: xtensa-esp32s3-espidf
chip: esp32s3
limit: 2097152
- board: heltec-v3
target: xtensa-esp32s3-espidf
chip: esp32s3
limit: 2097152
- board: tdisplay
target: xtensa-esp32-espidf
chip: esp32
limit: 3145728
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: esp-rs/xtensa-toolchain@b989c80ffd4be88d30c09606ee9b449bd0d41958 # v1.5
# Authenticate the action's "latest Xtensa Rust" GitHub API lookup so it
# uses the 5000/hr limit, not the 60/hr unauthenticated one that has
# 403'd under concurrent matrix jobs.
env:
GITHUB_TOKEN: ${{ github.token }}
with:
default: true
buildtargets: ${{ matrix.chip }}
ldproxy: true
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
with:
workspaces: firmware
# Per-board cache: the boards now span esp32s3 and esp32, and esp-idf-sys
# keeps a STICKY generated sdkconfig keyed to the chip — a shared cache
# would let one board's sdkconfig bleed into another board's build.
key: ${{ matrix.board }}
- uses: taiki-e/install-action@5b4d68e2e660441203ab128a23676f1e4faf1532 # v2
with:
tool: espflash
- name: Build firmware (${{ matrix.board }}, release)
working-directory: firmware
env:
# MCU drives esp-idf-sys's chip selection; it must match the board's
# target (the config.toml default is esp32s3, fine for heltec but wrong
# for the T-Display's classic esp32).
MCU: ${{ matrix.chip }}
ESP_IDF_SDKCONFIG_DEFAULTS: "sdkconfig.defaults;sdkconfig.defaults.${{ matrix.board }}"
run: cargo build --release --target ${{ matrix.target }} --no-default-features --features ${{ matrix.board }}
- name: App image must fit the ${{ matrix.board }} app slot
working-directory: firmware
run: |
set -eu
espflash save-image --chip ${{ matrix.chip }} \
target/${{ matrix.target }}/release/heartwood-esp32 /tmp/app.bin
size=$(stat -c%s /tmp/app.bin)
limit=${{ matrix.limit }}
echo "${{ matrix.board }} app image: $size / $limit bytes ($((size * 100 / limit))%)"
if [ "$size" -gt "$limit" ]; then
echo "::error::${{ matrix.board }} app image ($size B) exceeds its app slot ($limit B)"
exit 1
fi
# Kept for a week so a branch can be benched on a board this machine
# cannot build for (the T-Display's classic ESP32): an app-only write at
# the board's app offset, never a full flash over its NVS.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: app-${{ matrix.board }}
path: /tmp/app.bin
retention-days: 7
# ESP32-C6 (RISC-V rv32imac) firmware. The Xtensa toolchain action above cannot
# install a RISC-V toolchain, so this job uses espup to install the Espressif
# Rust channel (which ships the riscv32imac-esp-espidf target) plus the matching
# riscv32-esp-elf GCC for the C FFI (secp256k1-sys, esp-idf). The -fno-pic CFLAGS
# fix lives in firmware/.cargo/config.toml. 4 MB single-OTA layout, 3 MB factory.
firmware-build-c6:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Install the esp Rust toolchain (espup — nightly Rust + LLVM)
run: |
set -eu
curl -fL -o espup \
https://github.com/esp-rs/espup/releases/latest/download/espup-x86_64-unknown-linux-gnu
chmod +x espup
# The xtensa chip (esp32) pulls the NIGHTLY-based "esp" Rust toolchain that
# firmware/rust-toolchain.toml pins — build-std (-Zbuild-std) is nightly-only,
# riscv-on-stable can't build `core` — plus the esp LLVM (libclang for bindgen).
# espup does NOT install the riscv gcc (every CI run logged only "Installing
# GCC (xtensa-esp-elf)"), so the C FFI cross-compiler is fetched in the next step.
./espup install --targets esp32 --export-file "$HOME/export-esp.sh"
- name: Install the riscv32-esp-elf gcc (the C FFI cross-compiler)
run: |
set -eu
# esp-idf v5.3.2's RISC-V cross-compiler (gcc 13.2.0). secp256k1-sys's cc build
# needs `riscv32-esp-elf-gcc` on PATH; the Xtensa boards get theirs from the
# toolchain action, but espup never installs the riscv one — fetch it directly
# and add it to PATH for the build (mirrors the esp8266 lx106-gcc step).
curl -fL -o /tmp/riscv-gcc.tar.xz \
https://github.com/espressif/crosstool-NG/releases/download/esp-13.2.0_20240530/riscv32-esp-elf-13.2.0_20240530-x86_64-linux-gnu.tar.xz
mkdir -p "$HOME/.local"
tar -xJf /tmp/riscv-gcc.tar.xz -C "$HOME/.local"
echo "$HOME/.local/riscv32-esp-elf/bin" >> "$GITHUB_PATH"
- name: Install ldproxy (esp-idf linker shim)
run: cargo install ldproxy
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
with:
workspaces: firmware
key: c6
- uses: taiki-e/install-action@5b4d68e2e660441203ab128a23676f1e4faf1532 # v2
with:
tool: espflash
- name: Build firmware (c6, release)
working-directory: firmware
env:
MCU: esp32c6
ESP_IDF_SDKCONFIG_DEFAULTS: "sdkconfig.defaults;sdkconfig.defaults.c6"
run: |
set -euo pipefail
. "$HOME/export-esp.sh"
# The riscv gcc is on PATH from the install step above (via $GITHUB_PATH);
# confirm it before building so a PATH problem fails here, not deep in cc-rs.
riscv32-esp-elf-gcc --version | head -1
cargo build --release --target riscv32imac-esp-espidf --no-default-features --features c6
- name: App image must fit the C6 3 MB app slot
working-directory: firmware
run: |
. "$HOME/export-esp.sh"
set -eu
espflash save-image --chip esp32c6 \
target/riscv32imac-esp-espidf/release/heartwood-esp32 /tmp/app.bin
size=$(stat -c%s /tmp/app.bin)
limit=3145728
echo "c6 app image: $size / $limit bytes ($((size * 100 / limit))%)"
if [ "$size" -gt "$limit" ]; then
echo "::error::c6 app image ($size B) exceeds its 3 MB app slot ($limit B)"
exit 1
fi
# ESP8266 tethered-signer firmware — a SEPARATE crate (esp8266-firmware/), a
# no_std lx106 binary with its own target, toolchain and linker. It shares the
# `esp` Rust channel (which ships xtensa-esp8266-none-elf) but the final link
# needs Espressif's prebuilt xtensa-lx106-elf-gcc, which no toolchain action
# provides. This job proves the firmware still COMPILES AND LINKS into a
# flashable ELF; the runtime (does k256 fault on lx106's unaligned access?) is
# a separate on-hardware step the boot self-test reports on first flash.
esp8266-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: esp-rs/xtensa-toolchain@b989c80ffd4be88d30c09606ee9b449bd0d41958 # v1.5
env:
GITHUB_TOKEN: ${{ github.token }}
with:
default: true
ldproxy: false
# Pin to the esp toolchain the firmware is known to build on. The latest
# esp Rust (1.95.0.0) dropped the `compiler-builtins-no-f16-f128` build-std
# feature that esp8266-firmware/.cargo/config.toml needs to stop the lx106
# backend choking on f16/f128 builtins ("cannot scavenge register"). 1.87.0.0
# is nightly-based, so it also accepts the vendored rt's #![feature(naked_functions)].
version: "1.87.0.0"
- name: Install the xtensa-lx106-elf GNU toolchain (the lx106 linker)
run: |
set -eu
curl -fL -o /tmp/lx106.tar.gz \
https://dl.espressif.com/dl/xtensa-lx106-elf-gcc8_4_0-esp-2020r3-linux-amd64.tar.gz
mkdir -p "$HOME/.local"
tar -xzf /tmp/lx106.tar.gz -C "$HOME/.local"
echo "$HOME/.local/xtensa-lx106-elf/bin" >> "$GITHUB_PATH"
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
with:
workspaces: esp8266-firmware
key: esp8266
- name: Build esp8266 firmware (release — compile + link)
working-directory: esp8266-firmware
run: cargo build --release