# SPDX-License-Identifier: FSL-1.1-Apache-2.0 # # Local Git hooks managed by lefthook (https://lefthook.dev). # Install with `lefthook install` after `nix run nixpkgs#lefthook --` # or `bun add -D lefthook && bun lefthook install`. # # `pre-commit` uses the `jobs` form (lefthook >= 1.10) rather than # `commands`, because only a job can carry a `group` — and the # docsgen/docs-check pair needs one. See the comment on that group. # # GLOB SHAPE, once, because two globs here got it wrong: in lefthook's # matcher `**` does NOT match zero directories when a separator # follows it, so `docs/**/*.md` silently skips `docs/index.md` and # `{cue.mod/**}/*.cue` silently skips `cue.mod/module.cue`. Any glob # that must cover a tree AND its top level spells both out — # `dir/{*.ext,**/*.ext}`. A TRAILING `/**` (no separator after) is # fine and does match direct children, which is why the `docsgen` and # `docs/hooks/**` globs need no such treatment. pre-commit: parallel: true jobs: - name: cue glob: "{cue.mod,schemas,examples}/{*.cue,**/*.cue}" run: ./scripts/lint-cue.sh - name: spdx run: ./scripts/check-spdx-headers.sh - name: cyrillic run: ./scripts/check-no-cyrillic.sh - name: crd-check # `operator/crdgen/**` is in the glob for the same reason the # docsgen job's glob covers `cli/docsgen/**`: the GENERATOR also # changes what's committed, not just its CUE/Rust input — a # crdgen-only edit (structural.rs, cue.rs, envelope.rs) can # change the rendered CRD YAML with no schema or operator-core # change at all. `.github/workflows/crd-check.yml` already lists # it in `paths:`; this glob was the local half missing it. glob: "{schemas/v1alpha1/*.cue,operator/operator-core/src/*.rs,operator/crdgen/**,operator/charts/apprafter-operator/templates/crd-*.yaml}" run: ./scripts/crd-check.sh # These two are ORDERED, and the rest of the hook is not. # # `docsgen` WRITES docs/reference/cli/; `docs-check` READS it (the # strict mkdocs build, then the byte-compare). A commit touching # both `cli/platform-cli/src/**` and a documentation page matches # both globs — and that is the prescribed shape for a CLI change, which # regenerates the reference in the same commit — so under the # hook's `parallel: true` they would race: docs-check could read a # half-written tree, or the pre-regeneration one, and fail or pass # for the wrong reason. # # A `piped` group is lefthook's sequential construct: its jobs run # in listed order and the group stops at the first failure (so a # docsgen failure never reports itself as reference drift). The # group as a whole still runs in parallel with cue/spdx/cyrillic/ # crd-check, which share no files with it. A job whose glob does # not match is skipped without failing the group, so a docs-only # commit still runs docs-check and a cli-only commit still runs # docsgen. # # `priority` is NOT the mechanism here: it only orders jobs when # the hook is sequential (`parallel: false` or `piped: true`), and # this hook is parallel by design. - name: docs group: piped: true jobs: # The CLI reference is generated from the clap tree, so a # touched cli.rs (or anything else under platform-cli) is a # docs change too. Regenerating and staging it here means a # contributor never sees "the docs drifted" for a file they # were never asked to edit — the gate in docs-check then # only fires for a hand-edited generated page. # `cli/docsgen/**` is in the glob for the same reason from # the other side: the GENERATOR also changes the committed # artefacts, not just its input. Editing `model.rs` alone # (no clap change at all) can add or drop a projected node # and change `commands.json` — proven by the root-binary # node this hook's own glob was missing. # `cli/Cargo.toml` is in the glob because commands.json # embeds `cli_version` — a release bump is a reference # change. # The nix guard mirrors scripts/docs-check.sh: `just # docsgen-generate` runs under `nix develop` for the pinned # toolchain a byte-compare needs, and without nix a raw # "command not found" says nothing about what to do next. - name: docsgen glob: "{cli/platform-cli/src/**,cli/docsgen/**,cli/Cargo.toml}" run: | command -v nix >/dev/null 2>&1 || { echo "ERROR: regenerating the CLI reference needs nix — docsgen runs" >&2 echo "under 'nix develop' so local and CI render with one pinned" >&2 echo "toolchain. Install nix (flakes enabled) — see" >&2 echo "docs/contributing/setup.md — or skip the local hook with" >&2 echo "'git commit --no-verify'; the docs workflow still runs on the" >&2 echo "pull request." >&2 exit 2 } just docsgen-generate && git add docs/reference/cli # Wider than the pages themselves, because `docs-check` runs # the 2.19c drift gate and the gate resolves what the docs # CLAIM against what the tree SHIPS. Its two inputs are the # clap tree (`cli/**`, same files as the `docsgen` job above) # and the schemas (`schemas/v1alpha1` plus the CRDs generated # from them). Renaming a field or a flag is therefore a # documentation change even when no page is touched, and a # commit that matched neither glob used to leave the finding # for the next contributor's unrelated docs commit to hit. # Mirrors the `paths:` filter in .github/workflows/docs.yml. # # The corpus is `docs/**` PLUS the root `README.md`, which is # what `scan::in_scope` lists (`git ls-files -- docs # README.md`) and one of the pages the census counts. It is # named here because no `docs/` glob can reach it: without # the entry, deleting or rewriting the README moved the # census and fired nothing on that commit, leaving the # finding to land on the NEXT contributor's unrelated docs # pull request — the failure the wide globs below exist # against. # # `docs/measurements/docs-health.json` is in the glob as a # TRIGGER — editing the committed corpus census must re-run # the gate that compares against it — and it is spelled out # because the `.md` globs beside it do not cover a `.json`. # # It must NEVER be regenerated here, the way the `docsgen` # job above regenerates docs/reference/cli and `git add`s it. # That job is safe only because its TRIGGER (`cli/**`) and its # ARTEFACT (`docs/reference/cli/**`) live in different trees, # so a docs-only commit never rewrites it. This file's trigger # IS `docs/**`: a `docsgen metrics && git add` step here would # rewrite and stage the census on every documentation commit, # so the committed numbers would always equal the current # ones, every deletion would be recorded as intended, and the # ratchet would report success on exactly the change it exists # to catch. Re-recording is a deliberate act — run `docsgen # metrics` by hand, in the same commit, and say why in the # commit message. - name: docs-check glob: "{README.md,docs/{*.md,**/*.md},docs/measurements/docs-health.json,mkdocs.yml,docs/hooks/**,scripts/docs-check.sh,scripts/docs-artefacts-check.py,cli/platform-cli/src/**,cli/docsgen/**,cli/Cargo.toml,schemas/v1alpha1/*.cue,operator/charts/apprafter-operator/templates/crd-*.yaml}" run: ./scripts/docs-check.sh # THE VERSION GATES. # # These lived ONLY in `just lint`, and no workflow invokes `just lint` — # so the one check that catches "operator code changed but appVersion did # not" fired only when somebody remembered to type it. It did not fire for # the commit that shipped the surge-free rollout fix: that change passed # fmt, clippy, the full workspace test suite and the docs gate, and would # still never have become an image, because `release-operator`'s two-axis # gate (tag exists + images exist) SKIPS the rebuild when appVersion has # not moved. Committed, green, and silently not shipped — the class of # miss behind the 0.2.40/0.2.41 double-yank. # # Safe on a pre-commit hook for two MEASURED reasons. Each script is ~2s, # and they are listed FLAT rather than inside a `group:` so the hook's own # `parallel: true` applies to them — measured: the same three scripts take # 6251ms one after another and 1999ms together, and a `group:` carrying # `parallel: true` ran them sequentially anyway on lefthook 2.1.5. A group # here would buy a nicer heading and cost 4 seconds on every commit that # touches platform-stack. Second reason: each asks `git ls-remote` whether # its tag is already PUBLISHED, and with no network that call fails. Outside # CI the scripts then print a warning that the check did NOT run and exit 0 # (scripts/lib/published-tag.sh; they used to say "in flight" instead). An # offline commit therefore LOSES the check rather than breaking on it — and # loses it only until the pull request: since WI-373 the `version-guards` # job in .github/workflows/lint.yml runs the same scripts on every pull # request and push to master, and there an undecided guard FAILS. That job # is also what catches the commits this hook never sees: `--no-verify`, a # clone without `lefthook install`, a web edit, a merge commit. # # `version-coherence` gets no glob narrower than the union of the others: # it cross-checks the platform-stack pins against BOTH chart appVersions # and the cue-cmp version, so a file from any of those trees can break it. - name: operator-version-bump glob: "{operator/**,schemas/v1alpha1/*.cue}" run: ./scripts/check-operator-version-bump.sh - name: cli-version-bump glob: "cli/**" run: ./scripts/check-cli-version-bump.sh - name: backup-runner-pin glob: "{cli/apprafter-backup/**,cli/backup-core/**,cli/Cargo.toml,platform-stack/cue/platform.cue}" run: ./scripts/check-backup-runner-pin.sh - name: cue-cmp-drift glob: "argocd-cue-cmp/**" run: ./scripts/check-argocd-cue-cmp-drift.sh - name: cue-cmp-mirror glob: "{argocd-cue-cmp/plugin.yaml,platform-stack/cue/component_argocd.cue}" run: ./scripts/check-cue-cmp-mirror.sh - name: version-coherence glob: "{operator/charts/**,platform-stack/**,argocd-cue-cmp/**,cli/Cargo.toml}" run: ./scripts/check-version-coherence.sh # The remaining `just lint` script gates. All offline, all a few seconds at # most, all glob-scoped, so they cost nothing on a commit that cannot break # them. # # What is deliberately NOT here: `just lint` also runs `cargo fmt`, # `cargo clippy` and the docsgen property tests across two workspaces. # Those are minutes, not seconds, and a pre-commit hook that costs minutes # gets bypassed with `--no-verify`, which loses the cheap checks too. They # stay in `just lint` and in the lint/test workflows, where a cold build is # expected. - name: heredoc-backticks glob: "{scripts/**,e2e/**,.github/workflows/**,argocd-cue-cmp/**}" run: ./scripts/check-heredoc-backticks.sh # dump_diagnostics is what a failing walk leaves behind, and nothing runs # it until a walk fails. See the header of the script. - name: dump-diagnostics glob: "{e2e/lib.sh,scripts/check-dump-diagnostics.sh,operator/operator-core/src/deadline.rs}" run: ./scripts/check-dump-diagnostics.sh # A workflow that does not parse fails as a run with ZERO JOBS, which # `gh pr checks` does not list — every check can be green while a # release workflow is broken. See the header of the script. - name: workflows glob: ".github/workflows/**" run: ./scripts/check-workflows.sh - name: crd-structural glob: "operator/charts/apprafter-operator/templates/crd-*.yaml" run: ./scripts/check-crd-structural.sh - name: argocd-health-lua glob: "platform-stack/**" run: ./scripts/check-argocd-health-lua.sh - name: component-enablement glob: "platform-stack/**" run: ./scripts/check-component-enablement.sh - name: plan-checkboxes glob: "plan.md" run: ./scripts/check-plan-checkboxes.sh # There is deliberately NO job here for `landing/cms/payload-types.ts`, # the generated file the `payload-types drift` step in # .github/workflows/lint.yml checks. Both shapes were built and their # failures reproduced before being rejected: # # - Regenerate-and-stage, mirroring the `docsgen` job above, breaks under # `git commit -- `, `--only` and `--amend -- `. Those run # hooks against a THROWAWAY index (`.git/next-index-.lock`) that # git discards afterwards, so the `git add` reaches the commit but never # the real index: the artefact stays staged at its pre-hook bytes and # the next plain commit silently reverts it, between two clean commits. # `docsgen` shares this exposure; it is not a reason to add a second. # - Either shape renders from the WORKING TREE while the commit is the # INDEX. lefthook hides unstaged hunks only for PARTIALLY staged files, # so a sibling global that is modified but wholly unstaged is baked into # the artefact anyway — staged into a commit whose sources do not # contain it, or reported as drift the commit does not have. # # CI is where index, tree and commit are one thing, so that is where the # byte-compare runs. `scripts/check-payload-types.sh` carries the long # version of this reasoning. commit-msg: commands: conventional: run: ./scripts/check-commit-msg.sh "{1}"