Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/public/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ 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.

## Bundle budgets need a recorded reason

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.
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/src/content/docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/budgets.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/maintenance/product-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
6 changes: 6 additions & 0 deletions scripts/ci/pre-push-security.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions scripts/ci/pre-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
# - The workflow file under .github/workflows/apps-<app>-*.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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions tools/agent/pre-push-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
});
Loading