add nix flake/fix release mode tests
All checks were successful
Build / Engine, CLI and TUI (push) Successful in 1m56s
Build / Android (push) Successful in 5m59s
Build / Sign (push) Successful in 3m38s
Build / Publish (push) Successful in 12s

This commit is contained in:
Emil Lerch 2026-10-04 15:01:16 -07:00
parent 95b2db5e02
commit 3c9ec72b88
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 201 additions and 17 deletions

View file

@ -55,13 +55,27 @@ jobs:
# Engine, CLI, TUI, the C ABI from Zig, the JNI layer against a synthetic
# function table, and the C ABI from C through the installed header.
run: zig build test --summary all
- name: Run tests in each release mode
# What ships is not Debug: the CLI and TUI are ReleaseSafe, the Android library
# ReleaseSmall. Debug fills undefined memory with 0xaa, so code that reads
# something it never wrote can pass there by luck and fail in a release build:
# the JNI tests did exactly that. ReleaseFast has no safety checks at all, which
# is where such a read shows up most readily, so it runs too.
run: |
for mode in ReleaseSafe ReleaseSmall ReleaseFast; do
echo "== $mode"
zig build test -Doptimize="$mode" --summary all
done
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Prove the JNI table against a real JVM
run: zig build jvm-test --summary all
# Debug, and ReleaseSmall: the optimization the Android library ships with.
run: |
zig build jvm-test --summary all
zig build jvm-test -Doptimize=ReleaseSmall --summary all
- name: Package the CLI and TUI
# One binary holds both. Static musl on Linux, so it runs on any distribution.
run: |

4
.gitignore vendored
View file

@ -2,6 +2,10 @@
.zig-cache/
zig-out/
# nix build output links
result
result-*
# Gradle and Android build artifacts
android/.gradle/
android/.kotlin/

View file

@ -104,6 +104,35 @@ Android ties an app to its signing key permanently, so a published APK cannot up
copy you built yourself (which is signed with your debug key), and the reverse. Uninstall
one before installing the other; the app's saved state goes with it.
### With nix
This repository is a flake, for the CLI and TUI (the Android app stays with gradle). To
add it to a `buildEnv`-style home profile:
```nix
inputs = {
tally.url = "git+https://git.lerch.org/lobo/tally.git";
# Optional: reuse your own nixpkgs instead of instantiating another one.
tally.inputs.nixpkgs.follows = "nixpkgs";
};
```
then add `tally.packages.${system}.default` to your package list. Or run it directly:
```
nix run git+https://git.lerch.org/lobo/tally.git -- '2^100 + 1'
```
The package is the `tally` binary, plus the engine as `lib/libtally-engine.a`,
`lib/libtally.so` and `include/tally.h`. `nix flake update tally` picks up a new version.
The build runs `zig build test` in the sandbox, in ReleaseSafe like the binary it
installs, so a version that fails its tests will not install. `nix develop` gives a shell
with Zig 0.16 and zls.
Changing the dependencies in `build.zig.zon` invalidates the `zigDeps` hash in
`nix/package.nix`. To refresh it: set the hash to `lib.fakeHash`, build, and paste the
hash nix reports.
## Building
Every tool comes from [mise](https://mise.jdx.dev), pinned in [.mise.toml](.mise.toml):
@ -186,9 +215,13 @@ A few decisions shape everything else, and the design document explains each:
- `zig build test`: about a thousand Zig tests across the engine, CLI, TUI (rendering
real frames and reading the cells back), the C ABI, and the JNI layer against a
synthetic function table; plus a C program that links the shared library through
`tally.h`, which catches a header that disagrees with the library.
`tally.h`, which catches a header that disagrees with the library. CI also runs it with
`-Doptimize=ReleaseSafe`, `ReleaseSmall` and `ReleaseFast`: what ships is not Debug,
and Debug's 0xaa fill of undefined memory can hide a read of something never written
(it hid one in the JNI tests).
- `zig build jvm-test`: the JNI entry points from Java, which is the only way to check
the function-table indices without a device.
the function-table indices without a device. CI runs it in Debug and in ReleaseSmall,
the optimization the Android library ships with.
- `cd android && gradle testDebugUnitTest`: JVM tests of the app's rules (the converter,
programmer mode, saved state), no device needed.
- `cd android && gradle connectedAndroidTest`: the engine through the real app on a
@ -203,12 +236,12 @@ punctuation: the source is ASCII.
[.forgejo/workflows/build.yaml](.forgejo/workflows/build.yaml) runs on every push:
| Job | Runner | What |
|---------------------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test`, `zig build jvm-test`, then release builds for x86_64 and aarch64 Linux and aarch64 macOS |
| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) |
| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore |
| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` |
| Job | Runner | What |
|---------------------|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test` in Debug and each release mode, `zig build jvm-test` in Debug and ReleaseSmall, then release builds for x86_64 and aarch64 Linux and aarch64 macOS |
| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) |
| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore |
| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` |
Every job reports to ntfy. Zlint and Gradle are downloaded at pinned versions and checked
against pinned SHA-256 digests; Zig's version comes from `build.zig.zon`.

View file

@ -508,20 +508,24 @@ const FakeJvm = struct {
var current: ?*FakeJvm = null;
/// Puts every field in its starting state, defaults included: the tests declare the
/// fake `undefined` and call this, so a field set nowhere here would be whatever the
/// stack held. It was: `misplace_new_string` was never reset, Debug's 0xaa fill
/// happened to read as false, and release builds, which do not fill, failed the JNI
/// tests whenever the leftover byte was odd.
fn init(self: *FakeJvm) void {
self.table = .{ .entries = @splat(null) };
self.* = .{
.table = .{ .entries = @splat(null) },
.vm_table = .{ .entries = @splat(null) },
// Addresses inside `self`, which is where this value is being written.
.env = .{ .functions = &self.table },
.vm = .{ .functions = &self.vm_table },
};
self.table.entries[@intFromEnum(EnvIndex.get_version)] = @ptrCast(&getVersion);
self.table.entries[@intFromEnum(EnvIndex.new_string_utf)] = @ptrCast(&newStringUtf);
self.table.entries[@intFromEnum(EnvIndex.get_string_utf_chars)] = @ptrCast(&getStringUtfChars);
self.table.entries[@intFromEnum(EnvIndex.release_string_utf_chars)] = @ptrCast(&releaseStringUtfChars);
self.env = .{ .functions = &self.table };
self.vm_table = .{ .entries = @splat(null) };
self.vm_table.entries[@intFromEnum(VmIndex.get_env)] = @ptrCast(&getEnv);
self.vm = .{ .functions = &self.vm_table };
self.made_len = 0;
self.releases = 0;
current = self;
}

26
flake.lock generated Normal file
View file

@ -0,0 +1,26 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791142733,
"narHash": "sha256-x1jawtfPqMTQYpt1d6H+9rxXweqTzU5hYhdfz0T5MPM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "466eaf945dddfd32abd006bd94cc55660fdb56c8",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "master",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

46
flake.nix Normal file
View file

@ -0,0 +1,46 @@
{
description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI";
inputs.nixpkgs.url = "nixpkgs/master";
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
# Helper to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'.
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
tally = pkgs.callPackage ./nix/package.nix {
# Pinned deliberately rather than tracking `pkgs.zig`: build.zig.zon
# declares 0.16.0 as the minimum, and 0.16 was a large standard
# library refactor, so a newer default would not build unchanged.
zig = pkgs.zig_0_16;
# The git-tracked tree, which keeps .zig-cache, zig-out, zig-pkg and
# the Android build outputs out of the derivation.
src = self;
};
in
{
inherit tally;
default = tally;
});
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in
{
# `nix develop` for the CLI/TUI without mise. The versions here are the
# same ones .mise.toml pins; the Android toolchain stays with mise.
default = pkgs.mkShell {
packages = [
pkgs.zig_0_16
pkgs.zls
];
};
});
};
}

57
nix/package.nix Normal file
View file

@ -0,0 +1,57 @@
{ lib, stdenv, zig, src }:
stdenv.mkDerivation (finalAttrs: {
pname = "tally";
# Kept in step with build.zig.zon.
version = "0.1.0";
inherit src;
__structuredAttrs = true;
strictDeps = true;
# Zig resolves build.zig.zon dependencies (libvaxis) over the network, which the
# build sandbox forbids. `fetchDeps` runs `zig build --fetch` inside a fixed-output
# derivation, so the fetch happens once against a known hash and the real build
# stays offline.
#
# This hash changes whenever build.zig.zon's dependencies change. To update it: set
# `lib.fakeHash`, run the build, and paste the hash nix reports.
zigDeps = zig.fetchDeps {
inherit (finalAttrs) pname version src;
fetchAll = true;
hash = "sha256-Wy/H3z2dBsF/WTF2LrCofOuE15pbgdwEJLSUrKCOQGI=";
};
postConfigure = ''
# A writable copy rather than a symlink into the store: Zig writes cache
# metadata alongside the fetched packages while it verifies them.
cp -rLT ${finalAttrs.zigDeps} "$ZIG_GLOBAL_CACHE_DIR/p"
chmod -R u+w "$ZIG_GLOBAL_CACHE_DIR/p"
'';
nativeBuildInputs = [ zig ];
# The zig setup hook's check phase runs `zig build test`, with the same flags as the
# build (ReleaseSafe): the engine, CLI, TUI, C ABI and JNI tests, and the C program
# linked through tally.h. None of them touch the network or anything outside the
# build directory, so every rebuild is a test run. (`jvm-test`, `coverage` and
# `android` are separate steps, not run here.)
doCheck = true;
meta = {
description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI";
longDescription = ''
One Zig engine behind a command line, a terminal UI and an Android app:
exact rational arithmetic where the operations allow it, programmer mode
(8 to 128-bit integers in every base), unit conversion as part of the
expression language, and financial functions. This package is the CLI/TUI
binary, plus the engine as a static library and as a shared library with
its C header (tally.h).
'';
homepage = "https://git.lerch.org/lobo/tally";
license = lib.licenses.mit;
mainProgram = "tally";
platforms = lib.platforms.unix;
};
})