Prechádzať zdrojové kódy

ci(hooks): pre-commit + CI gate for dist/ in sync with src/ (i-9l2ueyyz)

Process hardening after the i-qkarfffa Stage-3 catch-up bug, where
dist/embedding/* was compiled by the original session but never
committed; only caught two days later when an unrelated session
rebuilt dist/ and noticed.

Changes:
* scripts/pre-commit  -- new. Detects staged src/*.ts; runs the
  package build script; aborts the commit if dist/ drifts (does
  NOT auto-stage -- silent commit-scope expansion was rejected in
  DoD #4). tsc errors also block. Skips the rebuild for docs-only
  commits. Bypass via standard `git commit --no-verify`.
* scripts/install-hooks.sh  -- now installs both pre-commit and
  pre-push (loop). package.json `prepare` script auto-runs this
  during install so contributors get the hook for free.
* .github/workflows/ci.yml  -- new dist-sync job installs deps,
  rebuilds dist/, then `git diff --exit-code -- dist/`. Fails the
  build with an actionable ::error:: line on drift.
* CLAUDE.md  -- new "Dist/ commit hygiene" section explains the
  workflow + the two safety nets, with the i-9l2ueyyz reference.

Local hook tested in a scratch repo with 6 scenarios:
  A drift fails loudly         B in-sync passes
  C docs-only fast-paths       D idempotent
  E --no-verify bypasses       F build-failure blocks

Session-Id: a8d83ec3
root 3 týždňov pred
rodič
commit
29e11bdc9b
4 zmenil súbory, kde vykonal 183 pridanie a 5 odobranie
  1. 33 0
      .github/workflows/ci.yml
  2. 31 0
      CLAUDE.md
  3. 12 5
      scripts/install-hooks.sh
  4. 107 0
      scripts/pre-commit

+ 33 - 0
.github/workflows/ci.yml

@@ -70,3 +70,36 @@ jobs:
           CI: true
           DYLD_LIBRARY_PATH: /opt/homebrew/opt/sqlite/lib
           LD_LIBRARY_PATH: /usr/lib/x86_64-linux-gnu
+
+  dist-sync:
+    # Verify that committed dist/ matches what `npm run build` produces from
+    # the committed src/. Drift here would crash consumers that import from
+    # @oivo/qmd's pre-built dist/ (e.g. fleet oc.tgz bundles ship dist/
+    # AS-IS without rebuilding). See i-9l2ueyyz / i-qkarfffa retrospective.
+    name: dist/ in sync with src/
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "22"
+
+      - name: Install SQLite
+        run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
+
+      - run: npm install
+
+      - name: Build dist/
+        run: npm run build
+
+      - name: Verify dist/ is committed and in sync
+        run: |
+          if ! git diff --exit-code -- dist/; then
+            echo ""
+            echo "::error::dist/ drifted after npm run build -- commit the rebuilt dist/ alongside src/."
+            echo "::error::Reproduce locally: cd vendor/qmd && npm install && npm run build && git diff dist/"
+            echo "::error::Then: git add dist/ && git commit --amend --no-edit (or new commit) and push."
+            exit 1
+          fi
+          echo "dist/ is in sync with src/ ✓"

+ 31 - 0
CLAUDE.md

@@ -153,6 +153,37 @@ bun test --preload ./src/test-preload.ts test/
 - The `qmd` file is a shell script that runs compiled JS from `dist/` - do not replace it
 - `npm run build` compiles TypeScript to `dist/` via `tsc -p tsconfig.build.json`
 
+## Dist/ commit hygiene (MANDATORY when editing src/)
+
+`dist/` is **tracked in git** because Oivo's fleet bundler (`oc.tgz`) and the
+satellite deploy script (`pull_and_build_and_deploy.sh`) consume the cloned
+repo's `dist/` AS-IS — neither rebuilds before shipping. So any commit that
+edits `src/*.ts` MUST also commit the matching `dist/` delta, or the next
+fleet deploy will crash with `MODULE_NOT_FOUND` on every consumer machine.
+
+**Workflow when editing `src/*.ts`:**
+
+```sh
+# 1. Edit src/*.ts
+# 2. Rebuild dist/
+npm run build
+# 3. Stage BOTH src/ and the new dist/ in the same commit
+git add src/<changed-files> dist/
+git commit -m "..."
+```
+
+The repo enforces this with two safety nets (both shipped via i-9l2ueyyz):
+
+1. **Pre-commit hook** (`scripts/pre-commit`, auto-installed by `npm install`)
+   detects staged `src/*.ts`, runs `npm run build`, and refuses the commit if
+   `dist/` drifts. Bypass with `git commit --no-verify` only if you know what
+   you're doing (it ships broken JS to fleet).
+2. **CI dist-sync job** (`.github/workflows/ci.yml`) re-runs the same check on
+   every push and PR. A green CI is your second guarantee.
+
+**Reference:** post-mortem of i-qkarfffa Stage-3 — `dist/embedding/*` was
+compiled but never committed, only caught two days later by accident.
+
 ## Releasing
 
 Use `/release <version>` to cut a release. Full changelog standards,

+ 12 - 5
scripts/install-hooks.sh

@@ -2,7 +2,7 @@
 set -euo pipefail
 
 # Self-installing git hooks for qmd
-# Called from package.json "prepare" script after bun install
+# Called from package.json "prepare" script after bun install / npm install
 
 REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
 HOOKS_DIR="$REPO_ROOT/.git/hooks"
@@ -12,8 +12,15 @@ if [[ ! -d "$HOOKS_DIR" ]]; then
   exit 0
 fi
 
-# Install pre-push hook
-cp "$REPO_ROOT/scripts/pre-push" "$HOOKS_DIR/pre-push"
-chmod +x "$HOOKS_DIR/pre-push"
+INSTALLED=()
 
-echo "Installed git hooks: pre-push"
+for hook in pre-commit pre-push; do
+  src="$REPO_ROOT/scripts/$hook"
+  if [[ -f "$src" ]]; then
+    cp "$src" "$HOOKS_DIR/$hook"
+    chmod +x "$HOOKS_DIR/$hook"
+    INSTALLED+=("$hook")
+  fi
+done
+
+echo "Installed git hooks: ${INSTALLED[*]}"

+ 107 - 0
scripts/pre-commit

@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Pre-commit hook: keeps dist/ in sync with src/.
+#
+# Why: vendor/qmd/dist/ is tracked in git. Consumer machines fetch the
+# repo and use dist/ AS-IS without rebuilding (cli/build.sh and the fleet
+# pull_and_build_and_deploy.sh both consume the pre-built JS). A commit
+# that updates src/*.ts without committing the matching dist/ will ship a
+# CLI that crashes with MODULE_NOT_FOUND on any new import path.
+# Reference: i-9l2ueyyz (post-mortem of i-qkarfffa Stage-3 dist/embedding/
+# catch-up bug).
+#
+# Behaviour:
+#   1. If no staged file matches ^src/.*\.ts$ -> exit 0 (fast path for
+#      docs/config-only commits).
+#   2. Otherwise run `npm run build` (which runs tsc + chmod the CLI shim).
+#   3. Then `git diff --quiet -- dist/` -- if dist/ changed as a result of
+#      the rebuild AND those changes are not staged, FAIL LOUDLY with a
+#      message naming the drifted files and the recovery command.
+#
+# Bypass: `git commit --no-verify` (standard git escape hatch).
+
+# --- collect staged src/*.ts paths -----------------------------------------
+mapfile -t STAGED_SRC < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '^src/.*\.ts$' || true)
+
+if [[ "${#STAGED_SRC[@]}" -eq 0 ]]; then
+  # Nothing under src/ in this commit -- no rebuild required.
+  exit 0
+fi
+
+echo "pre-commit: ${#STAGED_SRC[@]} staged src/*.ts file(s) detected -- rebuilding dist/" >&2
+
+# --- snapshot dist/ before rebuild so we can detect rebuild-induced drift --
+# Use SHA-256 of git ls-tree-style content (working tree) for every dist/ file
+# we care about. Cheap enough for ~75 files.
+DIST_SHA_BEFORE=$(find dist -type f \( -name '*.js' -o -name '*.d.ts' -o -name '*.js.map' -o -name '*.d.ts.map' \) -print0 \
+  | sort -z \
+  | xargs -0 sha256sum \
+  | sha256sum \
+  | awk '{print $1}')
+
+# --- run build -- this also gates on tsc errors ----------------------------
+if ! npm run --silent build >/tmp/qmd-pre-commit-build.log 2>&1; then
+  echo "" >&2
+  echo "pre-commit: npm run build FAILED" >&2
+  echo "" >&2
+  echo "Build output (last 40 lines):" >&2
+  tail -n 40 /tmp/qmd-pre-commit-build.log >&2
+  echo "" >&2
+  echo "Full log: /tmp/qmd-pre-commit-build.log" >&2
+  exit 1
+fi
+
+DIST_SHA_AFTER=$(find dist -type f \( -name '*.js' -o -name '*.d.ts' -o -name '*.js.map' -o -name '*.d.ts.map' \) -print0 \
+  | sort -z \
+  | xargs -0 sha256sum \
+  | sha256sum \
+  | awk '{print $1}')
+
+# --- determine drift relative to git index ---------------------------------
+# `git diff --quiet -- dist/` exits 0 if working tree dist/ matches index.
+# Non-zero means dist/ has changes that are NOT staged.
+if git diff --quiet -- dist/; then
+  # dist/ matches index -- everything is in sync, commit may proceed.
+  exit 0
+fi
+
+# Drift detected. Print the offending files + recovery command.
+echo "" >&2
+echo "==========================================================================" >&2
+echo "ABORT: dist/ out of sync with src/" >&2
+echo "==========================================================================" >&2
+echo "" >&2
+echo "Your commit modifies src/*.ts but dist/ is not in sync after rebuild." >&2
+echo "Shipping this commit would push a broken @oivo/qmd to consumer machines" >&2
+echo "(see i-9l2ueyyz / i-qkarfffa Stage-3 retrospective)." >&2
+echo "" >&2
+echo "Drifted files:" >&2
+git diff --name-only -- dist/ | sed 's/^/  /' >&2
+echo "" >&2
+
+if [[ "$DIST_SHA_BEFORE" == "$DIST_SHA_AFTER" ]]; then
+  # The rebuild produced the SAME dist/ as before, but git still reports
+  # drift -- this means dist/ was already drifted before this commit
+  # (someone hand-edited or forgot to stage a previous build). Tell them.
+  echo "Note: rebuild produced no changes -- dist/ was already drifted before this" >&2
+  echo "commit. Stage the drift OR revert dist/ to HEAD before retrying:" >&2
+  echo "" >&2
+  echo "  # Option A: include the drift in this commit" >&2
+  echo "  git add dist/ && git commit  # retry" >&2
+  echo "" >&2
+  echo "  # Option B: discard the drift (use if dist/ was hand-edited)" >&2
+  echo "  git checkout HEAD -- dist/ && git commit  # retry" >&2
+else
+  # The rebuild itself produced new dist/ content. User just needs to stage
+  # the freshly-built dist/.
+  echo "Recovery (run, then retry the commit):" >&2
+  echo "" >&2
+  echo "  git add dist/" >&2
+  echo "  git commit  # retry" >&2
+fi
+echo "" >&2
+echo "Bypass (NOT recommended -- ships broken @oivo/qmd to fleet):" >&2
+echo "  git commit --no-verify" >&2
+echo "" >&2
+exit 1