name: release # A release is blocked on the printer replica passing. # # The replica is the printer's real rootfs.squashfs, chrooted under # qemu-mipsel, with /usr/prog installed by FlashForge's own updater. The # installer under test runs on the printer's busybox, tar, md5sum and unTar -- # not on Debian stand-ins. If that fails, nothing ships. # # The stock FlashForge packages and the factory image are large but not # secret: they are published at ghzserg/FF and this workflow fetches what is # already public, exactly as ci.yml does. Every one is pinned by sha256 and # the checksum is enforced, not advisory -- an unpinned download would let the # archive change under us while the gates kept reporting green against a # different firmware than they were written for. # # TAGS. `v-`, e.g. v20260824-nova-kakhovka: the release date, # and the name of a Ukrainian city under occupation. Only the date reaches the # package filename -- the installer's version field is a date and nothing else # -- so the city is carried by the tag and the release title. on: push: tags: [ 'v*' ] workflow_dispatch: permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Fetch the stock firmware # Same artefacts, same checksums as ci.yml. Enforced: a mismatch here # means the thing we test against is not the thing we tested against # last time, and that has to stop the release rather than colour it. env: STOCK_URL_CREATOR5PRO: https://github.com/ghzserg/FF/releases/download/R/Creator5Pro-1.9.7-1.2.9-20260810.tgz STOCK_SHA_CREATOR5PRO: 1f3252feaae6d8fc72d7b0fd1ba832cc63d369659f8b2d2896efcf7adab2976b STOCK_URL_CREATOR5: https://github.com/ghzserg/FF/releases/download/R/Creator5-1.9.7-1.2.9-20260810.tgz STOCK_SHA_CREATOR5: 72009be183e74a4562050a927787ba686c6b558a075f00958892197ac52659cc run: | set -euo pipefail mkdir -p stock curl -fsSL "$STOCK_URL_CREATOR5PRO" -o stock/Creator5Pro-stock.tgz curl -fsSL "$STOCK_URL_CREATOR5" -o stock/Creator5-stock.tgz # Exactly two spaces before the path: sha256sum -c reads everything # after them as the filename, so a column-aligned line looks for a # file whose name begins with spaces and fails as "not found". echo "$STOCK_SHA_CREATOR5PRO stock/Creator5Pro-stock.tgz" | sha256sum -c - echo "$STOCK_SHA_CREATOR5 stock/Creator5-stock.tgz" | sha256sum -c - ls -lh stock - name: Write test.env (the replica) env: # THE replica: the printer's rootfs, its real /usr/prog and # /usr/data, and the stock package already installed on top. The # end-to-end test reinstalls the baseline itself when the package # under test is for the other model, so this only ever changes how # long a run takes, never what it proves. vars.PRINTER_IMAGE # overrides it for a fork building its own. # # A VERSION TAG, NOT :latest. A release is blocked on this image, so # it is as much a pinned input as the stock packages above -- and it # is the only one whose contents nothing here checksums. A floating # tag would let the substrate a release was proved on change without # a commit, which is exactly what the sha256 lines exist to stop. 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 <-; the package version is the date # alone. FlashForge's installer reads this as the software version # and it is compared as a number, so the codename stays in the tag. # A manual run has no tag -- GITHUB_REF_NAME would be the branch -- # so leave it empty and let the build stamp today's UTC date. # The optional trailing letter is the same-day re-release # convention common.sh documents (20260824b): nothing on the printer # reads it, so it only keeps the outer filenames distinct. MOD_VER=$(case "$GITHUB_REF" in refs/tags/*) echo "${GITHUB_REF_NAME#v}" | sed -n 's/^\([0-9]\{8\}[a-z]\{0,1\}\).*/\1/p' ;; *) echo "" ;; esac) TARGET_MACHINE=Creator5Pro STOCK_TGZ_CREATOR5PRO="$PWD/stock/Creator5Pro-stock.tgz" STOCK_TGZ_CREATOR5="$PWD/stock/Creator5-stock.tgz" STOCK_TGZ="" # No Klipper source knob any more: pkgs/klipper builds from the # commit pinned in versions.env and cross-compiles c_helper.so from # those same sources. KLIPPER_FORK used to be settable here, and # setting it to "" is what made v20260824 ship the stock 0.12-era # klippy; a recipe names one source, so that cannot recur. # The secret is INTENTIONALLY unset: a hash baked in here would be # the same root password in every public release. Empty means each # printer picks a random one on its first install and writes it to # anvil-password.txt on the stick; updates keep it. The plumbing # stays so a fork can bake one in deliberately. ROOT_PW_HASH='${{ secrets.ROOT_PW_HASH }}' FF_KEY='FFP0331&*%root' CFG # Before the suite, not after: the replica gates run ./bin/payload.sh # directly and it expects vendor/ already populated. `make release` # further down would have fetched them, but that runs after the gates. # # Cached because the set now includes the Klipper fork and the ~203MB # MIPS toolchain that compiles its chelper; the key is the pin file, so # a version bump refetches and anything else reuses. - name: Cache the pinned vendor assets uses: actions/cache@v4 with: path: vendor key: vendor-${{ hashFiles('versions.env') }} - name: Fetch the pinned Mainsail, HelixScreen, Moonraker and Klipper assets run: make vendor ASSET_ROOT=$PWD # THE FEED FIRST. bin/payload.sh builds the payload by installing it, so # it refuses to start without one, and the python recipes resolve their # build dependencies out of it. This used to run unpack/patch/pack with # no feed at all and could not have passed. - name: Build the package feed run: make packages ASSET_ROOT=$PWD - name: Build the package the suite installs run: make build ASSET_ROOT=$PWD - name: Brick-safety suite, replica required # No REQUIRE_PRINTER_SIM and no ALLOW_SKIP: qa has no skip mode, so a # missing docker, image or package fails at the point that needs it. # # Through `make`, not around it. These steps used to hand-roll their # own `docker run`, which meant two things: the release path exercised # entry points no human uses -- so a bug in one of the Makefile's own # runners could not be caught here -- and the container ran as root, # leaving a work/ that the `make release` below, which runs as the # runner user, could not write to. run: make qa ASSET_ROOT=$PWD - name: Build both models run: make release ASSET_ROOT=$PWD # BEFORE the end-to-end test, so a build that produced packages hands # them over even when the lane below fails. This is not a weakening of # the release gate: Publish runs after that step and the job stops if it # fails, so a red run still cannot reach the release page. - uses: actions/upload-artifact@v4 with: name: firmware-packages path: dist/*.tgz retention-days: 30 - name: End-to-end update test on the package that will actually ship # The suite above tested a package built from the same tree; this # tests the exact file that gets attached to the release. # # ONE MODEL, NOT BOTH. Two models are built and both are attached, but # there is one replica and it is a Creator5Pro -- its own app_startup.sh # globs /mnt/Creator5Pro-*.tgz and refuses anything else, which is the # model gate doing its job. Feeding it the Creator5 package produced 123 # errors that all said "could not install", and the package it was # complaining about was correct. # # This loop used to run both because sim-install.py picked its own # machine per package; the replica lane takes PRINTER_IMAGE from # test.env and there is one of those. A Creator5 end-to-end needs a # Creator5 replica image, which nothing publishes yet. # # REAL_PKG points the replica lane at one exact package instead of the # newest thing in work/out -- 22 named results where sim-install.py # gave one exit code. `make qa-replica` passes it through (see RUNSIM). run: | set -euo pipefail p=$(ls dist/${TARGET_MACHINE}-*.tgz) echo "=== $p ===" REAL_PKG="$p" make qa-replica ASSET_ROOT=$PWD env: TARGET_MACHINE: Creator5Pro - name: Assemble the release notes if: startsWith(github.ref, 'refs/tags/') # The fixed half is the same every time; the half that names the city # is not. docs/releases/.md carries it, and is appended when it # exists -- so the story of the city the release is named for lives in # the repo at the tagged commit, not only in a hand-edited release # page that nothing reproduces. run: | set -euo pipefail cat > RELEASE_NOTES.md <<'NOTES' Copy the file matching YOUR printer to the root of a FAT32 USB stick and power the machine on. A `Creator5Pro-` package will not install on a Creator 5 and vice versa. Every package here was installed into a replica of the printer -- the real rootfs running under qemu-mipsel, with the package on a real FAT filesystem that the machine's own app_startup.sh found and installed -- and the machine was booted again afterwards and checked to come up. **If the screen stays dark**, the printer is still up: ssh and Mainsail run from their own services, before and independently of the UI, so reach it at `http:///` or ssh in and read `/usr/data/logs/helixscreen.log`. There is no automatic fallback -- `opkg remove anvil-helixscreen` to boot headless on purpose. **To go back to stock**, put FlashForge's own package on a USB stick and power on -- that path is tested on every release too. --- NOTES CITY="docs/releases/${GITHUB_REF_NAME}.md" if [ -f "$CITY" ]; then cat "$CITY" >> RELEASE_NOTES.md else echo "::warning::no $CITY -- publishing the generic line instead" echo "Releases are named for a Ukrainian city under occupation." \ >> RELEASE_NOTES.md fi cat RELEASE_NOTES.md - name: Publish if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: files: dist/*.tgz # Every build so far is a pre-release: this replaces the stock UI on # a machine whose only recovery path is a USB stick with the stock # package on it. That is worth saying with the flag, not just in # prose people scroll past. prerelease: true name: ${{ github.ref_name }} body_path: RELEASE_NOTES.md