diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    types: [opened, synchronize, reopened, ready_for_review]
+  workflow_dispatch:
+
+# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
+# that lands while main's run is in progress waits for it, so every main state that gets a run is
+# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
+concurrency:
+  group: ci-${{ github.ref }}
+  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions: {}
+
+jobs:
+  prek:
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    permissions:
+      contents: read
+
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
+        with:
+          persist-credentials: false
+          fetch-depth: 1  # latest only
+
+      - name: Run prek pre-commit
+        uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204  # v3.0.1
+        with:
+          extra-args: '--all-files --quiet'
diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,3 @@
-__pycache__/
 *.pyc
+__pycache__/
 dist/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,87 @@
+# install prek package
+#  cargo binstall prek
+# register as git commit-hook to run automatically
+#  prek install
+# run manually
+#  prek run --all-files
+# update hooks to latest version
+#  prek autoupdate
+
+exclude: >-
+  (?x)^(
+    \.vscode/.*
+  )$
+repos:
+  - repo: builtin
+    hooks:
+      - id: check-added-large-files
+        args: ["--maxkb=500"]
+      - id: check-case-conflict
+      - id: check-executables-have-shebangs
+      - id: check-illegal-windows-names
+      - id: check-json
+      - id: check-json5
+      - id: check-merge-conflict
+      - id: check-shebang-scripts-are-executable
+      - id: check-symlinks
+      - id: check-toml
+      - id: check-vcs-permalinks
+      - id: check-xml
+      - id: check-yaml
+      - id: destroyed-symlinks
+      - id: detect-private-key
+      - id: end-of-file-fixer
+      - id: file-contents-sorter
+        files: '^(\.gitignore)$'
+        args: ["--ignore-case"]
+      - id: fix-byte-order-marker
+      - id: mixed-line-ending
+        args: ["--fix=lf"]
+      - id: requirements-txt-fixer
+      - id: trailing-whitespace
+
+  - repo: https://github.com/gitleaks/gitleaks
+    rev: v8.30.1
+    hooks:
+      - id: gitleaks
+
+  - repo: https://github.com/rvben/rumdl-pre-commit
+    rev: v0.3.0
+    hooks:
+      - id: rumdl-fmt
+      - id: rumdl
+        # "--fix"
+        args: ["--disable", "MD013"]  # line-length
+
+  - repo: https://github.com/scop/pre-commit-shfmt
+    rev: v3.14.1-1
+    hooks:
+      - id: shfmt
+        args: ["-w", "-i", "2", "-ci"]
+
+  - repo: https://github.com/shellcheck-py/shellcheck-py
+    rev: v0.11.0.1-1
+    hooks:
+      - id: shellcheck
+
+  # TODO: deactivated until findings are fixed
+  # - repo: https://github.com/owenlamont/ryl-pre-commit
+  #   rev: v0.25.1
+  #   hooks:
+  #     - id: ryl-check
+  #       args: ["--fix", "-d", "{extends: default, rules: {line-length: disable, truthy: disable, document-start: disable}}"]
+  #     - id: ryl-format
+
+  # TODO: deactivated until findings are fixed
+  # - repo: https://github.com/zizmorcore/zizmor-pre-commit
+  #   rev: v1.31.1
+  #   hooks:
+  #     - id: zizmor
+
+  # TODO: deactivated until findings are fixed
+  # - repo: https://github.com/astral-sh/ruff-pre-commit
+  #   rev: v0.17.1
+  #   hooks:
+  #     - id: ruff-format
+  #     - id: ruff-check
+  #       args: [ --fix ]
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # artcraft-common-actions
 
-<a href="https://sonarcloud.io/project/overview?id=storytold_craft-common-actions"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
+<a href="https://sonarcloud.io/project/overview?id=storytold_craft-common-actions"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a> <!-- rumdl-disable-line no-inline-html -->
 
 Reusable GitHub Actions workflows and composite actions for Storyteller / ArtCraft desktop and web applications.
 
@@ -9,7 +9,9 @@ This repository unifies and eliminates duplicate CI/CD and release workflow defi
 ## Workflows Included
 
 ### 1. `release.yml`
+
 Full multi-platform release pipeline for Craft apps:
+
 - **macOS universal** (x86_64 + aarch64 universal bundle, DMG, CLI, Developer ID codesign & notarization)
 - **Windows** (x64, x86, ARM64 MSI installer with WiX v5, CLI, Azure Trusted Signing)
 - **Linux** (x86_64, aarch64 AppImage, deb, rpm, tar.gz via nfpm)
@@ -20,7 +22,8 @@ Full multi-platform release pipeline for Craft apps:
 - **Linux RISC-V 64** (cross-compilation for riscv64gc-unknown-linux-gnu, producing deb, rpm, and tar.gz via SHA256-verified nfpm; built on Ubuntu 24.04, so it sets `GLIBC_FLOOR=2.39` for the app's `nfpm.yaml` to declare, see #44)
 - **Draft Release** (computes `SHA256SUMS.txt` and publishes/updates draft GitHub Release)
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 name: Release
 
@@ -108,7 +111,9 @@ invalid craft-i18n checkout; a missing translation never blocks a build, pin bum
 (storytold/craft-i18n#20). An app without `craft-i18n.rev` builds as before.
 
 ### 2. `packaging-lint.yml`
+
 Lints all packaging configurations and metadata:
+
 - `actionlint` for workflow syntax
 - `shellcheck` for shell scripts and AppRun
 - PowerShell parser validation for WiX helper scripts
@@ -118,7 +123,8 @@ Lints all packaging configurations and metadata:
 - Flatpak manifest consistency checks
 - FreeBSD dry run & embedded icons verification
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 name: Packaging lint
 
@@ -143,9 +149,11 @@ jobs:
 ```
 
 ### 3. `windows-arm64.yml`
+
 Cross-compiles ARM64 MSI on x64 Windows runner, then installs, verifies native ARM64 architecture (`0xAA64`), smoke tests the CLI, and uninstalls on a Windows 11 ARM64 runner.
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 name: Windows ARM64
 
@@ -180,6 +188,7 @@ jobs:
 ```
 
 ### 4. `freebsd.yml`
+
 Builds and runs workspace tests inside a FreeBSD 15 VM on a Linux runner.
 Pull requests default to `x86_64`; pushes, schedules and manual runs retain both
 `x86_64` and `aarch64`. Both boot a KVM-accelerated x86_64 VM; `aarch64` is
@@ -268,7 +277,8 @@ succeed, then requires at least one readable `*-freebsd-<arch>.tar.gz` in
 `dist/release` for each matrix architecture. Each archive is checked separately. The app's package script
 retains responsibility for its required binaries, metadata and licence files.
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 name: FreeBSD
 
@@ -306,7 +316,7 @@ arguments with shell-style quotes and escapes for values containing spaces.
 Arguments are passed literally: shell commands, variable expansion and globbing
 are not evaluated. Malformed quoting fails the check.
 
-#### Caller Example:
+#### Caller Example
 
 ```yaml
 name: Nix
@@ -351,7 +361,8 @@ After the gate, the job fails if `Cargo.lock` changed. Legs run with `fail-fast:
 `timeout-minutes` (default 60). Jobs only some apps need, such as fuzzing or web builds, stay in
 the app's own workflow next to the call.
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 name: CI
 
@@ -418,7 +429,8 @@ Findings show up as annotations on the pull request; a failing scan also lists e
 error-severity finding fails the job. ast-grep is pinned and its download checked against a
 SHA256 kept in the action, since its releases publish none; pass `sha256` with another `version`.
 
-#### Caller Example:
+#### Caller Example
+
 ```yaml
 jobs:
   ast-grep:
@@ -453,6 +465,7 @@ Individual actions are also available for composing custom pipelines:
     with:
       packages: pkg-config libxkbcommon-dev libwayland-dev mesa-vulkan-drivers
   ```
+
 - `actions/install-riscv64-cross`: Installs Ubuntu ports RISC-V multiarch and cross-compilers.
 - `actions/install-trunk`: Installs Trunk for WebAssembly builds.
 - `actions/draft-release`: Collects artifacts, calculates SHA256 checksums, and publishes/updates draft GitHub release.
diff --git a/actions/arch-package/arch-package.sh b/actions/arch-package/arch-package.sh
--- a/actions/arch-package/arch-package.sh
+++ b/actions/arch-package/arch-package.sh
@@ -16,7 +16,10 @@ license="${ARCH_LICENSE:-MIT OR Apache-2.0}"
 pkgrel="${PKGREL:-1}"
 epoch="${SOURCE_DATE_EPOCH:-$(date +%s)}"
 
-die() { echo "error: $*" >&2; exit 1; }
+die() {
+  echo "error: $*" >&2
+  exit 1
+}
 
 [[ -f "$tarball" ]] || die "$tarball not found"
 
@@ -26,7 +29,7 @@ done
 
 case "$target_arch" in
   x86_64) arch=x86_64 ;;
-  aarch64|arm64) arch=aarch64 ;;
+  aarch64 | arm64) arch=aarch64 ;;
   riscv64) arch=riscv64 ;;
   *) die "unsupported architecture: '$target_arch'" ;;
 esac
@@ -156,7 +159,7 @@ license_bad_chars='[|;&`$<>()]'
 [[ ! "$license" =~ (OR|AND)[[:space:]]+(OR|AND) ]] || die "consecutive operators in license expression: '$license'"
 
 license_formatted=()
-IFS='|' read -ra parts <<< "$(sed -E 's/ (OR|AND) /|/g' <<< "$license")"
+IFS='|' read -ra parts <<<"$(sed -E 's/ (OR|AND) /|/g' <<<"$license")"
 for part in "${parts[@]}"; do
   part="$(echo "$part" | xargs)"
   [[ -n "$part" ]] || die "empty license component in '$license'"
@@ -170,7 +173,7 @@ license_expr="$(sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/[[:space:]]+/ /g
 license_str="$(printf '%q' "$license_expr")"
 
 # Create makepkg.conf override
-cat > "$work/makepkg.conf" <<EOF
+cat >"$work/makepkg.conf" <<EOF
 if [[ -f /etc/makepkg.conf ]]; then
   source /etc/makepkg.conf
 fi
@@ -181,7 +184,7 @@ COMPRESSZST=(zstd -c -T0 --ultra -20 -)
 EOF
 
 # Write PKGBUILD
-cat > "$work/PKGBUILD" <<EOF
+cat >"$work/PKGBUILD" <<EOF
 # Maintainer: Storyteller Authors
 pkgname=$(printf '%q' "$pkgname")
 pkgver=$(printf '%q' "$pkgver")
diff --git a/tests/test_arch.py b/tests/test_arch.py
--- a/tests/test_arch.py
+++ b/tests/test_arch.py
@@ -274,4 +274,3 @@ exit 0
 
 if __name__ == "__main__":
     unittest.main()
-
