diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af6a443..7ba5876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI +# Every job runs a mise task from mise.toml — the same tasks the git hooks +# (hk.pkl) and developers run locally. Never inline a command here. + on: push: branches: @@ -18,7 +21,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - - run: cargo fmt --check + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} + - run: mise run fmt-check clippy: name: Clippy @@ -28,8 +34,11 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --workspace --all-targets -- -D warnings + - run: mise run lint test: name: Test @@ -38,7 +47,20 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --workspace + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} + - run: mise run test + + tooling: + name: Tooling config + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} + - run: mise run validate-hooks commits: name: Conventional commits @@ -48,8 +70,8 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: cargo-bins/cargo-binstall@main - - name: Install convco - run: cargo binstall -y convco + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} - name: Check commits - run: convco check ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} + run: mise run commits "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" diff --git a/.github/workflows/determinism.yml b/.github/workflows/determinism.yml index 6a87030..ae87a59 100644 --- a/.github/workflows/determinism.yml +++ b/.github/workflows/determinism.yml @@ -1,9 +1,13 @@ name: Determinism -# docs/verification.md: export the same (raw + sidecar) on two architectures and diff -# bytes. Runs the full workspace test suite (golden-file and regression -# tests) on both, then byte-hashes focale-cli renders of the committed -# fixture in every export format and compares across architectures. +# docs/verification.md: export the same (raw + sidecar) on two architectures and +# diff bytes. Runs the full workspace test suite (golden-file and regression +# tests) on both, then byte-hashes focale-cli renders of the committed fixture +# in every export format and compares across architectures. +# +# Both steps are mise tasks from mise.toml; the render/hash step lives in +# scripts/determinism-hashes.sh so it can be reproduced locally with +# `mise run determinism`. on: push: @@ -29,28 +33,15 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - uses: jdx/mise-action@v4 + env: + GITHUB_TOKEN: ${{ github.token }} - name: Workspace tests (golden + regression suites) - run: cargo test --workspace + run: mise run test - name: Render fixture in every format and hash - run: | - set -euo pipefail - RAW=crates/focale-core/tests/fixtures/synthetic.dng - SIDECAR=crates/focale-cli/tests/fixtures/determinism.fcl - OUT=hashes-${{ matrix.arch }}.txt - : > "$OUT" - echo "working $(cargo run -q -p focale-cli -- hash "$RAW" --sidecar "$SIDECAR")" >> "$OUT" - for fmt in tiff16 png16 png8 jpeg jxl avif; do - hash=$(cargo run -q -p focale-cli -- render "$RAW" --sidecar "$SIDECAR" \ - --format "$fmt" --gamut display-p3 --hash --out "/tmp/out.$fmt") - echo "$fmt $hash" >> "$OUT" - done - hash=$(cargo run -q -p focale-cli -- render "$RAW" --sidecar "$SIDECAR" \ - --format avif --gamut rec2020 --hdr pq --hash --out /tmp/out-hdr.avif) - echo "avif-hdr-pq $hash" >> "$OUT" - hash=$(cargo run -q -p focale-cli -- render "$RAW" --sidecar "$SIDECAR" \ - --format jxl --gamut rec2020 --hdr hlg --hash --out /tmp/out-hdr.jxl) - echo "jxl-hdr-hlg $hash" >> "$OUT" - cat "$OUT" + run: mise run determinism + env: + HASHES_OUT: hashes-${{ matrix.arch }}.txt - uses: actions/upload-artifact@v4 with: name: hashes-${{ matrix.arch }} @@ -65,6 +56,9 @@ jobs: with: pattern: hashes-* merge-multiple: true + # The one workflow step that does not go through a mise task: this job + # deliberately has no checkout (it only needs the two artifacts), so there + # is no mise.toml here to run a task from. - name: Diff run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index e9921fd..30f8a80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ **/*.rs.bk *.pdb +# === Tooling === +# mise's local, uncommitted config override. +mise.local.toml +.mise.local.toml +# Default output of `mise run determinism`. +/hashes*.txt + # === Linux === *~ .fuse_hidden* diff --git a/AGENTS.md b/AGENTS.md index 0b10753..b25b133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,14 +26,21 @@ changing processing code. ## Quality -Validate changes with `just check` (exactly what CI runs), or individually: +Validate changes with `mise run check` — the four gate jobs CI runs on every push. +CI additionally validates the commit range on pull requests (`mise run commits`) and +runs the two-architecture Determinism workflow. Individually: ```bash -cargo test --workspace # correctness -cargo fmt --check # formatting -cargo clippy --workspace --all-targets -- -D warnings # lint +mise run test # correctness (cargo test --workspace) +mise run fmt-check # formatting (cargo fmt --check) +mise run lint # lint (cargo clippy --workspace --all-targets -- -D warnings) +mise run validate-hooks # hk.pkl parses (hk validate) ``` +`mise.toml` is the single source of truth for these commands: the git hooks +(`hk.pkl`) and every GitHub Actions job invoke the same tasks. Never inline a +command in a hook or a workflow — add or change the task instead. + - All `pub` items need doc comments where non-obvious; processing code documents its algorithm source (paper/reference implementation). - Floating-point code on the export path must not depend on evaluation order that the @@ -42,8 +49,9 @@ cargo clippy --workspace --all-targets -- -D warnings # lint ## Commits Commits MUST follow [Conventional Commits](https://www.conventionalcommits.org/) -(`feat:`, `fix:`, `chore:`, …) — enforced by `convco` at commit-msg, pre-push, and in -CI on pull requests. Merge commits are exempt. +(`feat:`, `fix:`, `chore:`, …) — enforced by `convco` (via `mise run commit-msg` and +`mise run commits`) at commit-msg, pre-push, and in CI on pull requests. Merge commits +are exempt. ## Releases diff --git a/README.md b/README.md index e5fd604..2ad3b73 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ A: If you have another tool you need to further process your images, your best b ### Getting Started ```bash -just run # launch the desktop app (Wayland/X11) +mise run run # launch the desktop app (Wayland/X11) scripts/fetch-models.sh # optional: download the AI segmentation models cargo run -p focale-cli -- render photo.ARW --format tiff16 # headless export ``` @@ -94,33 +94,62 @@ bit-identically on any machine, forever. See the docs index at `docs/README.md` ### Prerequisites - [Rust (rustup)](https://rustup.rs) — toolchain (pinned via `rust-toolchain.toml`) -- [just](https://github.com/casey/just) — command runner -- [Lefthook](https://github.com/evilmartians/lefthook) — git hooks manager (`lefthook install` after cloning) -- [convco](https://github.com/convco/convco) — conventional-commit checker used by hooks -- cmake + a C++ toolchain — builds the vendored libjxl for JPEG XL export +- [mise](https://mise.jdx.dev) — task runner; it also pins and installs `hk` and `convco` +- cmake + a C++ toolchain — builds the vendored libjxl for JPEG XL export (not managed by mise) + +After cloning: + +```bash +mise install # fetch the pinned tools (hk, convco) +mise run hooks # install the git hooks +``` + +`mise.toml` pins [hk](https://hk.jdx.dev) (git hooks) and +[convco](https://github.com/convco/convco) (conventional-commit checker), so neither +needs installing by hand. `mise run hooks` installs the hooks into this clone only, +and they run through `mise x` — so whatever launches git must have `mise` on its +`PATH`. Committing from a GUI client with a trimmed environment needs mise's shim +directory added to that client's `PATH`. ## Commands -| Command | Description | -| ------------ | -------------------------------------------- | -| `just check` | Run everything CI runs (format, lint, tests) | -| `just test` | Run the test suite | -| `just fmt` | Format code | -| `just lint` | Clippy with warnings denied | -| `just run` | Launch the desktop app | +`mise.toml` is the single source of truth for every command: the git hooks and +every CI job invoke these same tasks. `mise tasks ls` lists them all. + +| Command | Description | +| --------------------- | -------------------------------------------- | +| `mise run check` | Run every gate CI runs (format, lint, tests, hk.pkl) | +| `mise run test` | Run the test suite | +| `mise run fmt` | Format code | +| `mise run lint` | Clippy with warnings denied | +| `mise run run` | Launch the desktop app | +| `mise run commits` | Validate conventional commits in a range | +| `mise run determinism`| Render the determinism fixture and hash it | ## Git Hooks -This project uses Lefthook. Pre-commit auto-formats staged Rust files; commit-msg -validates the message is a conventional commit; pre-push runs the full CI check suite -(format, clippy, tests, commit-range check) so pushes never fail CI. +This project uses [hk](https://hk.jdx.dev); `mise run hooks` installs them. +`hk.pkl` decides only *when* each task runs — the commands themselves come from +`mise.toml`. + +- **pre-commit** formats staged Rust files. hk stashes unstaged work first, so the + hook sees the staged content and only the files you staged get formatted and + re-staged — work you deliberately left out of the commit is preserved untouched. +- **commit-msg** validates the message is a conventional commit; merge and rebase + commits are exempt. +- **pre-push** runs the full CI check suite (format, clippy, tests, commit-range + check, hk.pkl validation) on every push, so pushes should not fail CI. The + Determinism workflow is the one exception — it is a two-architecture comparison + CI alone can make. ## CI/CD -GitHub Actions runs format checks, clippy, tests on pushes to `master` and pull -requests, plus conventional-commit validation on pull requests. A separate -Determinism workflow renders the committed (raw + sidecar) fixture on x86_64 and -aarch64 in every export format and fails if any byte differs (`docs/verification.md`). +GitHub Actions runs format checks, clippy, tests and an `hk.pkl` validation on pushes +to `master` and pull requests, plus conventional-commit validation on pull requests. +Every job invokes the same `mise run ` a developer runs locally, so CI and the +git hooks cannot drift apart. A separate Determinism workflow renders the committed +(raw + sidecar) fixture on x86_64 and aarch64 in every export format and fails if any +byte differs (`docs/verification.md`). ## Releases & Changelog diff --git a/docs/verification.md b/docs/verification.md index 9cbad90..3ed2158 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -26,6 +26,9 @@ check; write them before adding the next dependency, not after. - CI job matrix: `ubuntu-24.04` (x86_64) and `ubuntu-24.04-arm` (aarch64) render the committed fixture set and compare SHA-256 of output bytes; any divergence fails. +- The render/hash step is `scripts/determinism-hashes.sh`, driven by the + `mise run determinism` task, so CI runs exactly what a developer can run + locally on one architecture. - Golden-file suites: (a) sidecar bytes for a canonical edit state, (b) frozen sidecars + frozen output hashes per pipeline version (regression), (c) colour transform vectors per gamut/format. diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..e011860 --- /dev/null +++ b/hk.pkl @@ -0,0 +1,62 @@ +/// Git hooks for Focale. +/// +/// Every command lives in `mise.toml` — this file only decides when each task +/// runs and against which files. Install the hooks with `mise run hooks`. +/// +/// Keep the version below in sync with `hk` in `mise.toml`. +amends "package://github.com/jdx/hk/releases/download/v2.0.1/hk@2.0.1#/Config.pkl" + +min_hk_version = "2.0.1" +default_branch = "master" + +hooks { + ["pre-commit"] { + fix = true + // Run against the staged content, not the dirty worktree: hk saves unstaged + // work, formats, stages only the files it selected, then restores the rest. + stash = "git" + steps { + ["fmt"] { + glob = "**/*.rs" + fix = "mise run fmt" + } + } + } + + ["commit-msg"] { + steps { + // convco rather than hk's check_conventional_commit builtin: hk can only + // validate a single message, and pre-push and CI need a *range* checker, + // so convco is the one implementation of the rule. Merge and rebase + // commits are exempt; the task handles that. + ["conventional-commit"] { + check = "mise run commit-msg '{{commit_msg_file}}'" + } + } + } + + ["pre-push"] { + steps { + // All three are deliberately unglobbed. Each runs a workspace-wide cargo + // command that ignores hk's file selection, so a glob would only gate + // whether the step runs at all — and a push touching just Cargo.toml, + // rust-toolchain.toml or rustfmt.toml would then skip a gate CI still + // enforces. Matches the lefthook config this replaces. + ["fmt-check"] { + check = "mise run fmt-check" + } + ["lint"] { + check = "mise run lint" + } + ["test"] { + check = "mise run test" + } + ["validate-hooks"] { + check = "mise run validate-hooks" + } + ["commits"] { + check = "mise run commits" + } + } + } +} diff --git a/justfile b/justfile deleted file mode 100644 index c7e336d..0000000 --- a/justfile +++ /dev/null @@ -1,24 +0,0 @@ -# Task runner for Focale. `just check` mirrors CI exactly. - -default: check - -# Everything CI runs, in the same order. -check: fmt-check lint test - -fmt: - cargo fmt - -fmt-check: - cargo fmt --check - -lint: - cargo clippy --workspace --all-targets -- -D warnings - -test: - cargo test --workspace - -build: - cargo build --workspace - -run *ARGS: - cargo run -p focale-app -- {{ARGS}} diff --git a/lefthook.yml b/lefthook.yml deleted file mode 100644 index ed59ea6..0000000 --- a/lefthook.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Git hooks for Focale. pre-push replicates CI exactly (see justfile `check`). - -commit-msg: - commands: - conventional-commit: - # Merge commits are exempt (see CLAUDE.md); skip while git is mid-merge/rebase. - skip: - - merge - - rebase - run: convco check --from-stdin < {1} - -pre-commit: - commands: - format-fix: - glob: "**/*.rs" - stage_fixed: true - run: cargo fmt - -pre-push: - commands: - format-check: - run: cargo fmt --check - lint-check: - run: cargo clippy --workspace --all-targets -- -D warnings - test: - run: cargo test --workspace - conventional-commits: - run: convco check origin/master..HEAD diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..e21c6db --- /dev/null +++ b/mise.toml @@ -0,0 +1,72 @@ +# Single source of truth for Focale's commands. +# +# The git hooks (hk.pkl) and every GitHub Actions job invoke these tasks; no +# command string is defined anywhere else. Run `mise tasks ls` to list them. + +[tools] +# Keep the version in sync with the `amends` URL at the top of hk.pkl. +hk = "2.0.1" +cargo-binstall = "1.23.0" +"cargo:convco" = "0.7.2" + +[tasks.fmt] +description = "Format all Rust sources" +run = "cargo fmt" + +[tasks.fmt-check] +description = "Verify formatting" +run = "cargo fmt --check" + +[tasks.lint] +description = "Clippy over the workspace, warnings denied" +run = "cargo clippy --workspace --all-targets -- -D warnings" + +[tasks.test] +description = "Workspace test suite" +run = "cargo test --workspace" + +[tasks.build] +description = "Build the workspace" +run = "cargo build --workspace" + +[tasks.run] +description = "Launch the desktop app (Wayland/X11)" +run = "cargo run -p focale-app --" + +[tasks.check] +description = "Everything CI runs" +run = [ + "mise run fmt-check", + "mise run lint", + "mise run test", + "mise run validate-hooks", +] + +[tasks.commits] +description = "Validate conventional commits over a range" +usage = 'arg "[range]" help="Commit range to check" default="origin/master..HEAD"' +run = 'convco check "$usage_range"' + +[tasks.commit-msg] +description = "Validate a single commit message file" +usage = 'arg "" help="Path to the commit message file"' +run = ''' +# Merge and rebase commits are exempt (AGENTS.md); git is mid-operation then. +if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then exit 0; fi +if [ -d "$(git rev-parse --git-path rebase-merge)" ] \ + || [ -d "$(git rev-parse --git-path rebase-apply)" ]; then exit 0; fi +convco check --from-stdin < "$usage_file" +''' + +[tasks.determinism] +description = "Render the determinism fixture in every export format and hash it" +quiet = true +run = "./scripts/determinism-hashes.sh" + +[tasks.validate-hooks] +description = "Check hk.pkl parses (it does not resolve task names)" +run = "hk validate" + +[tasks.hooks] +description = "Install the hk git hooks in this clone" +run = "hk install --mise" diff --git a/scripts/determinism-hashes.sh b/scripts/determinism-hashes.sh new file mode 100755 index 0000000..f102384 --- /dev/null +++ b/scripts/determinism-hashes.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Render the committed determinism fixture in every export format and record +# "