Files
Oleksandr ShyshatskyiandClaude Opus 5 734a04db45 Make the apk feed buildable by CI, and signable by a release
PR #10 was green on this machine and red on both CI jobs. Three
separate faults, none of which a local signed build could show.

apk-tools is cloned from gitlab.alpinelinux.org, which answers a
GitHub runner with HTTP 418 or with nothing until the connection
times out. It clones from the GitHub mirror now. Which host serves
it cannot change what is fetched: the pin is a commit sha, and git
names a commit by the hash of its content, so a mirror answering
with this sha is serving this tree or is not answering. get_git also
sets the remote url on every run, so a checkout made before this
commit does not keep fetching from the host that refuses.

anvil-core's cache key hashed the feed's public key as

    sha256sum "${APK_SIGN_KEY:-/dev/null}.pub" 2>/dev/null | cut ...

With no key that file is absent, sha256sum is non-zero, pipefail
makes the pipeline non-zero, and build-packages.sh -- which sources
every pkg.conf under `set -euo pipefail` -- died before emitting a
single package. The 2>/dev/null ate the reason, so `make packages`
exited 1 having said only that it had sealed a cache entry. Every
signed build passed. Every CI build is unsigned. It is now
pkg_signkey_hash, which answers `unsigned` and succeeds.

The build container mounts the checkout and nothing else, so a key
at the documented ~/.anvil/anvil.rsa was visible in the developer's
shell and invisible to apk. The Makefile reads APK_SIGN_KEY out of
$(CONFIG_ENV) and bind-mounts its directory read-only. This survived
review because the key used while writing the migration sat under
work/, inside the one directory already mounted.

Gates for all three, each verified against its own bug by reverting
the fix under it. The recipe-reads-unsigned test runs pkg_conf under
the same flags build-packages.sh uses -- the existing _conf helper
does not, and would have stayed green throughout. Its companion
asserts the recipe SET does not change with the key, because
pkg_recipes evaluates PKG_WHEN in a subshell and a recipe that fails
to source is dropped rather than reported: broken only when
unsigned, a package would leave the feed with nothing turning red.
Both are scoped to what pkg_recipes lists, so busybox -- gated off
whenever there is no stock firmware to take one from -- is not
asked a question it cannot answer.

release.yml takes the private key PEM from secrets.APK_SIGN_KEY,
writes it to $RUNNER_TEMP outside the checkout where no later step
can archive it into a release, and derives the public half rather
than storing a second secret that could drift out of step with the
first. Unset, the release builds unsigned with a warning: a fork has
no such secret, and firmware nobody else can build is worse than a
feed untrusted on a stick the printer was handed by hand.

Verified from a cold work/host and work/packages, three ways: no key
(the CI case), a key inside the tree, and a key outside it at the
documented location. Each gives 47 packages plus the index, a
payload, 286 static and 135 replica tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 15:15:29 +03:00

338 lines
16 KiB
Makefile

# creator5-custom-firmware
#
# Nothing runs on your machine except Docker. Every target below executes
# inside the pinned build image (docker/Dockerfile.build); the docker socket
# is mounted through so the simulation targets can start sibling containers.
#
# make build build the firmware package
# make qa full brick-safety suite
# make shell interactive shell in the build container
#
# Escape hatch: LOCAL=1 make <target> runs the scripts directly on the host.
# A package carries our installer and our payload and no FlashForge component
# at all: the stock installer skips absent ones, so /usr/prog, the kernel and
# the MCU/board firmware are left alone. FULL=1 carries the kernel, control and
# library components too (and reflashes the MCU).
FULL ?=
PACKARGS = $(if $(FULL),--full,)
DOCKER ?= docker
IMAGE ?= creator5-fw-build
LOCAL ?=
DOCKER_SOCK := /var/run/docker.sock
# The repo is mounted AT ITS REAL HOST PATH, not at /src. The test targets
# start sibling containers through the mounted docker socket, and those are
# created by the host daemon -- which resolves -v paths on the HOST. If the
# build container saw the repo as /src it would ask the daemon to mount a
# /src that does not exist there, and the sibling would get empty
# directories. Keeping the path identical on both sides avoids that entirely.
# config.env usually points at a stock package OUTSIDE this repo, so the
# directory holding it must be mounted too. Override when yours lives
# elsewhere: make ASSET_ROOT=/path/to/parent build
ASSET_ROOT ?= $(firstword $(wildcard /mnt/c /Users /home))
# Three runners, because the lanes need different things:
#
# RUN the parts of a build that touch no daemon: the feed, unpack,
# pack. No docker socket, no replica settings.
# RUNSIM the test replica. It starts SIBLING containers through the
# mounted socket, so it needs that plus the replica's own knobs.
# RUNBUILD `make build`, which needs both: the payload is assembled inside
# the replica, and the output has to belong to you. Defined below
# the two it borrows from.
#
# Test-only variables never enter a build, which is the point of the split.
#
# THE SIGNING KEY HAS TO BE MOUNTED, and this is the only place that can do
# it. config.env names the key and is read INSIDE the container, so make never
# sees the path unless it reads it here -- and the key deliberately lives
# outside the checkout (it must never be committable), which means the only
# mount that would have covered it is the one for this repo. Without this,
# `make packages` with a key at the documented ~/.anvil/anvil.rsa reports that
# the file does not exist, on a machine where it plainly does.
#
# Read from the config file only when the environment does not already say,
# so `APK_SIGN_KEY=... make packages` still works for a one-off -- and from
# $(CONFIG_ENV), the same file bin/common.sh reads, so pointing that at a
# second config does not leave the mount looking at the first one's key.
APK_SIGN_KEY ?= $(shell sed -n 's/^[[:space:]]*APK_SIGN_KEY=["'"'"']\{0,1\}\([^"'"'"']*\).*/\1/p' $(if $(CONFIG_ENV),$(CONFIG_ENV),config.env) 2>/dev/null | tail -n1)
# Its DIRECTORY, because the public half beside it is mounted by the same
# line, and read-only because a build has no business writing to either.
APK_SIGN_MOUNT = $(if $(wildcard $(APK_SIGN_KEY)),-v "$(patsubst %/,%,$(dir $(APK_SIGN_KEY)))":"$(patsubst %/,%,$(dir $(APK_SIGN_KEY)))":ro,)
DOCKER_BASE = $(DOCKER) run --rm -i \
-v "$(CURDIR)":"$(CURDIR)" -w "$(CURDIR)" \
$(if $(ASSET_ROOT),-v "$(ASSET_ROOT)":"$(ASSET_ROOT)",) \
$(APK_SIGN_MOUNT) \
-e MODEL -e TARGET_MACHINE -e CONFIG_ENV -e APK_SIGN_KEY
# THE BUILD LANE RUNS AS YOU, NOT AS root. Without this every `make build` and
# `make packages` fills work/ with root-owned files that the user who started
# it cannot delete -- so the next `rm -rf work/pkg` fails, and the tempting fix
# is to run the build outside the container instead. That is how a project ends
# up with two build environments and packages that differ depending on which
# one produced them, which is exactly what this image exists to prevent.
#
# HOME is set because a container user with no passwd entry has none, and
# anything that wants a dotfile (perl, pip) writes to / and fails.
#
# BUILD LANE ONLY. RUNSIM below keeps root: it talks to the docker socket to
# start sibling containers, and the socket's ownership inside the container is
# not the host user's.
DOCKER_USER = --user $(shell id -u):$(shell id -g) -e HOME=/tmp
ifeq ($(LOCAL),)
RUN = $(DOCKER_BASE) $(DOCKER_USER) $(IMAGE)
RUNSIM = $(DOCKER_BASE) \
-v $(DOCKER_SOCK):$(DOCKER_SOCK) \
-e TEST_ENV -e PRINTER_IMAGE -e REAL_PKG -e SIM_IMAGE \
-e SIM_VERBOSE -e PROG_MB -e DATA_MB -e REQUIRE_PRINTER_SIM \
$(IMAGE)
RUNTTY = $(subst --rm -i,--rm -it,$(RUNSIM))
else
RUN =
RUNSIM =
RUNTTY =
endif
# The build-lane runner with a tty, for targets that prompt. Derived from RUN,
# not RUNSIM: prompting needs no docker socket and no replica knobs.
RUNBLDTTY = $(subst --rm -i,--rm -it,$(RUN))
.DEFAULT_GOAL := help
.PHONY: help image shell passwd build vendor packages \
printer-image printer-image-push \
boot-screen boot-screen-sim \
qa qa-static qa-replica \
release clean distclean
help:
@echo 'creator5-custom-firmware -- everything runs in Docker'
@echo
@echo 'Build (see docs/hardware-testing.md before you flash):'
@echo ' make build the firmware Klipper fork, toolchanger, Mainsail,'
@echo ' ssh and HelixScreen'
@echo ' make release build BOTH models into dist/'
@echo ' make packages .apk packages + feed index into work/packages/'
@echo ' (make build INSTALLS these to make the payload,'
@echo ' so run this first; needs no stock package.'
@echo ' PKG=<name> builds that recipe and the ones'
@echo ' it builds against -- see'
@echo ' docs/notes/85-packaging.md)'
@echo
@echo 'Models: packages are model-specific and refuse to install on the'
@echo 'other one. MODEL=Creator5 make build builds the non-Pro variant.'
@echo
@echo 'Recovery: keep a copy of the STOCK FlashForge .tgz on a spare stick.'
@echo 'Flashing it restores every file the mod touches.'
@echo
@echo 'Test:'
@echo ' make qa both lanes'
@echo ' make qa-static needs nothing: parses, names, packaging, the boot graph'
@echo ' make qa-replica needs docker + the firmware: install, upgrade, boot'
@echo
@echo 'Look at things:'
@echo ' make boot-screen render the first-boot screen to work/boot-screen/*.png'
@echo ' make boot-screen-sim the same, drawn by the printer own python in the replica'
@echo
@echo 'qa-replica runs inside a replica of the printer: the real'
@echo 'rootfs.squashfs under qemu-mipsel, with the package'
@echo 'installed by the printer own app_startup.sh off a real FAT filesystem'
@echo 'at /dev/sda1. It needs make build, plus PRINTER_IMAGE in test.env'
@echo '(test.env.example carries the published one).'
@echo
@echo 'qa has no ALLOW_SKIP: a missing tool, daemon or image FAILS at the'
@echo 'point that needs it, so a gate that did not run cannot look green.'
@echo
@echo 'Other:'
@echo ' make passwd a ROOT_PW_HASH for config.env (prompts, echoes the hash)'
@echo ' make vendor download every pinned source, stock firmware included'
@echo ' make image build the build container'
@echo ' make shell shell inside it'
@echo ' make clean | distclean'
@echo
@echo 'Packages carry the installer and the payload; /usr/prog, the kernel'
@echo 'and the MCU are left untouched. FULL=1 also flashes the kernel and MCU.'
@echo
@echo 'Config: config.env is the BUILD config (what ships). test.env holds'
@echo 'the replica settings, which never reach a printer. Copy the .example'
@echo 'of each. LOCAL=1 make <target> runs on the host instead of in Docker.'
image:
@$(DOCKER) build -q -t $(IMAGE) -f docker/Dockerfile.build docker >/dev/null && echo "image $(IMAGE) ready"
config.env:
@echo 'no config.env -- copy the example and edit the paths:'; \
echo ' cp config.env.example config.env'; exit 1
shell: image
@$(RUNTTY) bash
# A ROOT_PW_HASH for config.env, generated INSIDE the pinned build image so
# the same python makes the same $6$ sha512-crypt everywhere. Host tools are
# not dependable here: LibreSSL's openssl has no `passwd -6` at all.
passwd: image
@$(RUNBLDTTY) python3 -W ignore -c 'import crypt, getpass; print(crypt.crypt(getpass.getpass("password: "), crypt.mksalt(crypt.METHOD_SHA512)))'
# ===========================================================================
# BUILD LANE -- produces the package you flash. Reads config.env, ships
# payload/ and assets/, and touches nothing under qa/.
# ===========================================================================
# Nothing third-party is vendored in the repo -- not Mainsail, HelixScreen and
# Moonraker, and not the stock FlashForge package either. bin/build.sh fetches
# what the build needs; this target pre-fetches everything, and is a
# no-op once vendor/ holds files with the sha256 that versions.env pins.
#
# --stock adds both models' stock firmware, ~186MB. It is a separate flag from
# --all because the packaging lane wants none of it: `./bin/fetch-assets.sh
# --all` is every payload piece and no FlashForge package, which is what keeps
# `make packages` runnable on a bare checkout.
vendor: image config.env
@$(RUN) ./bin/fetch-assets.sh --all --stock
# A THIRD LANE, because the build needs both halves of the split above.
# bin/payload.sh assembles the payload by starting the printer replica, so this
# lane needs the docker socket -- and it still has to run AS YOU, or every
# build leaves a root-owned work/ the next one cannot delete.
#
# --group-add is what makes those compatible: the socket is root:docker and a
# --user container has no supplementary groups, so it gets the socket's own
# gid instead of root. Read from the socket rather than assumed -- it is 999
# on some distributions and 1001 here.
RUNBUILD = $(DOCKER_BASE) $(DOCKER_USER) \
--group-add $(shell stat -c %g $(DOCKER_SOCK)) \
-v $(DOCKER_SOCK):$(DOCKER_SOCK) \
-e TEST_ENV -e PRINTER_IMAGE -e SIM_VERBOSE -e PROG_MB -e DATA_MB \
$(IMAGE)
build: image config.env
@$(RUNBUILD) ./bin/build.sh $(PACKARGS)
# The package feed (docs/notes/85-packaging.md). Builds every
# recipe under pkgs/ into work/packages/ as .apk files plus the feed index that
# makes that directory an apk repository.
#
# THE RELEASE PATH IS BUILT ON THIS. pkgs/3rdparty/python declares seven build
# dependencies, pkg_deps resolves them by unpacking their .apk out of
# work/packages, and bin/payload.sh builds none of the seven. `make build` on a
# cold checkout does not work without this target.
# bin/payload.sh now checks for the feed up front and names this command.
#
# It still, unlike `build`, needs NO stock FlashForge package -- which is most
# of the point: packaging has to be runnable in CI on a bare checkout, or the
# gate only runs where the proprietary firmware is and stops being a gate.
packages: image config.env
@$(RUN) ./bin/build-packages.sh $(PKG)
# One package per model, collected in dist/. They cannot share content: the
# two stock packages ship different firmwareExe binaries.
#
# It checks nothing itself: `make qa-replica` has the printer perform its own
# install, so THAT is the gate before a release goes out.
# The feed is built ONCE, outside the loop: nothing in it is model-specific
# (bin/payload.sh says so where it picks the roots -- TARGET_MACHINE names only
# the output file), so building it twice would only cost time. bin/build.sh
# is run with RUNBUILD and not RUN because bin/payload.sh assembles the payload
# INSIDE the replica, which needs the docker socket; `build` above has always
# used it and `release` was left behind on the runner that has no socket.
release: image config.env
@rm -rf dist && mkdir -p dist
@$(RUN) ./bin/build-packages.sh
@for m in Creator5Pro Creator5; do \
echo "=== $$m ==="; \
MODEL=$$m $(RUNBUILD) ./bin/build.sh $(PACKARGS) || exit 1; \
cp work/out/$$m-*.tgz dist/ || exit 1; \
done
@echo; echo "dist/:"; ls -lh dist | awk 'NR>1{print " "$$9" "$$5}'
@echo; echo "Each file installs ONLY on the model in its name."
# ===========================================================================
# TEST LANE -- never ships. Reads test.env for the replica settings; the only
# targets allowed to reach the docker daemon ($(RUNSIM)).
# ===========================================================================
# ---------------------------------------------------------------------------
# THE qa SUITE
#
# Same machine, same gates, one framework. `make qa` is the suite; see
# qa/conftest.py for the lanes.
#
# make qa both lanes
# make qa-static needs nothing: parses, bashisms, names, the probes
# make qa-replica needs docker + the firmware
#
# There is no strictness flag and there is nothing to remember to pass. A
# missing tool, daemon, replica image or package FAILS, at the point that
# needs it, with the fix in the message. Skips are reserved for a question
# that does not apply to this configuration, and there are currently none.
#
# The replica lane needs two things, and says so if either is absent:
#
# a base replica PRINTER_IMAGE in test.env (see test.env.example)
# a package work/out/*.tgz, from `make build`
#
# There is ONE way to get a replica, and it is that image. `make rootfs`
# used to be a second: it unsquashed rootfs.squashfs out of the stock
# package into work/rootfs and a thin container bind-mounted it. Every
# caller set PRINTER_IMAGE anyway -- CI included, which extracted a rootfs
# on every push and read none of it -- and `make printer-image` builds the
# image from the same public firmware without needing a stock package at
# all, so the fallback was a second path to the same place that nothing
# took. Rebuild the image when it goes stale; that is the one lever.
#
# It needs the package because it INSTALLS it, through the printer's own
# app_startup.sh off a real FAT stick, rather than hand-placing payload/.
# That way the install is under test too, and the payload under test is the
# built artefact -- s6 and CPython included, neither of which exists in
# payload/. Baked into an image once per package and cached on its md5.
# ---------------------------------------------------------------------------
qa: image
@$(RUNSIM) python3 -m pytest ./qa -q
qa-static: image
@$(RUN) python3 -m pytest ./qa/static -q
qa-replica: image
@$(RUNSIM) python3 -m pytest ./qa/replica -q
# A Docker image that IS the printer: real rootfs, real /usr/prog and
# /usr/data, with the stock package already installed on top of them.
#
# Both of those are per-run costs it removes. Measured on this repo:
#
# unpacking the 182MB factory image 22s every run
# installing the stock package under qemu 37s every run
# with the image 0.7s once, at build time
#
# make printer-image build (one image serves both models)
# make printer-image-push build and push to Docker Hub
#
# Point PRINTER_IMAGE in test.env at it to use it.
#
# The image contains proprietary FlashForge firmware.
printer-image: image
@$(RUNSIM) ./tools/replica/build-printer-image.sh
printer-image-push: image
@$(RUNSIM) ./tools/replica/build-printer-image.sh --push
boot-screen:
@./bin/preview-boot-screen.py
# The same frames, drawn by the PRINTER's python inside the replica. Slower,
# and the only render that proves what the panel would really show.
boot-screen-sim: image
@$(RUNSIM) ./tools/replica/sim-boot-screen.py
clean:
@rm -rf work/stage work/out work/modpayload-root
@echo cleaned
distclean:
@rm -rf work
@echo "removed work/"