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-matrixrunsmake build-matrix;unitclones 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 runsmake clean && make gui-release,make unit-testandmake unit-test-sdl;unit-wininstalls the MinGW cross toolchain and wine and runsmake unit-test-win: the SDL-only suites as Windows executables under wine. It builds no PGO binary, so on an SD-image cache missmake unit-test-winprovisions the image itself (make sdcard-image);regressionrunsmake clean && make gui-releaseandmake 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.