diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..acc7064 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +# Quality gate. +# +# This workflow owns no rules of its own: it installs the tools the hooks +# need, then hands over to `prek run --all-files`, which reads prek.toml — +# the same file `prek install` wires into the local pre-commit hook. Adding +# or tightening a check is a one-line edit to prek.toml and takes effect in +# both places, so the local gate and CI cannot drift. +# +# Supply chain: no third-party action runs here. `j178/prek-action` would do +# the job in one line, but it executes 2.4 MB of bundled JavaScript from a +# `main:` *and* a `post:` entry point, behind a mutable tag. Fetching the +# upstream release tarball and checking its published SHA-256 gives the same +# result with a reviewable surface. actions/checkout is pinned by commit +# SHA rather than by tag for the same reason: tags can be repointed. +# +# Every hook is `language = "system"`, meaning prek shells out to binaries +# rather than building an environment. The runner image supplies bash, dash +# and shellcheck; everything else is installed below at a pinned version, so +# an upstream release cannot silently change what this gate enforces. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gate: + name: prek gate + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + PREK_VERSION: 0.4.10 + PREK_SHA256: d31168fcbfe9921ad63572dc7ddbee07be5d5d6befe11e6e7397e3861a4edd49 + GITLEAKS_VERSION: 8.30.1 + ACTIONLINT_VERSION: 1.7.12 + steps: + # actions/checkout v6, pinned to its commit. + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + # zsh and ksh are absent from the runner image. Without them the + # shell-syntax hook would quietly shrink to the three shells that + # happen to be preinstalled — exactly the coverage gap it exists to + # close, and it would still report success. + - name: Install shells + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends zsh ksh + for s in sh bash dash zsh ksh; do + printf '%-5s %s\n' "$s" "$(command -v "$s")" + done + + - name: Install prek + run: | + set -euo pipefail + target=prek-x86_64-unknown-linux-musl + curl -fsSL --retry 3 -o prek.tar.gz \ + "https://github.com/j178/prek/releases/download/v${PREK_VERSION}/${target}.tar.gz" + echo "${PREK_SHA256} prek.tar.gz" | sha256sum -c - + # prek nests its binary under the target-triple directory, unlike + # gitleaks and actionlint which ship theirs at the archive root. + sudo tar -xzf prek.tar.gz -C /usr/local/bin --strip-components=1 "${target}/prek" + rm -f prek.tar.gz + prek --version + + - name: Install hook tools + run: | + set -euo pipefail + curl -fsSL --retry 3 "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + curl -fsSL --retry 3 "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin actionlint + gitleaks version + actionlint --version + shellcheck --version + + # --show-diff-on-failure so a hook that rewrites a file + # (end-of-file-fixer, mixed-line-ending) reports the exact diff rather + # than an opaque failure. + - name: Run prek + run: prek run --all-files --show-diff-on-failure --color=always diff --git a/README.md b/README.md index 2070b00..bba15fa 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ Toutes optionnelles, à exporter avant le `source`. | `GIT_THEME_BASE_PALETTE` | `dracula` | Palette du profil iTerm2 de base, retirée de l'auto-assignation | | `GIT_THEME_ITERM2_PARENT_PROFILE` | `git-theme:base` | Profil iTerm2 dont héritent les profils générés | +## Développement + +Le contrôle qualité est décrit une seule fois, dans `prek.toml` : + +```bash +prek install # branche le hook git local +prek run --all-files +``` + +La CI ne redéfinit aucune règle : elle installe les outils puis lance ce même `prek run --all-files`. Un pre-commit vert vaut donc une CI verte, et resserrer une règle se fait à un seul endroit. + +Ce que le gate vérifie : espaces traînants, newline finale, fins de ligne, YAML et TOML valides, marqueurs de conflit, `shellcheck`, `actionlint`, détection de secrets (`gitleaks`), et surtout le parsing du script sous les **cinq shells** annoncés en tête de fichier — le script étant *sourcé*, une erreur de syntaxe casse le shell de login, pas une commande. + ## Comment ça marche 1. Vous entrez dans un repo git diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..ad51f06 --- /dev/null +++ b/prek.toml @@ -0,0 +1,86 @@ +# Local quality gate. +# +# `.github/workflows/ci.yml` runs this exact file through `prek run +# --all-files`, so there is one hook list rather than two that drift apart: +# a green pre-commit is a green CI, and tightening a rule here tightens it +# in both places at once. +# +# Every hook is `language = "system"` — no environment is provisioned, the +# tools come from the developer's machine and, in CI, from an explicit +# install step. `prek install` wires the git hook locally. + +fail_fast = false +default_install_hook_types = ["pre-commit"] + +# Rust-native hooks: no network, no interpreter, no environment to build. +[[repos]] +repo = "builtin" +hooks = [ + { id = "trailing-whitespace" }, + { id = "end-of-file-fixer" }, + { id = "check-yaml" }, + { id = "check-toml" }, + { id = "check-merge-conflict" }, + { id = "mixed-line-ending", args = ["--fix", "lf"] }, +] + +[[repos]] +repo = "local" + +# ShellCheck. The shebang is `#!/bin/sh`, so the default ruleset is POSIX. +# +# SC3043 ("In POSIX sh, 'local' is undefined") fires on every helper and is +# accepted deliberately: `local` is implemented by all five shells this +# project targets, and dropping it would leak every helper variable into the +# user's interactive shell — a worse outcome than the standards deviation. +# +# SC3045 ("type -t is undefined") is a false positive in context: its only +# call site sits inside an `elif [ -n "${BASH_VERSION-}" ]` branch, so it +# never executes outside bash, where `type -t` is well defined. +# +# What actually backs the portability claim is the shell-syntax hook below, +# which parses the script under each advertised shell rather than reasoning +# about the standard. +[[repos.hooks]] +id = "shellcheck" +name = "shellcheck" +language = "system" +entry = "shellcheck --severity=warning --exclude=SC3043,SC3045" +types = ["shell"] + +# The script is *sourced*, not executed: a parse error takes down the user's +# login shell instead of failing one command. Parsing it under every shell +# named in the file header is the cheapest gate that catches a syntax +# regression before it reaches a shell that cannot start. +# +# A shell that is absent is skipped rather than failing the hook, so the gate +# stays usable on a machine without ksh; CI installs all five, so nothing is +# silently skipped where it counts. +[[repos.hooks]] +id = "shell-syntax" +name = "shell syntax (sh, bash, dash, zsh, ksh)" +language = "system" +entry = "sh" +args = [ + "-c", + 'status=0; for f in "$@"; do for s in sh bash dash zsh ksh; do command -v "$s" >/dev/null 2>&1 || continue; "$s" -n "$f" || { echo " ^ $s cannot parse $f"; status=1; }; done; done; exit "$status"', + "--", +] +types = ["shell"] + +# Scans the working tree rather than git history, so the hook behaves +# identically on staged changes and on a full CI checkout. +[[repos.hooks]] +id = "gitleaks" +name = "gitleaks (secret detection)" +language = "system" +entry = "gitleaks dir . --no-banner --redact" +pass_filenames = false + +[[repos.hooks]] +id = "actionlint" +name = "actionlint" +language = "system" +entry = "actionlint" +files = '^\.github/workflows/.*\.ya?ml$' +pass_filenames = false