name: CI # Quality gates for every push to main and every pull request. The Linux gate runs through the # suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the # test variant and the corpus, web and fuzz jobs are PhotoCraft's own. on: push: branches: [main] pull_request: # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it. types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: fuzz: description: "Run the fuzz job" type: boolean default: false fuzz_seconds: description: "Seconds per fuzz target" default: "600" # The token is only used to check out the code: no job writes to the repository. permissions: contents: read env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 # No test or fuzz run may print a page: the engine refuses to spool, and if something ever # bypasses that, lp fails loudly on a printer that does not exist. PHOTOCRAFT_NO_PRINT: "1" LPDEST: photocraft-no-such-printer # A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that # is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci` # gets a group of its own, so it neither runs anything nor cancels a run. concurrency: group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: # Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci` # get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the # app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile. # Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested # only by release.yml. platforms: if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: windows: ${{ steps.pick.outputs.windows }} web: ${{ steps.pick.outputs.web }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Git dependencies come from storytold run: | set -euo pipefail test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } # Source replacement, patches and path overrides in cargo config never show up # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. # Cargo still reads the legacy .cargo/config. python3 - <<'PY' import os, sys, tomllib for path in (".cargo/config", ".cargo/config.toml"): if not os.path.exists(path): continue config = tomllib.load(open(path, "rb")) replaced = [name for name, source in config.get("source", {}).items() if isinstance(source, dict) and "replace-with" in source] if replaced or "patch" in config or "paths" in config: print(f"{path} replaces, patches or overrides a source") sys.exit(1) PY # Fails when Cargo.lock is out of date with the manifests. cargo metadata --locked --format-version 1 > /dev/null python3 - <<'PY' import sys, tomllib lock = tomllib.load(open("Cargo.lock", "rb")) bad = sorted({p["source"] for p in lock.get("package", []) if p.get("source", "").startswith("git+") and not p["source"].startswith("git+https://github.com/storytold/")}) if bad: print("Git dependencies outside github.com/storytold:", *bad, sep="\n") sys.exit(1) PY - id: pick env: EVENT: ${{ github.event_name }} BASE: ${{ github.event.pull_request.base.sha }} FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }} run: | if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "windows=true" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" exit 0 fi # The browser build (a fat-LTO wasm build) runs only when something it compiles or # packages changed: the web app and its packaging, crate sources and manifests (the web # build compiles nearly every crate), the workspace manifests and this workflow. if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then echo "web=true" >> "$GITHUB_OUTPUT" else echo "web=false" >> "$GITHUB_OUTPUT" fi if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then echo "windows=true" >> "$GITHUB_OUTPUT" else echo "windows=false" >> "$GITHUB_OUTPUT" fi # The Linux gate. Its rust-cache entry is the one `test (without heif)` restores. gate: if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # egui/winit/rfd. packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev # The tests build with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run. Same commit as release.yml. craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # Cargo.toml's dev profile already keeps line tables only. dev-debug: "" env: | PHOTOCRAFT_NO_PRINT=1 LPDEST=photocraft-no-such-printer timeout-minutes: 100 # fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with # `--locked --features heif`, then the missing-translations report (xtask/src/main.rs). command: cargo xtask ci # Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job # they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration # tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7 # runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache, # which needs its own action step. windows: needs: platforms if: needs.platforms.outputs.windows == 'true' timeout-minutes: 100 name: test (windows-latest, ${{ matrix.part }}) runs-on: windows-latest strategy: fail-fast: false matrix: part: [unit, integration] env: # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps # line-tables-only, so a backtrace with lines is one OS away). CARGO_PROFILE_DEV_DEBUG: "0" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy # Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's. # A failing test still saves: the cache holds compiled dependencies, not test results. # Both legs share one key (same dependencies); the integration leg saves it, because it also # runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688). # Test executables can't be cached, so a PR still links its tests. - name: Install sccache uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: version: v0.18.0 - name: Use sccache env: MAIN: ${{ github.ref == 'refs/heads/main' }} run: | echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi shell: bash - name: Clippy if: matrix.part == 'integration' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering if: matrix.part == 'integration' run: cargo xtask layers - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: tool: cargo-nextest@0.9.149 # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the # dependency cache. - name: Test env: PART: ${{ matrix.part }} run: | case "$PART" in unit) targets=(--lib --bins) ;; *) targets=(--test '*') ;; esac cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash - name: Doctests if: matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error # for distributors who leave it out. Runs beside the gate and restores the gate's dependency # cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight # (#2688). test-without-heif: if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 60 name: test (without heif) # The gate's runner: its cache entry is keyed on the OS. runs-on: ubuntu-24.04 env: # The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key. CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Linux build dependencies uses: storytold/craft-common-actions/actions/install-apt-packages@main with: packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable # gate.yml keys its cache `-rust--gate`. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: prefix-key: v1-rust shared-key: ${{ hashFiles('Cargo.toml') }}-gate save-if: false - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 with: tool: cargo-nextest@0.9.149 - name: Test without heif run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci corpus: if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache # restore and a download; a missing corpus fails the tests. The cache key changes only when a # pin or manifest does, so normally this restores in seconds (fork PRs read the base cache). name: corpus tests runs-on: ubuntu-latest env: # test-corpus builds with --release, but here without the release profile's thin LTO and # single codegen unit: they ran LTO over the dependency graph for each of the ~95 corpus test # binaries, so building took 23.5 of the job's 37 minutes while the tests ran for 1.5. # Turning them off builds 2.7x faster; the code under test is the same. CARGO_PROFILE_RELEASE_LTO: "false" CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: corpus save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - name: Restore test corpora uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: corpus/ key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }} - name: Fetch and verify corpora run: cargo xtask corpus --all - name: Corpus tests run: cargo xtask test-corpus web: timeout-minutes: 60 name: web build and wasm size gate # PRs run it only when web-relevant files change (see `platforms`); main always does. needs: platforms if: needs.platforms.outputs.web == 'true' runs-on: ubuntu-latest env: # Match release.yml so PR results reflect what would actually ship. TRUNK_VERSION: 0.21.14 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: ci-web save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - name: Install trunk run: | curl --proto '=https' -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ | tar -xz -C "$HOME/.cargo/bin" trunk trunk --version # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into # parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses. - name: Build and verify browser release run: packaging/web/package.sh - name: Report wasm size if: success() run: | { echo "### Browser WebAssembly size" echo echo "| File | Bytes | MiB | Limit |" echo "| --- | ---: | ---: | ---: |" row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; } # The app's wasm as built; it ships gzipped in parts, so only the download grows with it. for wasm in dist/web/*.wasm; do [ -f "$wasm" ] || continue row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none" done # What a host sees: the largest file of the packaged site. largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1) row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB" } >> "$GITHUB_STEP_SUMMARY" fuzz: timeout-minutes: 60 name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) # Placeholder: manual trigger only until a nightly schedule is enabled. if: github.event_name == 'workflow_dispatch' && inputs.fuzz runs-on: ubuntu-latest strategy: fail-fast: false matrix: target: - { crate: psd, name: parse } - { crate: psd, name: descriptor } - { crate: psd, name: image_source_data } - { crate: psd, name: grd } - { crate: psd, name: ase } - { crate: psd, name: aco } - { crate: psd, name: patterns } - { crate: psd, name: filter_effects } - { crate: psd, name: metadata } - { crate: psd, name: tagged } - { crate: psd, name: resources } - { crate: psd, name: asl } - { crate: codecs, name: decode_any } - { crate: codecs, name: decode_png } - { crate: codecs, name: decode_jpeg } - { crate: codecs, name: decode_tiff } - { crate: codecs, name: decode_exr } - { crate: codecs, name: decode_heif } - { crate: affinity, name: preview } - { crate: affinity, name: import_preview, args: "--features io" } - { crate: format, name: load } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: crates/${{ matrix.target.crate }}/fuzz - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked - name: Fuzz working-directory: crates/${{ matrix.target.crate }} env: FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS" - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} path: crates/${{ matrix.target.crate }}/fuzz/artifacts