diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef94c955..1d7f3b28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,6 +71,48 @@ jobs: - name: Run Deno lint run: deno lint + # Independent of the rust job and intentionally absent from build/playwright + # needs: so Zig work cannot extend the critical path. + zig: + name: Zig Lint, Test, and Build + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Read pinned Zig toolchain from .tool-versions + id: zig-version + shell: bash + run: | + version=$(grep '^zig ' .tool-versions | awk '{print $2}') + echo "toolchain=${version}" >> "$GITHUB_OUTPUT" + + - name: Restore Zig build cache + uses: actions/cache@v4 + with: + path: | + zig-core/.zig-cache + zig-core/zig-out + key: zig-build-${{ runner.os }}-${{ steps.zig-version.outputs.toolchain }}-${{ hashFiles('zig-core/build.zig', 'zig-core/build.zig.zon', 'zig-core/src/**/*.zig', '.tool-versions') }} + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: ${{ steps.zig-version.outputs.toolchain }} + + - name: Install Task + uses: go-task/setup-task@v1 + + - name: Zig format check + run: task zig:lint + + - name: Zig tests + run: task zig:test + + - name: Zig build + run: task zig:build + build: name: Build (${{ matrix.platform }}) # Gate on lint/format/test jobs so builds don't burn CI minutes diff --git a/.tool-versions b/.tool-versions index 851dc90d..3f6dd410 100644 --- a/.tool-versions +++ b/.tool-versions @@ -9,3 +9,4 @@ prek 0.3.2 ruff 0.12.11 uv 0.9.24 hadolint 2.14.0 +zig 0.15.2 diff --git a/backlog/tasks/task-355.1 - Zig-toolchain-Taskfile-integration-and-CI-job.md b/backlog/tasks/task-355.1 - Zig-toolchain-Taskfile-integration-and-CI-job.md index 881e8ef9..bf78f43f 100644 --- a/backlog/tasks/task-355.1 - Zig-toolchain-Taskfile-integration-and-CI-job.md +++ b/backlog/tasks/task-355.1 - Zig-toolchain-Taskfile-integration-and-CI-job.md @@ -1,7 +1,7 @@ --- id: TASK-355.1 title: 'Zig toolchain, Taskfile integration, and CI job' -status: To Do +status: In Progress assignee: [] created_date: '2026-09-11 00:38' labels: [] @@ -19,10 +19,29 @@ The Zig toolchain must be reproducibly available to both local developers and CI ## Acceptance Criteria -- [ ] #1 zig 0.15.2 is pinned in .tool-versions -- [ ] #2 taskfiles/zig.yml provides format, lint, test, build, and cross-compile tasks following the structure of taskfiles/cargo.yml -- [ ] #3 A missing or version-mismatched zig installation fails with an actionable error message rather than silently using the wrong binary -- [ ] #4 task lint, task format, and task test invoke the Zig equivalents alongside the existing Rust and Deno ones -- [ ] #5 A new zig CI job runs on a Blacksmith Linux runner in parallel with the existing rust job and does not extend the critical path +- [x] #1 zig 0.15.2 is pinned in .tool-versions +- [x] #2 taskfiles/zig.yml provides format, lint, test, build, and cross-compile tasks following the structure of taskfiles/cargo.yml +- [x] #3 A missing or version-mismatched zig installation fails with an actionable error message rather than silently using the wrong binary +- [x] #4 task lint, task format, and task test invoke the Zig equivalents alongside the existing Rust and Deno ones +- [x] #5 A new zig CI job runs on a Blacksmith Linux runner in parallel with the existing rust job and does not extend the critical path - [ ] #6 Both a cold-cache and a warm-cache run of the zig job are measured and the timings recorded on this task + +## Plan + +See PROMPT.md decisions on the branch: `zig-core/` placeholder executable (no SQLite/HTTP — that is TASK-355.2), zig resolved through mise against the pinned version rather than PATH, and a standalone `zig` CI job that no other job depends on. + + +## Notes + +### Local verification (2026-09-11) + +`mise install zig` -> 0.15.2, then all green against the placeholder `zig-core/`: +`task zig:format`, `task zig:lint`, `task zig:test` (1/1 tests passed), `task zig:build`, plus `task zig:cross-compile TARGET=aarch64-macos|x86_64-macos|x86_64-linux|x86_64-windows`. `task --list` shows the six `zig:*` tasks, and root `task format` / `task lint` / `task test` now run the zig step last (the Vitest step in root `task test` fails on 17 pre-existing frontend failures in `context-menu-favorites`, `go-to-album`, `go-to-artist` and `library.store` tests — untouched by this task). + +AC#3 was exercised by hand: a pinned version that is not installed, a resolved binary reporting a different version, and `mise` absent from PATH each produce their own error naming the expected version, what was found, and the `mise install zig` fix. + +### AC#6 still open — needs a push + +Cold/warm cache timings cannot be measured from this unpushed worktree. The job caches `zig-core/.zig-cache` and `zig-core/zig-out` under a key of runner OS + pinned version + `hashFiles(build.zig, build.zig.zon, src/**/*.zig, .tool-versions)`, so the measurement is ready to take: push this branch, run the `zig` job twice (first run cold, second warm with unchanged inputs), and record both durations from the Actions UI here. + diff --git a/taskfile.yml b/taskfile.yml index e6b4a873..3df6de06 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -30,6 +30,8 @@ includes: taskfile: ./taskfiles/deno.yml cargo: taskfile: ./taskfiles/cargo.yml + zig: + taskfile: ./taskfiles/zig.yml tauri: taskfile: ./taskfiles/tauri.yml ci: @@ -51,18 +53,21 @@ tasks: cmds: - task: cargo:lint - task: deno:lint + - task: zig:lint format: desc: "Run formatters" cmds: - task: cargo:format - task: deno:format + - task: zig:format test: - desc: "Run tests (Rust + Frontend)" + desc: "Run tests (Rust + Frontend + Zig)" cmds: - task: cargo:test - task: deno:test + - task: zig:test test:e2e: desc: "Run Playwright E2E tests" diff --git a/taskfiles/zig.yml b/taskfiles/zig.yml new file mode 100644 index 00000000..4139ca3a --- /dev/null +++ b/taskfiles/zig.yml @@ -0,0 +1,107 @@ +version: "3.0" + +set: ['e', 'u', 'pipefail'] +shopt: ['globstar'] + +vars: + ZIG_CORE_DIR: "{{.ROOT_DIR}}/zig-core" + ZIG_OUT_DIR: "{{.ZIG_CORE_DIR}}/zig-out" + ZIG_CACHE_DIR: "{{.ZIG_CORE_DIR}}/.zig-cache" + PINNED_ZIG: + sh: grep '^zig ' {{.ROOT_DIR}}/.tool-versions 2>/dev/null | awk '{print $2}' || true + # Resolved through mise against the pinned version rather than PATH, which may + # hold an IDE-bundled zig or a mise default for a different version. + ZIG: + sh: | + pin="{{.PINNED_ZIG}}" + if [ -n "$pin" ] && command -v mise &> /dev/null; then + mise which zig --tool "zig@$pin" 2>/dev/null || true + fi + # Explicit target triple for the cross-compile task, e.g. TARGET=aarch64-macos + TARGET: '{{.TARGET | default ""}}' + +tasks: + _check-zig-version: + internal: true + desc: "Verify the pinned zig resolves and matches .tool-versions" + cmds: + - | + mise_bin="{{.ZIG}}" + if [ ! -x "$mise_bin" ]; then + if ! command -v mise &> /dev/null; then + echo "error: zig {{.PINNED_ZIG}} not found and mise is not on PATH." >&2 + echo " Expected: {{.PINNED_ZIG}} (pinned in .tool-versions)" >&2 + echo " Fix: install mise (https://mise.jdx.dev), then run: mise install zig" >&2 + exit 1 + fi + echo "error: zig {{.PINNED_ZIG}} (pinned in .tool-versions) is not installed." >&2 + echo " Fix: mise install zig" >&2 + exit 1 + fi + found=$("$mise_bin" version 2>/dev/null) + if [ "$found" != "{{.PINNED_ZIG}}" ]; then + echo "error: zig version mismatch." >&2 + echo " Expected: {{.PINNED_ZIG}} (pinned in .tool-versions)" >&2 + echo " Found: ${found:-unknown} ($mise_bin)" >&2 + echo " Fix: mise install zig@{{.PINNED_ZIG}}" >&2 + exit 1 + fi + echo "zig $found -> $mise_bin" + + format: + desc: "Format Zig source files" + deps: [_check-zig-version] + cmds: + - '{{.ZIG}} fmt {{.ZIG_CORE_DIR}}/' + + lint: + desc: "Check Zig formatting without making changes" + deps: [_check-zig-version] + cmds: + - '{{.ZIG}} fmt --check {{.ZIG_CORE_DIR}}/' + + test: + desc: "Run Zig unit tests" + deps: [_check-zig-version] + cmds: + - cd {{.ZIG_CORE_DIR}} && {{.ZIG}} build test + + build: + desc: "Build zig-core sidecar (debug)" + deps: [_check-zig-version] + cmds: + - cd {{.ZIG_CORE_DIR}} && {{.ZIG}} build + sources: + - "{{.ZIG_CORE_DIR}}/src/**/*.zig" + - "{{.ZIG_CORE_DIR}}/build.zig" + - "{{.ZIG_CORE_DIR}}/build.zig.zon" + generates: + - "{{.ZIG_OUT_DIR}}/bin/mt-zig-core" + + cross-compile: + desc: "Cross-compile zig-core for a Zig target triple (TARGET required)" + summary: | + Cross-compile the zig-core sidecar for an explicit Zig target triple. + + task zig:cross-compile TARGET=aarch64-macos + task zig:cross-compile TARGET=x86_64-macos + task zig:cross-compile TARGET=x86_64-linux + task zig:cross-compile TARGET=x86_64-windows + deps: [_check-zig-version] + preconditions: + - sh: test -n "{{.TARGET}}" + msg: | + TARGET is not set. Pass a Zig target triple, e.g.: + task zig:cross-compile TARGET=aarch64-macos + task zig:cross-compile TARGET=x86_64-macos + task zig:cross-compile TARGET=x86_64-linux + task zig:cross-compile TARGET=x86_64-windows + cmds: + - cd {{.ZIG_CORE_DIR}} && {{.ZIG}} build -Dtarget={{.TARGET}} + + clean: + desc: "Clean Zig build artifacts" + cmds: + - rm -rf {{.ZIG_OUT_DIR}} + - rm -rf {{.ZIG_CACHE_DIR}} + silent: true diff --git a/zig-core/build.zig b/zig-core/build.zig new file mode 100644 index 00000000..dcbc7b76 --- /dev/null +++ b/zig-core/build.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // Sidecar executable. Metadata parsing stays in crates/mt-core until the + // Zig core is vendored in (TASK-355.2). + const exe = b.addExecutable(.{ + .name = "mt-zig-core", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + const run_step = b.step("run", "Run the sidecar"); + run_step.dependOn(&run_cmd.step); + + const exe_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const run_tests = b.addRunArtifact(exe_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_tests.step); +} diff --git a/zig-core/build.zig.zon b/zig-core/build.zig.zon new file mode 100644 index 00000000..09bdef4a --- /dev/null +++ b/zig-core/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .name = .mt_zig_core, + .version = "0.0.0", + .fingerprint = 0x644b78534ae9e63e, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/zig-core/src/main.zig b/zig-core/src/main.zig new file mode 100644 index 00000000..818506af --- /dev/null +++ b/zig-core/src/main.zig @@ -0,0 +1,10 @@ +const std = @import("std"); + +pub fn main() !void { + // Prints to stderr, ignoring potential errors. + std.debug.print("mt-zig-core\n", .{}); +} + +test "sanity" { + try std.testing.expect(1 + 1 == 2); +}