mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
405 lines
18 KiB
YAML
405 lines
18 KiB
YAML
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: abb83316d96aa59c1cf64784289e378fe9fa5695
|
|
# 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 `<cache-version>-rust-<hash of Cargo.toml>-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
|