diff --git a/.gitignore b/.gitignore index 21ac90f..3183dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,8 @@ # kas *.lock.yml.bak +# release.sh staging output +/release-staging/ + # Python bytecode __pycache__/ diff --git a/docs/UPDATE-SYSTEM.md b/docs/UPDATE-SYSTEM.md index 0187853..ba45c1b 100644 --- a/docs/UPDATE-SYSTEM.md +++ b/docs/UPDATE-SYSTEM.md @@ -142,15 +142,21 @@ demonstrably untouched.* ## Phase 3 — release pipeline -- `scripts/release.sh` (build host): kas build → size gate → pack - `.fw` → sign → `sha256sums.txt` → `gh release create` → post-check - that asset names match what the installer, GUI updater, and (later) - recovery expect. -- One version source: `FORGEFIRM_RELEASE` = git tag = +- `scripts/release.sh` (build host): gates → kas build → pack `.fw` → + sign → `sha256sums.txt` → staged assets + `gh release create` + command (`--publish` runs it where gh is authenticated). Gates: + clean tree, version single-source, rootfs-vs-slot size + (warn ≥ 170 MiB / fail ≥ 195 MiB, under bitbake's own hard cap), + **installer-embedded pubkey must match the signing key**, and + factory-era fwup (0.14.2) verification of the packed archive. +- One version source: `FORGEFIRM_RELEASE` = git tag = `/etc/forgefirm-version` = `.fw` meta-version; the script enforces agreement. -- Dev builds emit a `.fw` too (dev-key or unsigned — see open - questions) for the GUI upload path. +- `release.sh --dev` packs a **dev-key-signed** `forgefirm-dev.fw` + from the release rootfs for the GUI upload path (decides open + question 4: dev archives are signed with the dev key, never + unsigned — the GUI exercises the same verification path either + way). - GitHub Actions: per-push compile checks for grblHAL-glowforge and forgectrl (minutes, no Yocto); optional `workflow_dispatch` cold-Yocto reproducibility build whose only product is a checksum. diff --git a/kas/README.md b/kas/README.md index 6944035..483c50e 100644 --- a/kas/README.md +++ b/kas/README.md @@ -109,10 +109,16 @@ config move in the right order. The sequence, with current status: base recipe) so a fresh clone is fully self-contained; - refresh `kas lock`, tag all repos, and prove self-containment by building from a **fresh clone**. -5. **GitHub release**: upload the image asset under the exact name the - installer downloads — Scarthgap emits - `forgefirm-image-glowforge.rootfs.wic.gz`; align BUILD.md and - `install-forgefirm.sh` to one name before the first release. +5. **GitHub release**: run `scripts/release.sh ` on the build + host. It gates (version single-source, rootfs-vs-slot size, + installer-embedded pubkey vs the signing key, factory-era fwup + verification), builds, packs and signs `forgefirm.fw`, stages the + assets with `sha256sums.txt`, and prints the `gh release create` + command. Assets and their exact names (the installer and the update + manager download them verbatim): `forgefirm.fw`, `sha256sums.txt`, + `forgefirm-image-glowforge.rootfs.wic.gz`. The release tag + `v` = `FORGEFIRM_RELEASE` = the rootfs `/etc/forgefirm-version` + = the `.fw` meta-version; `release.sh` enforces the agreement. For gfhardware development, either bump the recipe pin per iteration or add a tracked externalsrc bbappend mirroring the kernel-module pattern. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..be0df9f --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# (C) Copyright 2020-2026 +# Scott Wiederhold, s.e.wiederhold@gmail.com +# https://community.openglow.org +# SPDX-License-Identifier: MIT +# +# ForgeFIRM release pipeline (runs on the Yocto build host). +# +# release.sh [--publish] full release: gates, build, pack, +# sign, checksums, stage, publish cmd +# release.sh --dev build + pack a dev-signed .fw for +# the GUI upload path; no staging +# +# Environment: +# FWUP host fwup for packing (default: fwup in PATH) +# FWUP_COMPAT factory-era fwup 0.14.2 binary; when set, the +# packed archive is verified with it (raw-format +# key), replicating the factory-compat guarantee +# FORGEFIRM_SIGNING_KEY private key for release mode (REQUIRED - no +# default, so key choice is always deliberate) +# FORGEFIRM_DEV_KEY private key for --dev mode (REQUIRED for --dev) +# RELEASE_STAGING_DIR where release assets are staged +# (default: /release-staging) +# +# Version contract: == FORGEFIRM_RELEASE in forgefirm-image.bb +# == /etc/forgefirm-version ("v") in the built rootfs == .fw +# meta-version ("v") == release tag ("v"). + +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +DEPLOY="$REPO/build/tmp/deploy/images/glowforge" +IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb" +INSTALLER="$REPO/scripts/install-forgefirm.sh" +WARN_BYTES=$((170 * 1024 * 1024)) +FAIL_BYTES=$((195 * 1024 * 1024)) + +die () { echo "RELEASE FAILED: $*" >&2; exit 1; } +warn () { echo "WARNING: $*" >&2; } + +VERSION="" +MODE=release +PUBLISH=0 +for ARG in "$@"; do + case "$ARG" in + --dev) MODE=dev ;; + --publish) PUBLISH=1 ;; + -*) die "unknown option $ARG" ;; + *) VERSION="$ARG" ;; + esac +done + +FWUP="${FWUP:-fwup}" +command -v "$FWUP" >/dev/null || die "fwup not found (set FWUP=)" +command -v kas >/dev/null || die "kas not found on PATH" + +build_images () { + echo "== building images ==" + ( cd "$REPO" && kas shell kas/forgefirm-glowforge.yml \ + -c 'bitbake forgefirm-image forgefirm-image-dev' ) \ + || die "bitbake failed" +} + +resolve_ext4 () { + EXT4=$(readlink -f "$DEPLOY/forgefirm-image-glowforge.rootfs.ext4") + [ -s "$EXT4" ] || die "release ext4 not found in $DEPLOY" +} + +check_size () { + SZ=$(stat -c%s "$EXT4") + [ "$SZ" -lt "$FAIL_BYTES" ] \ + || die "rootfs is $SZ bytes - too close to the 200 MiB slot" + if [ "$SZ" -ge "$WARN_BYTES" ]; then + warn "rootfs is $((SZ / 1048576)) MiB - $(( (FAIL_BYTES - SZ) / 1048576 )) MiB of margin left before the release gate" + fi +} + +# --- dev mode ----------------------------------------------------------------- +if [ "$MODE" = "dev" ]; then + KEY="${FORGEFIRM_DEV_KEY:?set FORGEFIRM_DEV_KEY to the dev signing key}" + build_images + resolve_ext4 + check_size + REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB") + DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)" + OUT="$DEPLOY/forgefirm-dev.fw" + "$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY" + echo "== dev archive ready: $OUT ($DEVVER) ==" + exit 0 +fi + +# --- release mode ------------------------------------------------------------- +[ -n "$VERSION" ] || die "usage: release.sh [--publish] | release.sh --dev" +KEY="${FORGEFIRM_SIGNING_KEY:?set FORGEFIRM_SIGNING_KEY to the release signing key}" +PUB="${KEY%.priv}.pub" +[ -f "$KEY" ] || die "signing key '$KEY' not found" +[ -f "$PUB" ] || die "public key '$PUB' not found" + +echo "== gates ==" + +# Repo state (informational when the build tree is not a git checkout). +if git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then + [ -z "$(git -C "$REPO" status --porcelain)" ] \ + || die "working tree is dirty - release from a clean tree" + if ! git -C "$REPO" diff --quiet "@{upstream}" 2>/dev/null; then + warn "HEAD differs from upstream - push before publishing" + fi +else + warn "$REPO is not a git checkout - repo-state gates skipped (rsynced build tree)" +fi + +# Version single-source check. +BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB") +[ "$BB_REL" = "$VERSION" ] \ + || die "FORGEFIRM_RELEASE in forgefirm-image.bb is '$BB_REL', not '$VERSION'" + +# The installer must embed the pubkey matching the signing key, or every +# install will refuse the published archive. +INST_HEX=$(sed -n "s/^PUBKEY='\(.*\)'$/\1/p" "$INSTALLER" | tr -d '\\x') +KEY_HEX=$(base64 -d "$PUB" | xxd -p | tr -d '\n') +[ -n "$INST_HEX" ] || die "cannot extract the embedded pubkey from the installer" +[ "$INST_HEX" = "$KEY_HEX" ] \ + || die "installer's embedded pubkey does not match the signing key - update install-forgefirm.sh" + +build_images +resolve_ext4 +check_size + +# Rootfs version stamp. +STAMP=$(debugfs -R "cat /etc/forgefirm-version" "$EXT4" 2>/dev/null) +[ "$STAMP" = "v$VERSION" ] \ + || die "rootfs stamp is '$STAMP', expected 'v$VERSION'" + +echo "== pack + sign ==" +STAGE="${RELEASE_STAGING_DIR:-$REPO/release-staging}/v$VERSION" +mkdir -p "$STAGE" +"$REPO/scripts/mkfw.sh" "$EXT4" "v$VERSION" "$STAGE/forgefirm.fw" "$KEY" + +# Factory-era compat verification (fwup 0.14.2 wants raw 32-byte keys). +if [ -n "${FWUP_COMPAT:-}" ]; then + RAW=$(mktemp) + base64 -d "$PUB" > "$RAW" + "$FWUP_COMPAT" -V -i "$STAGE/forgefirm.fw" -p "$RAW" \ + || { rm -f "$RAW"; die "factory-era fwup rejects the archive"; } + rm -f "$RAW" + echo "factory-era fwup verification OK" +else + warn "FWUP_COMPAT not set - factory-era verification skipped" +fi + +echo "== stage assets ==" +cp -L "$DEPLOY/forgefirm-image-glowforge.rootfs.wic.gz" "$STAGE/forgefirm-image-glowforge.rootfs.wic.gz" +( cd "$STAGE" && sha256sum forgefirm.fw forgefirm-image-glowforge.rootfs.wic.gz > sha256sums.txt ) +ls -la "$STAGE" + +cat </dev/null || die "--publish requested but gh is not on PATH" + ( cd "$STAGE" && gh release create "v$VERSION" --repo ScottW514/forgefirm \ + --title "ForgeFIRM v$VERSION" --generate-notes \ + forgefirm.fw sha256sums.txt forgefirm-image-glowforge.rootfs.wic.gz ) \ + || die "gh release create failed" + echo "== published v$VERSION ==" +fi