From 006cdafe8f0248c00714d25cf50b76fa0a7c292f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 14:19:46 -0400 Subject: [PATCH] ci(drift): enforce alias freshness checks in CI and contributor flow (#2910) Merging alias-drift guardrails and local hook hardening. --- .githooks/pre-commit | 6 ++ .githooks/pre-push | 48 +++++++++++ .github/workflows/test.yml | 12 +++ CONTRIBUTING.md | 67 +++++++++++++++ package.json | 1 + sdk/package.json | 1 + tests/precommit-alias-drift-hook.test.cjs | 67 +++++++++++++++ tests/prepush-enterprise-email-hook.test.cjs | 90 ++++++++++++++++++++ 8 files changed, 292 insertions(+) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 tests/precommit-alias-drift-hook.test.cjs create mode 100644 tests/prepush-enterprise-email-hook.test.cjs diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..38f701798 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.generated\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then + npm run check:alias-drift +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..2d9862c60 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +zero_sha='0000000000000000000000000000000000000000' +blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}" + +# Local-only guard: no-op unless the developer opts in via env var, e.g. +# export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\.com$' +if [[ -z "$blocked_regex" ]]; then + exit 0 +fi + +violations=() + +while read -r local_ref local_sha remote_ref remote_sha; do + # branch/tag deletion + if [[ "$local_sha" == "$zero_sha" ]]; then + continue + fi + + if [[ "$remote_sha" == "$zero_sha" ]]; then + # New remote ref: inspect commits not already on any remote + commit_list=$(git rev-list "$local_sha" --not --remotes) + else + commit_list=$(git rev-list "$remote_sha..$local_sha") + fi + + while read -r commit; do + [[ -z "$commit" ]] && continue + author_email=$(git show -s --format='%ae' "$commit") + lower_email=$(printf '%s' "$author_email" | tr '[:upper:]' '[:lower:]') + if printf '%s' "$lower_email" | grep -Eq "$blocked_regex"; then + violations+=("$commit <$author_email>") + fi + done <<< "$commit_list" +done + +if [[ ${#violations[@]} -gt 0 ]]; then + { + echo "Push blocked: commit author email matched local blocked regex ($blocked_regex)." + echo "Rewrite author info before pushing these commits:" + for v in "${violations[@]}"; do + echo " - $v" + done + echo "Suggested fix: git rebase -i --exec \"git commit --amend --no-edit --author='Your Name '\"" + } >&2 + exit 1 +fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46b58614b..a39f8d133 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,6 +88,18 @@ jobs: - name: Build SDK dist (required by installer) run: npm run build:sdk + # Seam contract gate: keep manifest -> generated aliases -> registry/CJS adapters aligned. + # Run once per workflow on the primary Linux node to avoid redundant matrix cost. + - name: SDK seam coverage tests + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: cd sdk && npx vitest run src/query/command-seam-coverage.test.ts + + - name: SDK generated alias artifact drift check + if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24 + shell: bash + run: node sdk/scripts/check-command-aliases-fresh.mjs + - name: Run tests with coverage shell: bash run: npm run test:coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0defdbba0..b0a7525f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -345,6 +345,73 @@ node --test tests/core.test.cjs npm run test:coverage ``` +### Pre-PR Seam Checks (Manifest/Alias Routing) + +If you touched any of the command-manifest or generated alias files, run: + +```bash +npm run check:alias-drift +``` + +This verifies generated alias artifacts are in sync with manifest source-of-truth. + +Optional local pre-commit hook entry (Git-native): + +```bash +# one-time setup +mkdir -p .githooks +cat > .githooks/pre-commit <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.generated\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then + npm run check:alias-drift +fi +EOF +chmod +x .githooks/pre-commit +git config core.hooksPath .githooks +``` + +Optional local pre-push hook to block a private author-email pattern: + +```bash +# set locally in your shell profile (example) +export GSD_BLOCKED_AUTHOR_REGEX='@example-corp\\.com$' + +cat > .githooks/pre-push <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +zero_sha='0000000000000000000000000000000000000000' +blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}" +[[ -z "$blocked_regex" ]] && exit 0 +violations=() + +while read -r local_ref local_sha remote_ref remote_sha; do + [[ "$local_sha" == "$zero_sha" ]] && continue + if [[ "$remote_sha" == "$zero_sha" ]]; then + commits=$(git rev-list "$local_sha" --not --remotes) + else + commits=$(git rev-list "$remote_sha..$local_sha") + fi + while read -r commit; do + [[ -z "$commit" ]] && continue + email=$(git show -s --format='%ae' "$commit" | tr '[:upper:]' '[:lower:]') + if printf '%s' "$email" | grep -Eq "$blocked_regex"; then + violations+=("$commit <$email>") + fi + done <<< "$commits" +done + +if [[ ${#violations[@]} -gt 0 ]]; then + echo "Push blocked: commit author email matched local blocked regex ($blocked_regex)." >&2 + printf ' - %s\n' "${violations[@]}" >&2 + exit 1 +fi +EOF +chmod +x .githooks/pre-push +``` + ### CI Test Quality Checks The following checks run on every PR in addition to the test suite: diff --git a/package.json b/package.json index 637e61e2f..4d3156ab9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "scripts": { "build:hooks": "node scripts/build-hooks.js", "build:sdk": "cd sdk && npm ci && npm run build", + "check:alias-drift": "cd sdk && npm run check:alias-drift", "prepublishOnly": "npm run build:hooks && npm run build:sdk", "pretest": "npm run build:sdk", "pretest:coverage": "npm run build:sdk", diff --git a/sdk/package.json b/sdk/package.json index 259ed530e..aef1cc219 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -34,6 +34,7 @@ }, "scripts": { "build": "tsc", + "check:alias-drift": "npm run build && node scripts/check-command-aliases-fresh.mjs", "prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js", "test": "vitest run", "test:unit": "vitest run --project unit", diff --git a/tests/precommit-alias-drift-hook.test.cjs b/tests/precommit-alias-drift-hook.test.cjs new file mode 100644 index 000000000..472ce9f21 --- /dev/null +++ b/tests/precommit-alias-drift-hook.test.cjs @@ -0,0 +1,67 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-commit'); + +function writeExec(filePath, content) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +describe('.githooks/pre-commit alias drift guard', () => { + test('runs npm check when staged files include command-manifest/alias artifacts', (t) => { + const tmpDir = createTempDir('gsd-precommit-hook-'); + t.after(() => cleanup(tmpDir)); + + const binDir = path.join(tmpDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`); + writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); + + const marker = path.join(tmpDir, 'npm-called.txt'); + + execFileSync('bash', [HOOK_PATH], { + cwd: ROOT, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + GSD_TEST_NPM_MARKER: marker, + }, + stdio: 'pipe', + }); + + assert.ok(fs.existsSync(marker), 'expected npm run check:alias-drift to be invoked'); + }); + + test('does not run npm check when staged files are unrelated', (t) => { + const tmpDir = createTempDir('gsd-precommit-hook-'); + t.after(() => cleanup(tmpDir)); + + const binDir = path.join(tmpDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`); + writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); + + const marker = path.join(tmpDir, 'npm-called.txt'); + + execFileSync('bash', [HOOK_PATH], { + cwd: ROOT, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + GSD_TEST_NPM_MARKER: marker, + }, + stdio: 'pipe', + }); + + assert.ok(!fs.existsSync(marker), 'expected npm check to be skipped for unrelated staged files'); + }); +}); diff --git a/tests/prepush-enterprise-email-hook.test.cjs b/tests/prepush-enterprise-email-hook.test.cjs new file mode 100644 index 000000000..81b3893d6 --- /dev/null +++ b/tests/prepush-enterprise-email-hook.test.cjs @@ -0,0 +1,90 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-push'); + +function writeExec(filePath, content) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +describe('.githooks/pre-push enterprise email guard', () => { + test('blocks push when any to-be-pushed commit matches local blocked regex', (t) => { + const tmpDir = createTempDir('gsd-prepush-hook-'); + t.after(() => cleanup(tmpDir)); + + const binDir = path.join(tmpDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "rev-list" ]]; then + echo "c1" + echo "c2" + exit 0 +fi +if [[ "$1" == "show" ]]; then + commit="$(printf '%s\n' "$@" | tail -n 1)" + if [[ "$commit" == "c1" ]]; then + echo "trekkie@nomorestars.com" + else + echo "person@example-corp.com" + fi + exit 0 +fi +exit 1 +`); + + assert.throws(() => { + execFileSync('bash', [HOOK_PATH], { + cwd: ROOT, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', + }, + input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', + stdio: 'pipe', + }); + }, /Push blocked: commit author email matched local blocked regex/); + }); + + test('allows push when to-be-pushed commits are non-enterprise emails', (t) => { + const tmpDir = createTempDir('gsd-prepush-hook-'); + t.after(() => cleanup(tmpDir)); + + const binDir = path.join(tmpDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + + writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "rev-list" ]]; then + echo "c1" + echo "c2" + exit 0 +fi +if [[ "$1" == "show" ]]; then + echo "trekkie@nomorestars.com" + exit 0 +fi +exit 1 +`); + + execFileSync('bash', [HOOK_PATH], { + cwd: ROOT, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', + }, + input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', + stdio: 'pipe', + }); + }); +});