JNEXT
Skip to content

5.4 Continuous integration

Two workflows run by themselves. ci.yml tests, and runs on every push to main and every pull request. release.yml builds and publishes packages, and runs on a v* tag push.

The rest are dispatched by hand, from Actions → Run workflow. They exist because a platform whose build only runs at release time is a platform whose breakage is found at release time. macos-build.yml builds, packages and verifies the macOS .dmg; windows-build.yml produces the three Windows zips, which reaches a reporter a fix build for a ref ci.yml does not run on (every ci.yml run on main already offers the same zips, see Testing a run's packages); and flatpak-build.yml builds the Flatpak bundle and checks its sandbox permissions.

flatpak-build.yml is different in kind from the other two, and the difference is worth copying. It is not a hand-dispatched copy of the release job — it is the release job. It carries both a workflow_call and a workflow_dispatch trigger, and the flatpak jobs in ci.yml and release.yml are one line each that calls it. So a manual run is evidence about the build that actually ships, rather than about something that resembles it. macos-build.yml and windows-build.yml predate the pattern and still repeat their release.yml counterpart's make targets.

The hard rule: every step is a plain make target

The organising idea behind ci.yml is worth stating before the details, because it is not how most projects are set up. Every run: in the workflow is a plain make target — the same one a human types locally. There is no CI-only build script, no YAML that knows how to do something the Makefile cannot. The purpose is that CI and a local run can never quietly diverge: if the suite is green on your machine it is green for the same reasons it will be green on the runner, and when it goes red you reproduce it by typing the target from the log.

The rule has a corollary for when CI seems to need something the local flow lacks. The answer is almost never a CI-only step. Either the project is missing a target — in which case add it to the Makefile, so local runs get it too — or the need is imaginary. There is exactly one declared exception, the Flatpak build in flatpak-build.yml, and it is written down both in that file's header and in doc/RELEASE-PROTOCOL.md so that it stays a decision rather than becoming drift. Its permission check is not part of the exception: that step runs make verify-flatpak-permissions, the same target a local make package-flatpak ends with.

The second corollary is never pipe a build or test command. GitHub's default bash -e does not set pipefail, so a piped step takes the pipe's exit status, and a failing make therefore reports success. That is not a hypothetical worry: a run here once printed 62 pass, 1 fail and UNIT TESTS FAILED in bold, and went green.

Why a Fedora container

Three of the seven ci.yml jobs — test, qt5-guard and package — run in a pinned Fedora container: the same image release.yml builds the rpm and Windows artifacts in, and the same distribution family the maintainer develops on. (The remaining four cannot: macos, macos-unit and macos-regression need a macos-latest runner, and flatpak runs in the Flathub flatpak-github-actions image, because that is where flatpak-builder and the KDE SDK live.)

Matching the maintainer's distribution is load-bearing rather than cosmetic. The man page, USAGE.md, the rendered user guide and the rendered developer guide — including its Graphviz SVGs — are all generated and committed, and pandoc, mkdocs-material and graphviz all emit byte-different output from one version to the next. A runner on a different distribution therefore reports a version gap as staleness, which is exactly what happened: a runner whose distribution shipped an older pandoc than the maintainer's failed docs-check on every run from the day the man page landed. Matching the distribution removes the whole class of problem, instead of pinning tool by tool and waiting for the next one.

The container tracks the updates repository of fedora:44, so CI can receive a newer package than a developer host that has not upgraded yet: unit-test-win was once red on CI under wine 11.20 and green on a host with 11.0. To see CI's package set locally, build an image from ci.yml's two install lists (the test dependencies, and the MinGW and wine packages), and run the same make targets in it against a separate detached worktree, with --security-opt label=disable --userns=keep-id and $HOME/.jnext and $HOME/.cache mounted, so the host's build trees and wine prefixes are never shared with the container's wine. This is a documented local check, not a CI step.

The jobs in ci.yml

test is the main job: one definition, a matrix of five legs that run in parallel on runners of their own. Every leg installs the same dependency set and checks out the repository with submodules; then

  • build-matrix runs make build-matrix;
  • unit clones the ZX Next FPGA core — sparsely, at a pinned commit, into the runner's temp directory outside the workspace, because the traceability generator validates every VHDL citation against the real source tree and produces different bytes without it — and runs make clean && make gui-release, make unit-test and make unit-test-sdl;
  • unit-win installs the MinGW cross toolchain and wine and runs make unit-test-win: the SDL-only suites as Windows executables under wine. It builds no PGO binary, so on an SD-image cache miss make unit-test-win provisions the image itself (make sdcard-image);
  • regression runs make clean && make gui-release and make regression.

They used to be steps of one job, so the 28-minute build matrix and the regression ran back to back although neither uses the other's output. Each leg has its own ccache key, and fail-fast is off so a red leg never cancels the others' evidence. On failure a leg uploads the screenshot diffs and the test summary under its own artifact name.

make gui-release comes first in both test legs for a reason. It is a PGO build (5.5) whose training needs the SD image, so the image is restored from the cache before it — and if the cache misses, the training downloads it through jnext's own provisioner. make regression's provisioning row then finds it, and make unit-test needs the same image for sd_rom_extractor_test — which is why the unit leg builds gui-release although none of its suites runs it.

qt5-guard runs make qt5-guard-build, which is the same GUI and debugger sources compiled against native Linux Qt5. Nothing else in CI compiles that combination, so a Qt6-only API creeping into the GUI would otherwise surface only at release time, inside the MinGW cross job, which is a slow and confusing place to find out. It is build-only by design.

package runs make package-test in its own parallel job, because it is several minutes of package builds using a toolchain the test job has no other use for. Each package is a PGO build, so the job also installs wine, for the Windows legs' training, and restores the SD image from the same cache entry as the test job. Every release and manual-build job that builds a PGO binary does the same; the macOS jobs keep their own entry, since a macOS runner's home directory differs from a container's. One detail matters here: a missing packaging tool is a SKIP, and a SKIP fails the run (locally too — the old CI-only $CI split is gone), so a row that has quietly stopped running cannot read as a pass. The Flatpak build is not a row of this job; the flatpak job below builds it, and only the tool-free flatpak-manifest contract runs here. After make package-test passes the job uploads each package it built as a run artifact; the steps are uses: steps, so the make-target rule is untouched.

macos runs make package-macos on a real macos-latest runner and uploads the .dmg, macos-unit runs make unit-test-sdl on one, with Homebrew's bash, coreutils and grep installed because the unit harness needs them (macOS ships bash 3.2 and BSD tools), and uploads the suites' logs on failure, and flatpak calls flatpak-build.yml, which actually builds the bundle inside the KDE runtime container and then asserts its sandbox permissions. Both exist because a platform whose failures are invisible until release time fails too late to be useful — the v0.98.34 .dmg referenced Homebrew paths and aborted in dyld on every Mac except the one that built it. Neither job is continue-on-error, since going red when the thing stops working is the entire point of having them.

The permission check came with the consolidation and is not incidental. This job used to be release.yml's copied near-verbatim, minus that step, so from the day that defect was fixed a bundle that stopped carrying --share=network would still have passed here on every push — which is exactly the defect the gate was written for. One body, one gate, three callers.

Testing a run's packages

Every green ci.yml run leaves its builds on the run's Summary page, under Artifacts, so a tester can try a build without a release. Upload is by uses: actions/upload-artifact@v6 steps (no run:). The package and macos uploads come after their job's tests, so they happen only on success; the Flatpak is uploaded by its action BEFORE the permission check, so it can be present in a red run. if-no-files-found: error turns a renamed output into a red job. Retention is 14 days (the Flatpak, uploaded by its action, keeps the repository default of 90), and compression is off since the packages are already compressed.

artifact file inside from
jnext-windows-x64-zip jnext-<v>-windows-x64.zip package
jnext-windows-x64-legacy-zip jnext-<v>-windows-x64-legacy.zip package
jnext-windows-x86-legacy-zip jnext-<v>-windows-x86-legacy.zip package
jnext-windows-x64-sdl-zip jnext-<v>-windows-x64-sdl.zip package (repo-internal, not a release asset)
jnext-windows-x86-sdl-zip jnext-<v>-windows-x86-sdl.zip package (repo-internal, not a release asset)
jnext-linux-rpm jnext-<v>-1.x86_64.rpm package
jnext-source-zip jnext-<v>-src.zip package
jnext-macos-dmg jnext-<v>-Darwin.dmg macos
jnext-linux-flatpak jnext-<v>-x86_64.flatpak flatpak

There is no .deb. The one package-deb builds in the Fedora container is only checked for content: it links Fedora's shared SDL3 and carries no Debian Depends, so it would install on Ubuntu and then fail at launch. The shippable debs exist only in release.yml's Ubuntu-container matrix; Ubuntu and Debian testers use the Flatpak.

A developer fetches one with gh run download <run-id> -n <artifact>. A tester follows the click path in the user guide (Installing, "Trying a build before it is released"). Pick a run whose overall status is green: an artifact appears when its upload step runs, possibly while another job is still running or failing. A pull-request run's artifacts are built from that PR's code; test main runs unless asked to test a PR. A new package-* row needs its own upload step in ci.yml: nothing cross-checks the two.

The SD-card image is provisioned, never faked

roms/ is git-ignored, so a fresh checkout has no NextZXOS SD image, and jnext's harness treats a missing image as a fatal fault rather than as a silent skip. (The z88dk-gdb client gets the same treatment: make regression provisions it with make z88dk-gdb, no CI step needed.) Either make unit-test or make regression will therefore fail outright without one; there is no honest way to run most of the suite and still call the job green.

So CI runs jnext's own provisioner — the same code path an end user reaches with --sdcard-download-confirm, downloading and FAT32-patching the official public NextZXOS distribution image into its default location, cached on the runner. Reimplementing that download in YAML would be exactly the mistake the make-target rule exists to prevent. It also has a second benefit: because the image is always the pristine distribution copy rather than a locally contaminated fixture, every declared regression row runs, including the one whose reference screenshot is an SD-card directory listing.

The tag-gated release workflow

release.yml triggers on a v* tag push. Its first job, gate, reads releases.yaml from the tag's own commit and decides whether this tag is public — the allowlist described in 5.3. When it is not, nothing builds, and the tag remains a private history marker.

When it is, six artifact jobs run. deb is a matrix over two pinned Ubuntu LTS containers, one per supported release, because dpkg-shlibdeps bakes the container's own library package names into the dependency list, so a deb built in one release's container is uninstallable on the next. rpm builds in the pinned Fedora container, so that its dependencies are Fedora-native. Then src, windows (three zips from one Fedora container), flatpak (one line, calling flatpak-build.yml), and macos. Finally publish attaches everything to a GitHub Release, gated on if: success() && needs.gate.outputs.publish == 'true'.

That explicit success() is what makes a failed artifact job withhold the release rather than silently omit a platform. Every artifact job is blocking as of v0.99.110. The accepted cost is that a hiccup on one platform holds up the others, and it is accepted because a release should either cover the platforms it claims to cover or stop and say so. Shipping a deliberately partial release is still possible — it means restoring continue-on-error on that one job in that one commit, which makes it an explicit and visible decision each time rather than a standing one.

The regression suite on other operating systems

Two more blocking jobs run a portable subset of the regression suite, with the same plain make targets a developer types. The regression-win leg of the test job installs the MinGW cross toolchain (including mingw64-qt6-qtbase and mingw64-qt6-qtsvg) and wine and runs make regression-win: the screenshots and the functional rows Windows can run, against the Release + LTO Qt exe and the SDL-only exe under wine. macos-regression runs make regression-macos on macos-latest with Homebrew's qt@6, ffmpeg, imagemagick, mtools, dosfstools added to the unit job's list. Rows an OS cannot run are declared by os= tags and printed as absent, never skipped.