Skip to content

Release v0.4.11

Release v0.4.11 #196

Workflow file for this run

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (C) 2026 RS-Key contributors
# Seven jobs:
# changes — what this change can possibly affect (`scripts/ci-scope.sh`), so a
# documentation edit does not build 24 firmware images. Gating is by
# `if:` on the jobs rather than `paths:` on the trigger: a skipped
# JOB still reports, a skipped WORKFLOW reports nothing, and the
# second kind is the one that quietly hides a missing check.
# docs — the mdBook build + link check, gitleaks, and the workflow lint.
# Always. It is the only job a documentation-only change runs, so the
# secret scan lives here too — `check.sh` runs it as well, and one
# that can be skipped is not a secret scan. Same argument for
# actionlint, and it has a case: `pages.yml` is inside ci-scope.sh's
# DOCS_ONLY set, so a change to that workflow alone skips `check`
# entirely and would never be parsed by anything.
# check — the merge gate, the same single source of truth as local work:
# `nix develop -c ./scripts/check.sh` (docs/testing.md, "CI parity").
# Green locally means green here; no CI-only logic to drift.
# proofs — the Kani harnesses this change can have broken: the fast tier of
# `scripts/kani.sh` on any crate edit, plus the security-state tier
# (rsk-fido and rsk-fs — twelve minutes, one harness peaking at
# 9.3 GiB) when the diff reaches the state those sequence proofs are
# about. Everything slower — the phy round-trip, the
# functional division specs — stays in the daily `deep-checks` row,
# which runs the whole roster. Not part of `check.sh`: Kani is the one
# tool deliberately outside the nix shell (docs/testing.md).
# flavors — every feature combination of the firmware (no-touch ×
# advertise-pqc × fips-profile), the strong-pin / always-uv /
# strict-up behavior flavors (each with a pqc pair), and the
# trusted-display build, each packaged as a downloadable
# firmware-<flavor>.uf2 artifact.
# knob-builds — build smokes for the compile-time env knobs (docs/build.md),
# five parallel rows over `scripts/ci-knobs.sh`: all VIDPID presets,
# FW_VERSION, XOSC_DELAY_MULT, FAKE_* test keys, and a check that the
# default build bakes this project's own RS-Key identity while the
# opt-in Yubico flavor bakes Yubico's (the vendor-mimicking ids are
# local interop only, never shipped).
# knobs — the aggregate of those rows, and the name `main`'s ruleset requires.
# A required check has to be reported by a fixed name; a matrix
# reports one per row.
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
firmware: ${{ steps.scope.outputs.firmware }}
docs_only: ${{ steps.scope.outputs.docs_only }}
proofs: ${{ steps.scope.outputs.proofs }}
proofs_state: ${{ steps.scope.outputs.proofs_state }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
# The refs go through `env`, never through `${{ }}` inside the script: a
# branch name is attacker-supplied on a fork PR, and the difference is a
# shell injection.
- name: what changed
id: scope
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.base_ref }}
BEFORE: ${{ github.event.before }}
run: |
files=""
if [ "$EVENT" = "pull_request" ]; then
files="$(git diff --name-only "origin/$BASE...HEAD")"
elif [ -n "$BEFORE" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
files="$(git diff --name-only "$BEFORE" HEAD)"
fi
# An empty list means we could not work out a base — ci-scope.sh reads
# that as "everything", which is the direction a mistake should fail in.
printf '%s\n' "$files"
printf '%s\n' "$files" | ./scripts/ci-scope.sh | tee -a "$GITHUB_OUTPUT"
docs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- name: docs build + link check
run: nix develop -c ./scripts/docs.sh check
- name: gitleaks
run: nix develop -c gitleaks detect --no-banner --redact
# The same command check.sh's first row runs, for the one change shape that
# skips check.sh: a `pages.yml`-only edit reads as docs_only.
- name: workflow lint
run: nix develop -c actionlint -no-color -oneline
check:
needs: changes
if: needs.changes.outputs.docs_only != 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-check-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'flake.lock') }}
restore-keys: cargo-check-${{ runner.os }}-
- name: check.sh — fmt, clippy, host tests, fips flavor, firmware builds, audit, deny, gitleaks
run: nix develop -c ./scripts/check.sh
env:
HOST_TARGET: x86_64-unknown-linux-gnu
proofs:
needs: changes
if: needs.changes.outputs.proofs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
KANI_VERSION: "0.67.0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Same key the daily row writes, so a pull request restores the CBMC bundle
# that job already downloaded rather than fetching its own.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.kani
~/.cargo/bin/cargo-kani
~/.cargo/bin/kani
~/.cargo/registry
key: kani-${{ runner.os }}-${{ env.KANI_VERSION }}
# Kani builds under its own pinned nightly into its own target dir, so this
# shares nothing with the `check` job's cache and needs a key of its own.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target
key: cargo-kani-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-kani-${{ runner.os }}-
- name: install kani (rustup-based — not packaged in nixpkgs)
run: |
command -v cargo-kani >/dev/null || cargo install --locked kani-verifier --version "$KANI_VERSION"
cargo kani setup
- name: prove the fast tier
run: ./scripts/kani.sh pr
# The expensive half, run only when the change reaches the state it is about
# (`scripts/ci-scope.sh`, PROOFS_STATE). A step, not a second job: the Kani
# install and the build cache above are most of the cost, and paying them
# twice to skip twelve minutes of solving is the wrong trade.
- name: prove the security-state crates
if: needs.changes.outputs.proofs_state == 'true'
run: ./scripts/kani.sh state
flavors:
needs: changes
if: needs.changes.outputs.firmware == 'true'
strategy:
fail-fast: false
matrix:
include:
- name: default
args: ""
- name: pqc
args: "--features advertise-pqc"
- name: fips
args: "--features fips-profile"
- name: fips-pqc
args: "--features fips-profile,advertise-pqc"
- name: strong-pin
args: "--features strong-pin"
- name: strong-pin-pqc
args: "--features strong-pin,advertise-pqc"
- name: always-uv
args: "--features always-uv"
- name: always-uv-pqc
args: "--features always-uv,advertise-pqc"
- name: strict-up
args: "--features strict-up"
- name: strict-up-pqc
args: "--features strict-up,advertise-pqc"
- name: no-touch
args: "--features no-touch"
- name: no-touch-pqc
args: "--features no-touch,advertise-pqc"
- name: no-touch-fips
args: "--features no-touch,fips-profile"
- name: no-touch-fips-pqc
args: "--features no-touch,fips-profile,advertise-pqc"
# The trusted-display flavor: the panel takes the addressable-LED pin
# (LED_KIND=none, compile-time-guarded) and the UI assets want the
# larger flash (docs/guides/display.md). `env` is empty for every other
# row, so only this one carries the extra knobs.
- name: display
args: "--features display"
env: "LED_KIND=none FLASH_SIZE=16M"
# Flash-geometry siblings of the default image (default features, RS-Key
# identity). 2mb shrinks KVMAIN so the image still fits ≥1 MiB of code;
# smoke-building it here catches a link/fit regression in PR CI rather
# than at tag time.
- name: 2mb
args: ""
env: "FLASH_SIZE=2M KVMAIN=896K"
- name: 16mb
args: ""
env: "FLASH_SIZE=16M"
# One row per shipped board file. Nothing else in the tree builds a
# `BOARD=`, so without these a wrong pin or flash size in a TOML — the
# values a user gets by following the CHANGELOG — reaches them uncaught.
- name: board-waveshare-one
args: ""
env: "BOARD=waveshare-one"
- name: board-tenstar-usb
args: ""
env: "BOARD=tenstar-usb"
- name: board-seeed-xiao
args: ""
env: "BOARD=seeed-xiao"
- name: board-waveshare-touch-lcd
args: "--features display"
env: "BOARD=waveshare-touch-lcd"
- name: board-abrobot-4m
args: ""
env: "BOARD=abrobot-4m"
- name: board-abrobot-16m
args: ""
env: "BOARD=abrobot-16m"
# The strict admin-write posture (historical default). The permissive
# default is what every other row builds; smoke-build this so the
# strict-gated code keeps compiling.
- name: strict-config
args: "--features strict-config"
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
# One key per flavour. Sharing it meant 24 jobs racing to write the
# same entry while each restored a `target/` another feature set had
# built — so a matrix whose whole point is that the rows differ was
# rebuilding from cold, and thrashing the cache doing it.
key: cargo-flavor-${{ matrix.name }}-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'flake.lock') }}
restore-keys: cargo-flavor-${{ matrix.name }}-${{ runner.os }}-
- name: build (${{ matrix.name }})
run: nix develop -c env ${{ matrix.env }} cargo build --release -p firmware ${{ matrix.args }}
- name: package firmware-${{ matrix.name }}.uf2
run: |
nix develop -c ./scripts/pt.sh \
target/thumbv8m.main-none-eabihf/release/firmware \
firmware-${{ matrix.name }}.elf
nix develop -c picotool uf2 convert \
firmware-${{ matrix.name }}.elf -t elf \
firmware-${{ matrix.name }}.uf2
sha256sum firmware-${{ matrix.name }}.uf2
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: firmware-${{ matrix.name }}-${{ github.sha }}
path: firmware-${{ matrix.name }}.uf2
knob-builds:
needs: changes
if: needs.changes.outputs.firmware == 'true'
strategy:
fail-fast: false
matrix:
# `scripts/ci-knobs.sh --groups` is the list; keep the two in step, since
# a group nobody names is a smoke nobody runs. Grouped rather than one row
# per preset: a public repo gets 20 concurrent runners and `flavors`
# already wants 24, so past that more rows buy queue time, not wall-clock.
group: [vidpid-1, vidpid-2, vidpid-3, identity, misc]
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-knobs-${{ matrix.group }}-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'flake.lock') }}
restore-keys: cargo-knobs-${{ matrix.group }}-${{ runner.os }}-
- name: knobs (${{ matrix.group }})
run: nix develop -c ./scripts/ci-knobs.sh ${{ matrix.group }}
# The name `knobs` is a required status check on `main`, so it has to be
# reported by *something* whatever the matrix above looks like. Splitting the
# job renamed its checks to `knobs (<group>)` and left the required context
# waiting for a report that would never come — a merge blocked on a check that
# no longer exists. An aggregate keeps the contract stable: the matrix can grow,
# shrink or be renamed without touching the repository's rules.
#
# `always()` so it still reports when the matrix was skipped — a skipped job is
# a satisfied required check, an absent one is not.
knobs:
needs: [changes, knob-builds]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: the knob builds all passed (or had nothing to build)
env:
RESULT: ${{ needs.knob-builds.result }}
run: |
case "$RESULT" in
success | skipped) echo "knob builds: $RESULT" ;;
*) echo "knob builds: $RESULT"; exit 1 ;;
esac