mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: add the pre-push gate and stop lying about it in the canon
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
This commit is contained in:
@@ -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:
|
||||
# <local ref> <local sha> <remote ref> <remote sha>
|
||||
|
||||
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"
|
||||
+26
-9
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user