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:
Matysh
2026-08-13 14:58:57 +03:00
parent a36b3129f6
commit 42335bc16d
3 changed files with 129 additions and 10 deletions
+23 -1
View File
@@ -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