From 42335bc16d7a3a496e9e76e330e27d54663b73f4 Mon Sep 17 00:00:00 2001 From: Matysh Date: Thu, 13 Aug 2026 14:58:57 +0300 Subject: [PATCH] ci: add the pre-push gate and stop lying about it in the canon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 10.1 promised pre-push as the blocking gate that replaces pull requests. The hook did not exist, so the document promised a check that was not there — worse than saying nothing, because a promise like that gets relied on. Until now process-gate ran only as the catch-up job in CI, which reports after the code is already in dev. The hook skips branch deletions and tags, and for a branch the remote has not seen it measures from the merge-base with dev rather than from the root, or every violation committed before the gate existed would make it impossible to pass. A missing script does not block a push: old checkouts and worktrees have to stay usable. gh is optional on purpose. Reading issue status needs the network, and a hook that cannot work on a train is a hook people switch off; offline it runs what it can and CI does the strict pass. The executable bit is the quiet part. Git skips a hook without +x and says nothing — the gate reports success by being absent. Measured on a real push: mode 644 produces zero lines from the gate and the push goes through, 755 stops it. The API cannot set the bit, so install-hooks restores it on every install. Issue: #121 User-Visible: no --- .githooks/pre-push | 80 +++++++++++++++++++++++++++++++++++++++ PROCESS.md | 35 ++++++++++++----- scripts/install-hooks.mjs | 24 +++++++++++- 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 .githooks/pre-push diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 00000000..0a9a1cf1 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,80 @@ +#!/bin/sh +set -eu + +# PROCESS.md 10.1: the blocking process gate lives here, because commits go +# straight to dev without pull requests and GitHub blocks nothing on its side. +# CI still runs the same script (10.3), but by then the code is already in dev — +# that catch-up pass reports, it does not prevent. +# +# Git feeds one line per ref on stdin: +# + +repo_root=$(git rev-parse --show-toplevel) +gate="$repo_root/scripts/process-gate.mjs" +zero=$(printf '%040d' 0) + +# The gate reasons about commits. A repository without it — an old checkout, a +# bisect, a worktree from before the script existed — must still be pushable. +if [ ! -f "$gate" ]; then + exit 0 +fi + +# Reading issue status needs gh, and a hook that cannot work on a train is a +# hook people disable. Offline the checks that need no network still run, and the +# strict pass happens in CI, where gh is always present. +issues_flag="" +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + issues_flag="--issues" +else + echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2 +fi + +status=0 + +while read -r local_ref local_sha remote_ref remote_sha; do + # Deleting a remote branch pushes nothing to examine. + if [ "$local_sha" = "$zero" ]; then + continue + fi + + # Tags carry no process state of their own: the commit they point at was + # already checked when it was pushed. + case "$local_ref" in + refs/tags/*) continue ;; + esac + + if [ "$remote_sha" = "$zero" ]; then + # A branch that does not exist on the remote yet. Everything it adds on top + # of dev is new, so that is the range — not the whole history, which would + # drag in every violation committed before the gate existed. + base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true) + if [ -z "$base" ]; then + echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2 + base="$local_sha~20" + fi + else + base="$remote_sha" + fi + + echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2 + # shellcheck disable=SC2086 + if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then + status=1 + fi +done + +if [ "$status" -ne 0 ]; then + cat >&2 <<'EOF' + +Push остановлен: нарушен процесс (PROCESS.md §10.2). + +Починить надо причину, а не симптом. Если нарушение уже опубликовано, его +исправляет следующий коммит плюс issue с меткой `process` — не force-push +(§12, правило 17). + +Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт +job `process-gate` в Validate — уже после того, как код окажется в dev. +EOF +fi + +exit "$status" diff --git a/PROCESS.md b/PROCESS.md index d71a0101..de69da47 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -503,13 +503,28 @@ Project v2 остаётся человеческим представление трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`. Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается job `provenance` в `validate.yml`. -- **`pre-push`** — **не реализован.** Задумывался как блокирующий гейт вместо PR; - фактически блокирующей проверки на клиенте нет, и `process-gate.mjs` работает - только догоняющим job в CI (§10.3). Долг известен, отдельная задача. +- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому + пушимому ref и останавливает push при нарушении. Это и есть блокирующий гейт + вместо PR. Удаление ветки и теги пропускаются: в первом случае проверять нечего, + во втором коммит уже проверен, когда его пушили. Для новой ветки диапазон + считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него + попали бы все нарушения, совершённые до появления гейта. -Файл хука обязан быть исполняемым: `assertHookMode` проверяет бит в индексе. -Через GitHub API режим не выставляется — хук, отправленный так, приезжает -`100644`, и проверка его отвергает. Ставить `git update-index --chmod=+x`. + Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает + предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук, + который не работает в самолёте, отключают целиком, а строгий проход всё равно + делает CI. + +**Хук обязан быть исполняемым, и это тише всего ломается.** Git **молча** не +запускает файл без бита `+x`: гейт сообщает об успехе тем, что его нет. Проверено +на настоящем push — при `644` от гейта ноль строк и push проходит, при `755` он +останавливается. + +Через GitHub API режим не выставляется: файл, отправленный так, приезжает +`100644`. Поэтому `scripts/install-hooks.mjs` восстанавливает бит при каждой +установке зависимостей, а `assertHookMode` дополнительно проверяет бит +`.githooks/commit-msg` в индексе. Правится вручную: +`git update-index --chmod=+x .githooks/<хук>`. ### 10.2 Что проверяет `process-gate.mjs` @@ -557,8 +572,9 @@ Validate стартует от этого push и успевает прочит - **`process-gate.mjs` — job `process-gate` в `validate.yml`**, без `needs`: краснеет сам и не роняет остальные. При прямом push проверка догоняющая: код уже - в `dev`, CI краснеет после. Это принятая цена отказа от PR — и, пока `pre-push` - не написан, единственная машинная проверка процесса. + в `dev`, CI краснеет после. Это принятая цена отказа от PR: `pre-push` ловит + нарушение до отправки, а этот job — то, что прошло мимо хука, включая + `--no-verify` и окружение без установленных зависимостей. - **Нарушение не откатывается force-push'ем** (правило 17): исправляющий коммит плюс issue с меткой `process`. Починить надо проверку, а не только симптом. - **Еженедельная гигиена** (workflow): issue в `S1-new` дольше 14 дней и в @@ -673,7 +689,8 @@ Medium-находку, кладёт документ в `docs/reviews/` ветк 8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры коммитов — из свежего клона канон не был виден вообще. -9. ⏳ **`pre-push` не написан** (§10.1). Блокирующей проверки на клиенте нет. +9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте + есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI. --- diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index b23a1f80..88aee659 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -1,8 +1,29 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; -import { existsSync, realpathSync } from 'node:fs'; +import { chmodSync, existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +const HOOKS = ['commit-msg', 'pre-push']; + +// Git skips a hook that is not executable, and says nothing about it. A hook that +// silently does not run is worse than no hook: the gate reports success by being +// absent. The bit cannot be set through the GitHub API either — a file pushed +// that way arrives as 100644 — so it is restored here, on every install. +function makeHooksExecutable(hooksDir) { + if (!existsSync(hooksDir)) return; + for (const name of readdirSync(hooksDir)) { + if (!HOOKS.includes(name)) continue; + const file = join(hooksDir, name); + try { + const mode = statSync(file).mode & 0o777; + if ((mode & 0o111) !== 0o111) chmodSync(file, mode | 0o111); + } catch { + // Windows reports modes it cannot change; git there runs hooks regardless. + } + } +} + const packageRoot = realpathSync(fileURLToPath(new URL('..', import.meta.url))); try { @@ -19,6 +40,7 @@ try { execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { cwd: packageRoot, stdio: 'ignore', }); + makeHooksExecutable(join(packageRoot, '.githooks')); console.log('House Plan: installed repository hooks from .githooks'); } catch { // npm also runs prepare for source archives and dependency installs where