Files
firmware/.github/workflows/ci.yml
Oleksandr ShyshatskyiandClaude Opus 5 73279f66f3 A backtick in a comment ran make vendor into config.env
The printer-sim job wrote its config.env from an UNQUOTED heredoc, and one
of the comments inside it says

    # `make vendor` puts the pinned download. Setting them here would be

Those backticks are command substitution. The step ran `make vendor`,
captured its stdout, and pasted the fetch log into the config file it was
supposed to be writing -- so config.env line 6 came out as

    >> fetch   https://github.com/ghzserg/FF/releases/download/R/...

which sources as a redirect into a file named "fetch" plus a URL for a
command. Every run of that job died at 127 before it fetched anything.

The delimiter is quoted now and ROOT_PW_HASH loses the backslashes it only
needed to survive expansion. release.yml keeps its unquoted heredoc: it
interpolates $(case ...) and $PWD deliberately, and has no backticks inside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S64uRMdZbj9xPMns2dqMG8
2026-08-30 14:21:04 +03:00

302 lines
15 KiB
YAML

name: ci
# The stock FlashForge firmware is proprietary and ~93MB, so it is never in
# the repo. The `test` job therefore runs only what needs no firmware at all:
# qa/static and the host-side pytest tree. That is enough for the static and
# packaging checks, but NOT enough to prove a package will not brick a
# printer.
#
# That proof needs the printer's real rootfs, and so it needs the stock
# package. `printer-sim` below now always runs, because the packages it needs
# are publicly mirrored and pinned in versions.env rather than hidden behind a
# secret nobody had set. It used to be gated on STOCK_URL_CREATOR5PRO, which meant
# that for its entire existence it never ran once: a grey dash beside two
# green ticks, reading as "CI passed" while the only gates that decide
# whether a package bricks a printer were never executed.
on:
push:
branches: [ main, master ]
pull_request:
workflow_dispatch:
# The stock packages are no longer fetched here. They are pinned by name and
# sha256 in versions.env like every other third-party source, and
# bin/fetch-assets.sh downloads them into vendor/ -- so `make vendor` below
# gets them the same way a person's checkout does, and this workflow no longer
# carries a second spelling of "fetch the firmware, check the hash" that could
# drift from the one humans run.
#
# The secrets that used to override the URLs (STOCK_URL_CREATOR5PRO and
# friends, for a fork testing its own firmware) are gone with them. A fork
# re-pins versions.env instead, which works locally too -- the secrets only
# ever worked in CI, and the local build had no equivalent at all.
#
# The factory image used to be fetched here too, for PROG_DUMP. Both are gone:
# the replica is PRINTER_IMAGE and nothing else, and that image carries the
# real /usr/prog already. PRINTER_IMAGE is pinned to a version tag for the
# same reason the sha256s are -- it is now the whole substrate the
# brick-safety gates run on, so it must not move on its own.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# gcc/pkg-config/perl/zlib1g-dev/patch/make are for the SAME reason
# docker/Dockerfile.build carries them: cross-building CPython 3.13
# needs a HOST build-python of its own to freeze modules and
# byte-compile the stdlib, and that one needs a host C compiler,
# pkg-config (CPython's configure probes libffi through it), perl
# (OpenSSL's Configure is a perl program), zlib1g-dev (the build-python
# needs a working zlib module or ensurepip cannot even unpack pip's
# bundled wheel) and patch (lmdb's setup.py shells out to
# /usr/bin/patch by absolute path). See that file's own comment for the
# long version.
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
openssl xz-utils unzip binutils file squashfs-tools \
python3-pytest python3-jinja2 python3-pyflakes \
gcc pkg-config perl zlib1g-dev patch make
# The shell parse and the bashism pass live in qa/static/test_shell_syntax.py
# now; this step is the repo-wide shellcheck, which is a wider net.
- name: shellcheck
run: |
set -euo pipefail
sudo apt-get install -y shellcheck
# FOUND, NOT GLOBBED, and the count is asserted. The list here used
# to be four literal globs, two of which stopped matching anything:
# pkgs/*/payload/init.d/S* went when s6-rc replaced the init
# scripts, and pkgs/*/prog/ moved under payload/. shellcheck exits 2
# on a path it cannot open, so this step failed on every run -- and
# had it not, the opposite failure was worse: a glob that quietly
# matches nothing checks nothing and still reports green.
mapfile -t PRINTER_SH < <(
find pkgs -path '*/payload/*' \
\( -name '*.sh' -o -name firmwareExe \) | sort)
PRINTER_SH+=(installer/runFirmwareExe.sh)
if [ "${#PRINTER_SH[@]}" -lt 5 ]; then
echo "::error title=Nothing to check::found ${#PRINTER_SH[@]} on-printer scripts; the find pattern is stale."
exit 1
fi
printf 'checking: %s\n' "${PRINTER_SH[@]}"
# Errors are a gate. These used to end in `|| true`, which meant
# shellcheck could report anything it liked and the job stayed green.
# payload scripts run under busybox ash on the printer.
shellcheck -s sh -S error "${PRINTER_SH[@]}"
shellcheck -s bash -S error bin/*.sh tools/replica/*.sh
# Warnings are advisory: printed, not enforced.
shellcheck -s sh -S warning "${PRINTER_SH[@]}" || true
# s6 and CPython 3.13 have no BUILD_ flag -- every package ships them
# (see bin/fetch-assets.sh) -- so this job builds them for real rather
# than stubbing around them the way it stubs Mainsail, HelixScreen and
# the Klipper fork. Cached on versions.env: an unchanged pin never
# re-fetches the ~203MB Ingenic toolchain and never recompiles either
# tree. Both are recipes under work/pkg now -- four for s6, nineteen for
# CPython and its site-packages -- and every one carries pkg_stamp's key.
#
# work/pkg is every recipe under pkgs/ at once -- one cache entry instead
# of one per package, because pkgs/lib.sh derives the output directory
# from the recipe name rather than naming each one in bin/common.sh. A
# new recipe therefore needs no edit here, which is the same property
# that makes adding a package one directory. work/packages is the feed
# built out of those trees, cached beside them for the same reason.
- name: Cache the s6 + CPython 3.13 build inputs and outputs
uses: actions/cache@v4
with:
path: |
vendor
work/pkg
work/packages
key: fixture-py-${{ hashFiles('versions.env') }}
- name: Fetch the s6 + CPython 3.13 build inputs
run: |
set -euo pipefail
# This config.env exists purely to let fetch-assets.sh see a
# config at all; it skips Mainsail and HelixScreen (still gated on
# their own BUILD_ flags, unset here) and fetches what s6, CPython
# and the Klipper fork need, none of which has a BUILD_ flag of its
# own any more -- BUILD_KLIPPER is gone and the fork is the only
# Klipper, so its 27MB tarball comes down here with the rest. This
# config.env is KEPT rather than removed: the packaging step below
# reads it too, and unlike payload.sh, bin/build-packages.sh needs no
# stock package -- which is the whole reason the feed can be built
# in this job at all.
#
# s6 and CPython are not cross-built by payload.sh any more; there are
# no sections 5b/5c. They are recipes under pkgs/, built by
# build-packages.sh like every other one.
cat > config.env <<'CFG'
BUILD_MAINSAIL=0
BUILD_HELIX=0
BUILD_MOONRAKER=0
CFG
./bin/fetch-assets.sh
# BEFORE the static lane, because a good part of what that lane asks is
# about .ipk files, and those questions do not fail on a checkout with
# no feed -- they return, quietly, and the lane reports a pass having
# never opened a package. Building the feed here is what makes that
# answer mean something, and it needs no stock firmware to do it.
- name: Build the package feed
run: ./bin/build-packages.sh
# The qa suite's static lane: parses, bashisms, names, recipe layout,
# and -- now that the feed above exists -- what is inside the .ipk files.
#
# No strictness flag: a missing shellcheck or pyflakes fails on its own,
# because there is no configuration in which running this lane without
# them is a complete run. Only the static lane here -- this job has no
# stock firmware on purpose, and printer-sim below is where the replica
# is proved.
- name: qa -- static lane
run: python3 -m pytest ./qa/static -q
# The real check: install the package into a replica of the printer -- the
# actual rootfs.squashfs under qemu-mipsel, on the printer's own busybox --
# and assert the machine still boots. No `if:` guard: this job is the point
# of the repo, and a version of it that can quietly not run is worse than
# not having it.
printer-sim:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write test.env (the replica)
env:
# THE replica. It carries the printer's rootfs, its real /usr/prog
# and /usr/data, and the stock package already installed on top of
# them -- about a minute of setup saved on EVERY replica case, which
# over a full suite is the difference between this job and a coffee
# break. vars.PRINTER_IMAGE overrides it for a fork building its own.
#
# A VERSION TAG, NOT :latest. This image is now the entire substrate
# the brick-safety gates run on -- there is no local fallback left
# to build one from a stock package -- so a floating tag would let
# it change under a green suite. That is precisely the drift the
# sha256 pins above exist to prevent, and it would be worse here,
# because nothing in the run would even record what was tested.
PRINTER_IMAGE: ${{ vars.PRINTER_IMAGE || 'monstrofil/creator5-printer:1.9.7-1.2.9-20260810' }}
run: |
set -euo pipefail
if [ -z "${PRINTER_IMAGE:-}" ]; then
echo "::error title=No replica::PRINTER_IMAGE is empty and there is no other way to get a replica."
exit 1
fi
echo "PRINTER_IMAGE=\"$PRINTER_IMAGE\"" > test.env
cat test.env
- name: Write config.env
run: |
cat > config.env <<'CFG'
MOD_NAME=anvil
MOD_VER=ci
TARGET_MACHINE=Creator5Pro
# No stock paths: bin/common.sh defaults them into vendor/, where
# `make vendor` puts the pinned download. Setting them here would be
# the override that suppresses the checksum.
STOCK_TGZ=""
# No Klipper source knob any more: pkgs/klipper builds from the
# commit pinned in versions.env and nothing else. The variable that
# used to live here, KLIPPER_FORK, is what let v20260824 ship the
# stock klippy tree by being set to "" in this very file.
ROOT_PW_HASH='$6$ci$abcdefghijklmnopqrstuvwxyz'
FF_KEY='FFP0331&*%root'
CFG
# The replica gates run ./bin/payload.sh, which needs the real Mainsail
# zip and HelixScreen tarball in vendor/ -- payload.sh does not fetch them
# itself, bin/build.sh does, and the gates do not go through build.sh.
# Nothing here noticed until this job ran for the first time, because
# every local run already had a populated vendor/. All are public,
# sha256-pinned in versions.env, and re-download only on a version bump.
#
# Cached because the set now includes the Klipper fork and the ~203MB
# MIPS toolchain that compiles its chelper -- this job runs on every
# push, and the toolchain host should not pay for that.
- name: Cache the pinned vendor assets
uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ hashFiles('versions.env') }}
# `make vendor` is --all --stock, which now also brings down both models'
# stock firmware (~186MB) -- this job builds Creator5Pro but the qa lane
# reads both baselines. Same cache, keyed on versions.env, so the pins and
# the firmware they name move together and an unchanged pin costs nothing.
# The `test` job above calls bin/fetch-assets.sh bare and so still gets
# no firmware at all, which is what keeps its feed build honest.
- name: Fetch the pinned vendor assets and the stock firmware
run: make vendor ASSET_ROOT=$PWD
# THE FEED, and it is not optional on the release path. bin/payload.sh
# builds the payload by INSTALLING the feed, so it refuses to start
# without one, and the python recipes resolve PKG_BUILD_DEPENDS out of
# work/packages. These two steps ran as one unpack/patch/pack with no
# feed for a while, which could not have passed.
#
# Unlike the build below, this needs no stock firmware -- which is most
# of the point: packaging has to be runnable on a bare checkout, or the
# gate only runs where the proprietary package is.
- name: Build the package feed
run: make packages ASSET_ROOT=$PWD
# BUILD THE PACKAGE THE SUITE INSTALLS. The qa replica lane installs for
# real -- the printer's own app_startup.sh, off a FAT stick -- so it
# needs a package in work/out, and it never builds one on demand: that
# would turn "the suite is slow today" into a mystery.
#
# It also supplies the cross-built s6 and CPython, because both ship
# INSIDE the package; there is no separate build tree to track.
#
# Through `make`, not around it. `make build` runs bin/build.sh on the
# RUNBUILD lane -- as the runner user, with the docker socket and the
# socket's own gid -- because bin/payload.sh assembles the payload inside
# the replica. Hand-rolling the `docker run` here meant CI exercised an
# entry point no human uses, so a mistake in one of the Makefile's own
# runners could never turn this job red.
- name: Build the package
run: make build ASSET_ROOT=$PWD
# The brick-safety gate. No ALLOW_SKIP and no REQUIRE_PRINTER_SIM: qa has
# no skip mode at all, so a missing docker, daemon, image or package
# fails at the point that needs it rather than being reported as a gap
# somebody has to remember to accept.
#
# The replica starts sibling containers through the docker socket and
# needs loop devices for the simulated USB stick; both work on a stock
# ubuntu-latest runner.
- name: qa suite on the printer replica
run: make qa ASSET_ROOT=$PWD
# The docs make claims about a machine that can be left unbootable, and the
# pages cross-link each other and the payload sources heavily -- 30+ links
# between README.md, docs/*.md and docs/notes/. This is the cheap half of
# keeping them true: --strict fails on a link whose target has moved or been
# deleted, so a rename in payload/ cannot quietly leave a doc pointing at
# nothing. It cannot check that the prose is still right; only a reader can.
#
# Read the Docs builds the same mkdocs.yml with fail_on_warning, so this job
# going red is also the warning that the published site is about to fail.
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12" # what .readthedocs.yaml builds with
- name: Install the docs toolchain
run: pip install -r docs/requirements.txt
- name: Build the site
run: mkdocs build --strict