diff --git a/AGENTS.md b/AGENTS.md index 886eee1..f8a7e51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,14 @@ bun run rename:project # one-shot rebrand after Use this template (boringstack ./scripts/audit-repo-settings.sh # diff GitHub repo settings vs .github/desired-repo-settings.json ``` +## Pre-push + +The root `pre-push` hook is fast by default: the secret scan, each changed +app's `bun run check`, the UI tests for changed files, shared packages and the +static Compose checks. Full suites, builds, budgets, semgrep, osv-scanner and +the smoke/Playwright runs are the pull request's required checks. +`FULL_PREPUSH=1 git push` runs the whole gate locally. + ## Structured verification Start account-owned feature work with `bun run agent:inspect -- account-resource --json`. diff --git a/apps/docs/public/agents.md b/apps/docs/public/agents.md index 6fff6f3..2cf7062 100644 --- a/apps/docs/public/agents.md +++ b/apps/docs/public/agents.md @@ -150,7 +150,8 @@ Turning the optional overlays off is the right move on a small machine: **`bun run check` is the oracle.** If anything here disagrees with what `check` says, the lint config wins, so flag the drift rather than working around it. Run it before every commit; -CI runs the same gates and a pre-push hook mirrors them. +CI runs the same gates. The pre-push hook runs the fast ones (secret scan, each changed +app's `check`, tests for changed UI files); `FULL_PREPUSH=1 git push` runs all of them. | Rule | What it means | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/apps/docs/src/content/docs/architecture/lint-as-contract.mdx b/apps/docs/src/content/docs/architecture/lint-as-contract.mdx index 3aa264d..58e6727 100644 --- a/apps/docs/src/content/docs/architecture/lint-as-contract.mdx +++ b/apps/docs/src/content/docs/architecture/lint-as-contract.mdx @@ -106,7 +106,7 @@ bun run lint:fix # autofix what can be fixed mechanically bun run knip # unused files, exports, and dependencies ``` -The same merge gate runs in three places so a broken change cannot reach `main`. A husky `pre-commit` hook runs `bun run lint-staged` against the staged files, the cheap, scoped check on every commit. A husky `pre-push` hook (installed via `prepare` on `bun run install` / `bun install`; for `infra/compose` and `.github`, run `./scripts/install-hooks.sh` once) then runs the full `validate` locally before anything leaves your machine: typecheck, lint, knip, tests, build, `osv-scanner` against the lockfile, and (when workspace apps are present) an OpenAPI schema drift check across apps/api and apps/ui. CI re-runs the same gate so a `--no-verify` push is still caught. +The same merge gate runs in three places so a broken change cannot reach `main`. A husky `pre-commit` hook runs `bun run lint-staged` against the staged files, the cheap, scoped check on every commit. A husky `pre-push` hook (installed via `prepare` on `bun run install` / `bun install`; for `infra/compose` and `.github`, run `./scripts/install-hooks.sh` once) then runs the fast gate before anything leaves your machine: the gitleaks secret scan, each changed app's `bun run check` (typecheck, lint, lint:meta, format, knip), the UI tests for the files the push changes, shared packages and the static Compose checks. Full test suites, builds, bundle budgets, semgrep, `osv-scanner` and the smoke and Playwright runs belong to the pull request's required checks, which block the merge. `FULL_PREPUSH=1 git push` runs the whole gate locally, and CI catches a `--no-verify` push either way. [Knip](https://knip.dev) sits alongside the lint plugins, surfacing unused files, unused exports, and unused dependencies. It runs as part of `validate` so dead code can't drift in unnoticed. @@ -114,7 +114,7 @@ The same merge gate runs in three places so a broken change cannot reach `main`. The UI's bundle budgets (`.size-limit.json`, plus the modulepreload coverage check) run in `bun run validate`. The budget limits do not go up silently. `bun run check:size-budget` -runs in `validate`, in CI and in the pre-push hook. It fails when a limit is raised or a new +runs in `validate`, in CI and in the full pre-push gate (`FULL_PREPUSH=1`). It fails when a limit is raised or a new entry is added, unless `apps/ui/budgets.md` has a reason for that entry, and the reason is new or changed in the same change. The check compares against the base branch, so an old reason cannot cover a later raise. diff --git a/apps/docs/src/content/docs/changelog.mdx b/apps/docs/src/content/docs/changelog.mdx index 110361b..35d0df6 100644 --- a/apps/docs/src/content/docs/changelog.mdx +++ b/apps/docs/src/content/docs/changelog.mdx @@ -7,6 +7,13 @@ A log of substantial changes to the docs, templates, and tooling. For per-commit ## 2026-10 +### Fast pre-push by default + +- The root `pre-push` hook runs the secret scan, each changed app's `bun run check`, the UI + tests for changed files, shared packages and the static Compose checks. Full suites, builds, + budgets and scanners are the pull request's required checks. `FULL_PREPUSH=1 git push` runs + the whole gate locally. + ### Template fixes from the first product's code A diff of every template file the first product changed found bugs it had to fix. diff --git a/apps/ui/budgets.md b/apps/ui/budgets.md index 77c1828..f9f351a 100644 --- a/apps/ui/budgets.md +++ b/apps/ui/budgets.md @@ -1,7 +1,7 @@ # Bundle budget reasons Limits in `.size-limit.json` do not go up silently. `bun run check:size-budget` -(part of `bun run validate`, CI and pre-push) fails when a limit is raised, or a +(part of `bun run validate`, CI and the full pre-push gate) fails when a limit is raised, or a new entry is added, unless this file has a reason for that entry that is new or changed in the same change. Compare against the base branch, so an old reason cannot cover a later raise. diff --git a/docs/maintenance/product-feedback.md b/docs/maintenance/product-feedback.md index 588c4c4..91602bb 100644 --- a/docs/maintenance/product-feedback.md +++ b/docs/maintenance/product-feedback.md @@ -153,4 +153,4 @@ it was fixed; Report 2 items are not repeated. | 116 | `pre-push.sh` used `echo \| grep -q` under `pipefail`; with many changed paths the pipeline exits 141 and the app gate is skipped silently. Hooks also leaked `GIT_DIR` into tests that create repositories. | Implemented: here-strings everywhere and the git variables are unset. `tools/agent/pre-push-shell.test.ts`. | | 117 | Code in `packages/*` could not reach the images or the dev containers. | Implemented: a BuildKit `packages` build context and read-only dev mounts; `packages/README.md` keeps the directory present. | | 118 | CI ran the API suite twice, and the UI lacked small conveniences the product added (retry on list errors, a home action on the error page, opt-in devtools, return-to after login, jsdom stubs). | Implemented: one coverage run; the UI items as listed. | -| 119 | The product made pre-push fast by default, skipped main runs for trees a merged PR verified, and removed the per-app release workflows. | Not adopted: the first two weaken gates by policy, and Compose still publishes through the per-app workflows. | +| 119 | The product made pre-push fast by default, skipped main runs for trees a merged PR verified, and removed the per-app release workflows. | Fast pre-push adopted after review: secrets, each changed app's `check`, changed-file UI tests, packages and static Compose checks, with `FULL_PREPUSH=1` for the whole gate. Not adopted: skipping main runs weakens the gate by policy, and Compose still publishes through the per-app workflows. | diff --git a/scripts/ci/pre-push-security.sh b/scripts/ci/pre-push-security.sh index a92ea51..a6a8c40 100755 --- a/scripts/ci/pre-push-security.sh +++ b/scripts/ci/pre-push-security.sh @@ -98,6 +98,12 @@ gitleaks detect \ || fail "gitleaks found leaked secrets. Inspect the output above and fix or add a narrow allowlist entry to .gitleaks.toml." ok "gitleaks clean" +# The fast pre-push stops here: a leaked secret must never reach the remote, +# while semgrep and osv-scanner run in the security-sast/deps workflows. +if [[ "${1:-}" == "--secrets-only" ]]; then + exit 0 +fi + # ─── 2. semgrep ──────────────────────────────────────────────────────────── # Matches the two security-sast workflows: apps-api-security-sast.yml + # apps-ui-security-sast.yml. Configs are kept in lockstep with those diff --git a/scripts/ci/pre-push.sh b/scripts/ci/pre-push.sh index c6d6ccb..8d48d7c 100755 --- a/scripts/ci/pre-push.sh +++ b/scripts/ci/pre-push.sh @@ -14,6 +14,8 @@ # - The workflow file under .github/workflows/apps--*.yml. # - The root codecov.yml (affects upload behaviour for every app). # +# Fast by default (see "Fast by default" below); `FULL_PREPUSH=1 git push` +# runs every gate as CI does. # Bypass: `git push --no-verify` (use sparingly, breaks the build). set -euo pipefail @@ -112,6 +114,57 @@ else echo "$CHANGED_PATHS" | sed 's/^/ /' fi +# ─── Fast by default ───────────────────────────────────────────────────── +# A push runs what fails fastest and most often: the secret scan, each +# changed app's own `check` (lint, lint:meta, format, typecheck, knip, as the +# app defines it), the UI tests touching what this push adds, shared packages +# and the static Compose gate. Full test suites, builds, bundle budgets, +# semgrep, osv-scanner and the smoke/Playwright run stay in the pull +# request's required checks, which block the merge, so they are not paid +# twice per push. The first product built on the template paid them on every +# push and ended up skipping the hook instead. +# FULL_PREPUSH=1 git push runs the whole local gate below. +if [[ "${FULL_PREPUSH:-0}" != "1" ]]; then + bash "$ROOT/scripts/ci/pre-push-security.sh" --secrets-only + + for app_path in "$ROOT"/apps/*/; do + [[ -f "$app_path/package.json" ]] || continue + app="$(basename "$app_path")" + app_changed "$app" || continue + if ! grep -q '"check":' "$app_path/package.json"; then + warn "${app}: no check script, left to CI" + continue + fi + step "${app}: fast checks (bun run check)" + ( cd "apps/${app}" && bun run check ) + ok "${app} fast checks passed" + done + + if app_changed ui || grep -q '^packages/' <<< "$CHANGED_PATHS"; then + step "ui: tests for the files this push changes" + # Only what this push adds: since the branch's last push, or main for a + # new branch. + since="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || echo "$UPSTREAM")" + ( cd apps/ui && bunx vitest run --changed "$since" --passWithNoTests ) + ok "ui tests for changed files passed" + fi + + if [[ -z "$CHANGED_PATHS" ]] || grep -q '^packages/' <<< "$CHANGED_PATHS"; then + step "Running shared packages validate" + bash -c 'source "$1/scripts/stack-lib.sh" && validate_packages' _ "$ROOT" + ok "shared packages validate passed" + fi + + if infra_compose_changed; then + step "Running infra/compose pre-push gate (compose config + shellcheck + yamllint)" + bash "$ROOT/infra/compose/scripts/pre-push.sh" + ok "infra/compose gate passed" + fi + + ok "Fast pre-push finished. Full suites run in the PR checks; FULL_PREPUSH=1 runs them here." + exit 0 +fi + # Security scanners run first: gitleaks, semgrep, osv-scanner all # fail the entire push, so racing them ahead of the slower per-app # `validate` saves time when a finding lands here. diff --git a/tools/agent/pre-push-shell.test.ts b/tools/agent/pre-push-shell.test.ts index 4ae5673..4d88ea1 100644 --- a/tools/agent/pre-push-shell.test.ts +++ b/tools/agent/pre-push-shell.test.ts @@ -71,3 +71,27 @@ test("the root pre-push hook clears inherited git environment before any gate ru expect(unsetIndex).toBeGreaterThan(-1); expect(unsetIndex).toBeLessThan(firstGateIndex); }); + +test("the root pre-push hook is fast by default and FULL_PREPUSH=1 reaches the full gate", () => { + const script = readFileSync(join(ROOT, "scripts/ci/pre-push.sh"), "utf8"); + const fastGuard = script.indexOf( + 'if [[ "${FULL_PREPUSH:-0}" != "1" ]]; then' + ); + const secretsOnly = script.indexOf('pre-push-security.sh" --secrets-only'); + const fastExit = script.indexOf(" exit 0\nfi", fastGuard); + const fullSecurity = script.indexOf( + 'bash "$ROOT/scripts/ci/pre-push-security.sh"\n' + ); + const security = readFileSync( + join(ROOT, "scripts/ci/pre-push-security.sh"), + "utf8" + ); + + expect(fastGuard).toBeGreaterThan(-1); + expect(secretsOnly).toBeGreaterThan(fastGuard); + expect(fastExit).toBeGreaterThan(secretsOnly); + expect(fullSecurity).toBeGreaterThan(fastExit); + expect(security.indexOf('"--secrets-only"')).toBeGreaterThan( + security.indexOf("gitleaks clean") + ); +});