Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d44537b9 | |||
| 101deb9546 | |||
| 80fc813b61 | |||
| 0d3a5fe2fe |
@@ -0,0 +1,17 @@
|
||||
# Copy to deploy/.env.staging and fill in. Used by:
|
||||
# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
|
||||
# -f docker-compose.yml -f docker-compose.staging.yml up
|
||||
# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
|
||||
# staging can never touch prod credentials, DB, or Google project.
|
||||
POSTGRES_PASSWORD=change-me-staging
|
||||
|
||||
# Use a SEPARATE Google OAuth client for staging with redirect URI:
|
||||
# http://localhost:18081/signin-google
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
AI_MODE=Disabled
|
||||
FRONTEND_ORIGIN=http://localhost:18081
|
||||
|
||||
# Staging caps the initial mailbox sync so rehearsals are fast.
|
||||
MAX_MESSAGES=2000
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy Production
|
||||
|
||||
# Production promotion. The APPROVAL GATE is the git tag: production only ever
|
||||
# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
|
||||
# is the deliberate, auditable "approve to go live" action — and the tag doubles
|
||||
# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
|
||||
# for re-deploys/rollbacks.
|
||||
#
|
||||
# STATUS: inactive until (a) the Linux production server exists and (b) a
|
||||
# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
|
||||
# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
|
||||
# switch on — review the deploy step for your server before first use.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or commit to deploy (e.g. v1.2.0)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [self-hosted, production]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Deploy the exact tag that triggered the run (immutable), or the
|
||||
# ref given to a manual dispatch.
|
||||
ref: ${{ github.event.inputs.ref || github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Deploy release to production
|
||||
run: |
|
||||
echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
|
||||
# deploy/.env lives on the server (never in git). up.sh validates it,
|
||||
# builds the Linux images, applies EF migrations on boot, and starts
|
||||
# the stack behind the nginx reverse proxy.
|
||||
./deploy/up.sh --proxy
|
||||
|
||||
- name: Smoke check
|
||||
run: |
|
||||
sleep 5
|
||||
curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
|
||||
{ echo "Smoke check failed"; exit 1; }
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Deploy Staging
|
||||
|
||||
# Continuous deployment to the LOCAL staging stack. Fires when develop advances
|
||||
# (i.e. after a PR is merged into develop). It re-verifies the code, then rebuilds
|
||||
# and restarts the isolated staging stack on this machine.
|
||||
#
|
||||
# CRITICAL: staging lives on your Windows box (ports 18080/18081). A cloud or
|
||||
# container runner CANNOT reach it, so this job MUST run on a self-hosted Gitea
|
||||
# Actions runner registered ON that Windows machine with Docker access
|
||||
# (labels: self-hosted, windows). Until that runner exists this job just waits
|
||||
# in the queue (harmless, cancelable) — deploy staging manually meanwhile with:
|
||||
# ./deploy/up.ps1 -Staging
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [self-hosted, windows]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Re-run the gate on the exact merged code before it touches staging.
|
||||
- name: Build + test (Release)
|
||||
shell: powershell
|
||||
run: |
|
||||
dotnet build InboxIntel.sln -c Release --nologo
|
||||
dotnet test InboxIntel.sln -c Release --no-build --nologo
|
||||
|
||||
- name: Redeploy staging stack
|
||||
shell: powershell
|
||||
run: |
|
||||
docker compose -p inboxintel-staging `
|
||||
--env-file deploy/.env.staging `
|
||||
-f docker-compose.yml -f docker-compose.staging.yml `
|
||||
up -d --build
|
||||
docker compose -p inboxintel-staging ps
|
||||
|
||||
- name: Staging endpoints
|
||||
shell: powershell
|
||||
run: Write-Host "Staging up — Frontend http://localhost:18081 API http://localhost:18080/swagger"
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Security
|
||||
|
||||
# Scans run alongside CI on every PR and on pushes to the long-lived branches.
|
||||
# This is the DETECTIVE layer (backstop). The PREVENTIVE layer is the local
|
||||
# pre-commit hook — this catches anything that slipped past it (e.g. --no-verify)
|
||||
# and scans the full history, not just the staged diff.
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
secrets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full history so gitleaks scans every commit
|
||||
- name: Secret scan (gitleaks)
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
|
||||
detect --source=/repo --redact --verbose --exit-code 1
|
||||
|
||||
dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Restore
|
||||
run: dotnet restore InboxIntel.sln
|
||||
- name: .NET vulnerable packages (fail on any)
|
||||
run: |
|
||||
dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt
|
||||
if grep -qiE 'Critical|High|Moderate|Low' vuln.txt; then
|
||||
echo "::error::Vulnerable NuGet packages detected — see the table above."
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable NuGet packages."
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: npm audit (fail on high/critical)
|
||||
working-directory: frontend
|
||||
run: npm audit --audit-level=high
|
||||
@@ -54,6 +54,7 @@ lpt[1-9].*
|
||||
*.code-workspace
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to InboxIntel are documented here. Format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/); versions follow
|
||||
[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
|
||||
vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`),
|
||||
`deploy-prod` (tag-gated production promotion, inactive until the server exists).
|
||||
- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
|
||||
- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
|
||||
Linux containers on Windows, isolated ports/volumes.
|
||||
- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
|
||||
(`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
|
||||
build+test gate.
|
||||
- `VERSION` file as the single source of truth for the release number.
|
||||
|
||||
## [0.1.0] — scaffold
|
||||
### Added
|
||||
- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
|
||||
- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
|
||||
- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
|
||||
- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
|
||||
loopback binds, non-root containers, SSRF egress guard.
|
||||
- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
|
||||
|
||||
[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
|
||||
+12
-3
@@ -5,13 +5,22 @@
|
||||
./deploy/down.ps1
|
||||
./deploy/down.ps1 -Volumes
|
||||
#>
|
||||
param([switch]$Volumes)
|
||||
param([switch]$Volumes, [switch]$Staging)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
|
||||
$composeArgs = @('compose', '--env-file', $envFile, 'down')
|
||||
if ($Staging) {
|
||||
$envFile = Join-Path $PSScriptRoot '.env.staging'
|
||||
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
|
||||
$project = @('-p', 'inboxintel-staging')
|
||||
} else {
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
$composeFiles = @()
|
||||
$project = @()
|
||||
}
|
||||
|
||||
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles + @('down')
|
||||
if ($Volumes) { $composeArgs += '--volumes' }
|
||||
|
||||
Push-Location $root
|
||||
|
||||
+23
-6
@@ -8,16 +8,29 @@
|
||||
#>
|
||||
param(
|
||||
[switch]$Proxy,
|
||||
[switch]$Foreground
|
||||
[switch]$Foreground,
|
||||
[switch]$Staging # production-shaped staging stack: separate env, ports, volumes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
|
||||
# Staging vs production: pick the env file + compose overlay + isolated project name.
|
||||
if ($Staging) {
|
||||
$envFile = Join-Path $PSScriptRoot '.env.staging'
|
||||
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
|
||||
$project = @('-p', 'inboxintel-staging')
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "Missing $envFile. Create it from .env.staging.example."
|
||||
}
|
||||
} else {
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
$composeFiles = @()
|
||||
$project = @()
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
||||
}
|
||||
}
|
||||
|
||||
# Fail fast if a required key is absent or blank.
|
||||
$required = 'POSTGRES_PASSWORD','GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET'
|
||||
@@ -28,19 +41,23 @@ Get-Content $envFile | ForEach-Object {
|
||||
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
|
||||
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
|
||||
|
||||
$composeArgs = @('compose', '--env-file', $envFile)
|
||||
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles
|
||||
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
|
||||
$composeArgs += @('up', '--build')
|
||||
if (-not $Foreground) { $composeArgs += '-d' }
|
||||
|
||||
Push-Location $root
|
||||
try {
|
||||
Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan
|
||||
Write-Host "Starting InboxIntel via docker compose (env: $envFile)..." -ForegroundColor Cyan
|
||||
& docker @composeArgs
|
||||
if (-not $Foreground) {
|
||||
& docker compose --env-file $envFile ps
|
||||
& docker compose @project --env-file $envFile @composeFiles ps
|
||||
if ($Staging) {
|
||||
Write-Host "`n[STAGING] Frontend: http://localhost:18081 API/Swagger: http://localhost:18080/swagger" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
|
||||
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
finally { Pop-Location }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Staging overlay for InboxIntel.
|
||||
#
|
||||
# The base docker-compose.yml IS the production definition (Linux containers,
|
||||
# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
|
||||
# top of it so you can run a production-shaped stack locally on Windows WITHOUT
|
||||
# clobbering a real production deployment's data, ports, or volumes.
|
||||
#
|
||||
# It differs from prod only in the ways staging is meant to differ:
|
||||
# - the dev/test banner is on (App__DevMode=true)
|
||||
# - the initial Gmail sync is capped so a big mailbox doesn't take forever
|
||||
# - ports are shifted into the 18xxx range so staging can run alongside prod
|
||||
# - a distinct project name gives it its own isolated pgdata + keys volumes
|
||||
#
|
||||
# Run it with the -p (project name) flag so volumes/networks are namespaced:
|
||||
#
|
||||
# docker compose -p inboxintel-staging \
|
||||
# --env-file deploy/.env.staging \
|
||||
# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
|
||||
#
|
||||
# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432"
|
||||
|
||||
api:
|
||||
environment:
|
||||
# Same Production runtime as prod (real config binding, real build), but
|
||||
# flagged as a non-production instance so the UI shows the staging banner
|
||||
# and the first sync is bounded.
|
||||
App__DevMode: "true"
|
||||
GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
|
||||
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
|
||||
ports:
|
||||
- "127.0.0.1:18080:8080"
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "18081:80"
|
||||
|
||||
nginx:
|
||||
ports:
|
||||
- "18000:80"
|
||||
@@ -0,0 +1,188 @@
|
||||
# InboxIntel — Git Workflow & Environment Strategy
|
||||
|
||||
The single source of truth for how we branch, commit, version, and promote code
|
||||
across environments. Optimised for a **solo developer on Windows 11 with a future
|
||||
Linux production server**. Kept deliberately lightweight — every rule here earns
|
||||
its place.
|
||||
|
||||
---
|
||||
|
||||
## 1. Branch strategy
|
||||
|
||||
A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
|
||||
short-lived branches off `develop`.
|
||||
|
||||
| Branch | Lives for | Purpose | Deploys to |
|
||||
|-----------------|-----------|------------------------------------------------------|------------|
|
||||
| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
|
||||
| `develop` | forever | Integration branch. What staging runs | staging |
|
||||
| `feature/*` | hours–days| One feature or refactor | dev (local)|
|
||||
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
|
||||
| `hotfix/*` | minutes–hrs| Urgent prod fix, branched from `main` | prod (fast)|
|
||||
| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
|
||||
|
||||
**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
|
||||
(separate release managers, parallel release trains). But keeping `develop`
|
||||
separate from `main` gives one thing that pure trunk-based can't: a **staging
|
||||
environment that always mirrors `develop`** while `main` stays clean and
|
||||
tag-perfect for production. `release/*` exists only when you want to freeze
|
||||
features and stabilise — skip it for routine work.
|
||||
|
||||
### Normal flow
|
||||
|
||||
```
|
||||
main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
|
||||
\ /
|
||||
develop ──●──●──●──●──●──●──●──●──────────── (staging)
|
||||
\ / \ /
|
||||
feature/x ●─● \ /
|
||||
fix/y ●───●
|
||||
```
|
||||
|
||||
1. `git switch develop && git pull`
|
||||
2. `git switch -c feature/sender-policy`
|
||||
3. Commit in small conventional commits.
|
||||
4. Push, open a PR **into `develop`** (Gitea). CI must be green.
|
||||
5. Squash-merge. Delete the branch.
|
||||
6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Commit conventions — Conventional Commits
|
||||
|
||||
Format: `type(scope): short imperative summary`
|
||||
|
||||
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
|
||||
`build`, `style`, `security`.
|
||||
|
||||
Rules:
|
||||
- Summary ≤ 72 chars, imperative mood ("add", not "added").
|
||||
- One logical change per commit.
|
||||
- Body explains **why**, not what (the diff shows what).
|
||||
- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
|
||||
|
||||
Examples (matching this repo's history):
|
||||
```
|
||||
feat(ui): F1+F2 — component primitives + rebuilt app shell
|
||||
fix(security): systemic IDOR safeguard via EF global query filters
|
||||
ci: add Gitea Actions build + test pipeline
|
||||
```
|
||||
|
||||
Why: conventional commits drive **automatic semver bumps** and a generated
|
||||
CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
|
||||
`BREAKING CHANGE` → major.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pull request / merge flow (Gitea)
|
||||
|
||||
Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
|
||||
a paper trail.
|
||||
|
||||
- **Target:** `feature/*` and `fix/*` → `develop`. `develop`/`hotfix/*` → `main`.
|
||||
- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
|
||||
- **Merge style:** **squash-merge** feature branches (one clean commit on
|
||||
`develop`). **Merge commit** for `develop → main` (preserves the integration
|
||||
history and makes the release boundary visible).
|
||||
- **Branch protection (Gitea → Settings → Branches):** protect `main` and
|
||||
`develop`; require status checks to pass; disallow force-push.
|
||||
|
||||
---
|
||||
|
||||
## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
|
||||
|
||||
- **MAJOR** — breaking API/behaviour change.
|
||||
- **MINOR** — backward-compatible feature.
|
||||
- **PATCH** — backward-compatible fix.
|
||||
|
||||
The current version lives in [`VERSION`](../VERSION) and is the one place bumped
|
||||
per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
|
||||
fixes; anything may change).
|
||||
|
||||
---
|
||||
|
||||
## 5. Tagging & releases
|
||||
|
||||
Tags are cut **only on `main`**, annotated, prefixed `v`:
|
||||
|
||||
```bash
|
||||
git switch main && git pull
|
||||
# bump VERSION + CHANGELOG in a release commit, then:
|
||||
git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
- Tag == the exact commit deployed to production == the rollback target.
|
||||
- The tag message summarises the release; details live in `CHANGELOG.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hotfix process
|
||||
|
||||
For a bug already in production:
|
||||
|
||||
```bash
|
||||
git switch main && git pull
|
||||
git switch -c hotfix/oauth-callback-500
|
||||
# fix + test
|
||||
git commit -m "fix(security): guard null OAuth state on callback"
|
||||
# PR hotfix/* -> main, CI green, merge
|
||||
git switch main && git pull
|
||||
git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
|
||||
# deploy the tag, THEN back-merge so develop doesn't lose the fix:
|
||||
git switch develop && git merge main && git push
|
||||
```
|
||||
|
||||
The **back-merge to `develop`** is the step people forget — without it the next
|
||||
release silently reintroduces the bug.
|
||||
|
||||
---
|
||||
|
||||
## 7. Environments
|
||||
|
||||
| Env | Host | Runtime | Config source | Purpose |
|
||||
|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
|
||||
| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
|
||||
| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
|
||||
| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
|
||||
|
||||
**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
|
||||
that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
|
||||
*same Linux images* Docker builds for production, so "works in staging" genuinely
|
||||
predicts "works in prod". The only staging↔prod differences are the dev banner,
|
||||
capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
|
||||
|
||||
### Commands
|
||||
|
||||
```powershell
|
||||
# DEV (native, hot reload) — two terminals
|
||||
dotnet run --project src/InboxIntel.Api # http://localhost:5080
|
||||
cd frontend; npm run dev # http://localhost:5173
|
||||
|
||||
# STAGING (production-shaped, on Windows via Docker)
|
||||
./deploy/up.ps1 -Staging # http://localhost:18081
|
||||
./deploy/down.ps1 -Staging
|
||||
|
||||
# PRODUCTION-shaped run locally (smoke test the real config)
|
||||
./deploy/up.ps1 # http://localhost:8081
|
||||
```
|
||||
|
||||
Dev and staging use **different ports and different volumes**, so they can run at
|
||||
the same time and never share a database.
|
||||
|
||||
---
|
||||
|
||||
## 8. Git hooks
|
||||
|
||||
Installed via `core.hooksPath` (run once per clone):
|
||||
|
||||
```powershell
|
||||
./scripts/install-hooks.ps1
|
||||
```
|
||||
|
||||
- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
|
||||
- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
|
||||
build — the same gate CI runs, caught before the push.
|
||||
|
||||
Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
|
||||
bypassed push can still be rejected by branch protection.
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmailApi } from '../api/client.js';
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtSize = (b) => {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared reading-pane / full single-email view.
|
||||
*
|
||||
* Accepts either an `email` summary object (as emitted by the list rows) or a
|
||||
* bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI
|
||||
* summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI
|
||||
* summary button, the body, and per-email actions including "Open in Gmail".
|
||||
*
|
||||
* `onClose` collapses the pane.
|
||||
*/
|
||||
export default function EmailDetail({ email: summary, emailId, onClose }) {
|
||||
const id = summary?.id ?? emailId;
|
||||
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiSummary, setAiSummary] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id == null) return;
|
||||
let cancelled = false;
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setAiSummary(null);
|
||||
setAiLoading(false);
|
||||
EmailApi.get(id)
|
||||
.then((d) => { if (!cancelled) setDetail(d); })
|
||||
.catch(() => { if (!cancelled) setDetail(null); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
const email = detail ?? summary ?? {};
|
||||
|
||||
const fetchAiSummary = () => {
|
||||
if (id == null) return;
|
||||
setAiLoading(true);
|
||||
EmailApi.summary(id)
|
||||
.then((r) => setAiSummary(r.summary))
|
||||
.catch(() => setAiSummary(null))
|
||||
.finally(() => setAiLoading(false));
|
||||
};
|
||||
|
||||
const openInGmail = () => window.open(
|
||||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||||
'_blank', 'noopener,noreferrer'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sv-detail">
|
||||
<div className="sv-detail-topbar">
|
||||
<button className="sv-close" onClick={onClose} aria-label="Close reading pane" title="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sv-detail-header">
|
||||
<div className="sv-detail-subject">{email.subject || '(no subject)'}</div>
|
||||
<div className="sv-detail-meta">
|
||||
<span>{email.senderDisplayName || email.senderAddress}</span>
|
||||
{email.sentAtUtc && (
|
||||
<>
|
||||
<span className="sv-detail-sep">·</span>
|
||||
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
{email.sizeEstimateBytes > 0 && (
|
||||
<>
|
||||
<span className="sv-detail-sep">·</span>
|
||||
<span>{fmtSize(email.sizeEstimateBytes)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="sv-ai-summary">
|
||||
{aiSummary != null ? (
|
||||
<div className="sv-ai-summary-text">✨ {aiSummary}</div>
|
||||
) : (
|
||||
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarising…' : '✨ AI summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="sv-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
<div className="sv-body">{detail.bodyText}</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail?.bodyText && email.snippet && (
|
||||
<div className="sv-snippet">{email.snippet}</div>
|
||||
)}
|
||||
|
||||
<div className="sv-detail-actions">
|
||||
<button className="btn-sm" onClick={openInGmail}>Open in Gmail ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import Layout from './components/Layout.jsx';
|
||||
import { ToastProvider, TooltipProvider } from './components/ui';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
import './split.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import EmailDetail from '../components/EmailDetail.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import { Skeleton, EmptyState } from '../components/ui';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
|
||||
@@ -37,20 +35,6 @@ const FOLDER_META = {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function ListSkeleton({ rows = 6 }) {
|
||||
return (
|
||||
<div className="sv-skeleton-list" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div className="sv-skeleton-row" key={i}>
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function FolderView() {
|
||||
const { slug } = useParams();
|
||||
@@ -62,7 +46,6 @@ export default function FolderView() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedEmail, setSelectedEmail] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
|
||||
@@ -73,7 +56,6 @@ export default function FolderView() {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
setSelectedEmail(null);
|
||||
clear();
|
||||
}, [slug, clear]);
|
||||
|
||||
@@ -133,15 +115,8 @@ export default function FolderView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
|
||||
<div className="sv-list-pane">
|
||||
{loading && emails.length === 0 && !error && <ListSkeleton />}
|
||||
|
||||
{!loading && !error && emails.length === 0 && (
|
||||
<EmptyState
|
||||
title="No emails in this folder"
|
||||
description="Nothing here yet — try another folder or run a sync."
|
||||
/>
|
||||
<div className="fv-empty">No emails in this folder.</div>
|
||||
)}
|
||||
|
||||
{emails.length > 0 && (
|
||||
@@ -154,11 +129,7 @@ export default function FolderView() {
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onOpen={(email) => setSelectedEmail(email)}
|
||||
onRemove={(id) => {
|
||||
setEmails((prev) => prev.filter((x) => x.id !== id));
|
||||
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
|
||||
}}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -168,22 +139,10 @@ export default function FolderView() {
|
||||
{/* Sentinel — triggers next page load when scrolled into view */}
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
|
||||
{loading && emails.length > 0 && <div className="fv-loading-more">Loading…</div>}
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEmail && (
|
||||
<aside className="sv-reading-pane">
|
||||
<EmailDetail
|
||||
key={selectedEmail.id}
|
||||
email={selectedEmail}
|
||||
onClose={() => setSelectedEmail(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,29 +2,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import EmailDetail from '../components/EmailDetail.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import { Skeleton, EmptyState } from '../components/ui';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function ListSkeleton({ rows = 6 }) {
|
||||
return (
|
||||
<div className="sv-skeleton-list" aria-hidden="true">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div className="sv-skeleton-row" key={i}>
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function SearchResults() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -36,7 +20,6 @@ export default function SearchResults() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedEmail, setSelectedEmail] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
|
||||
@@ -53,7 +36,6 @@ export default function SearchResults() {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
setSelectedEmail(null);
|
||||
clear();
|
||||
}, [q, clear]);
|
||||
|
||||
@@ -101,13 +83,11 @@ export default function SearchResults() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!q.trim() && (
|
||||
<EmptyState
|
||||
title="Search your mail"
|
||||
description="Enter a search query above to find emails."
|
||||
/>
|
||||
)}
|
||||
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
|
||||
{error && <div className="fv-error">{error}</div>}
|
||||
{!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
|
||||
<div className="fv-empty">No results for "{q}".</div>
|
||||
)}
|
||||
|
||||
<BulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
@@ -120,18 +100,6 @@ export default function SearchResults() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{q.trim() && (
|
||||
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
|
||||
<div className="sv-list-pane">
|
||||
{loading && emails.length === 0 && !error && <ListSkeleton />}
|
||||
|
||||
{!loading && !error && emails.length === 0 && !hasMore && (
|
||||
<EmptyState
|
||||
title={`No results for "${q}"`}
|
||||
description="Try a different search term or filter."
|
||||
/>
|
||||
)}
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
@@ -142,11 +110,7 @@ export default function SearchResults() {
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onOpen={(email) => setSelectedEmail(email)}
|
||||
onRemove={(id) => {
|
||||
setEmails((prev) => prev.filter((x) => x.id !== id));
|
||||
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
|
||||
}}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -154,23 +118,10 @@ export default function SearchResults() {
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
{loading && emails.length > 0 && <div className="fv-loading-more">Loading…</div>}
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} results —</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEmail && (
|
||||
<aside className="sv-reading-pane">
|
||||
<EmailDetail
|
||||
key={selectedEmail.id}
|
||||
email={selectedEmail}
|
||||
onClose={() => setSelectedEmail(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/* ── Split-view reading pane ────────────────────────────────────────────────
|
||||
* Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on
|
||||
* the left, a collapsible + horizontally resizable reading pane sits on the
|
||||
* right. Below 768px the pane overlays the list (mobile stack) instead of
|
||||
* squishing the columns side-by-side.
|
||||
*
|
||||
* These pages (.folder-view) are styled from styles.css, so we reference its
|
||||
* legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with
|
||||
* hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL
|
||||
* channel triplets and only work via hsl(var(--x)), so they are NOT used bare
|
||||
* here.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.sv-split {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column — the list. Flexes to fill remaining space and scrolls itself. */
|
||||
.sv-list-pane {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Right column — the reading pane. Resizable via the native handle; the width
|
||||
* gives it a sensible default, and the user can drag the bottom-right corner.
|
||||
* `resize: horizontal` needs `overflow` other than visible. */
|
||||
.sv-reading-pane {
|
||||
flex: 0 0 auto;
|
||||
width: clamp(320px, 42%, 640px);
|
||||
min-width: 300px;
|
||||
max-width: 80vw;
|
||||
border-left: 1px solid var(--panel-2, #222a3d);
|
||||
overflow: auto;
|
||||
resize: horizontal;
|
||||
background: var(--panel, #1a2030);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
/* ── Reading pane inner content ─────────────────────────────────────────── */
|
||||
.sv-detail { padding: 18px 20px; }
|
||||
|
||||
.sv-detail-topbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sv-close {
|
||||
background: transparent;
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
color: var(--muted, #8b93a7);
|
||||
border-radius: 6px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sv-close:hover {
|
||||
background: var(--panel-2, #222a3d);
|
||||
color: var(--text, #e6e9f0);
|
||||
}
|
||||
|
||||
.sv-detail-header { margin-bottom: 14px; }
|
||||
.sv-detail-subject {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text, #e6e9f0);
|
||||
word-break: break-word;
|
||||
}
|
||||
.sv-detail-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted, #8b93a7);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.sv-detail-sep { opacity: 0.4; }
|
||||
|
||||
.sv-ai-summary { margin-bottom: 14px; }
|
||||
.sv-ai-summary-text {
|
||||
background: var(--panel-2, #222a3d);
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text, #e6e9f0);
|
||||
}
|
||||
|
||||
.sv-body-loading { padding: 8px 0 18px; }
|
||||
.sv-body {
|
||||
background: var(--panel, #1a2030);
|
||||
border: 1px solid var(--panel-2, #222a3d);
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text, #e6e9f0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 18px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sv-snippet {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted, #8b93a7);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* ── List loading / empty states ───────────────────────────────────────── */
|
||||
.sv-skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.sv-skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */
|
||||
@media (max-width: 767px) {
|
||||
.sv-reading-pane {
|
||||
position: fixed;
|
||||
inset: 56px 0 0 0; /* below the topbar */
|
||||
width: 100% !important;
|
||||
max-width: 100vw;
|
||||
min-width: 0;
|
||||
border-left: none;
|
||||
resize: none;
|
||||
z-index: 40;
|
||||
}
|
||||
/* When the pane is open, hide the underlying list to avoid double-scroll. */
|
||||
.sv-split.sv-split--open .sv-list-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env sh
|
||||
# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
|
||||
# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
|
||||
# git commit --no-verify
|
||||
set -eu
|
||||
|
||||
echo "[pre-commit] running fast checks..."
|
||||
|
||||
# 1) Block obvious secrets from being committed. Scans only staged, added lines.
|
||||
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
|
||||
if [ -n "$STAGED" ]; then
|
||||
# Never allow a real .env (only *.example templates are tracked).
|
||||
echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
|
||||
if [ -s /tmp/ii_env_hits ]; then
|
||||
echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
|
||||
cat /tmp/ii_env_hits
|
||||
echo " -> add it to .gitignore or commit .env.example instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Heuristic secret scan on added lines (private keys, obvious credential assigns).
|
||||
if git diff --cached --unified=0 -- $STAGED \
|
||||
| grep -E '^\+' \
|
||||
| grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
|
||||
| grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
|
||||
echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
|
||||
cat /tmp/ii_secret_hits
|
||||
echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
|
||||
# non-mutating, so it never rewrites files out from under your staged diff.
|
||||
if echo "$STAGED" | grep -q '\.cs$'; then
|
||||
if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
|
||||
echo "[pre-commit] dotnet format --verify-no-changes"
|
||||
dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
|
||||
|| { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[pre-commit] OK"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
|
||||
# catch failures before they reach the server. Bypass with: git push --no-verify
|
||||
set -eu
|
||||
|
||||
echo "[pre-push] building solution (Release)..."
|
||||
dotnet build InboxIntel.sln -c Release --nologo \
|
||||
|| { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
|
||||
|
||||
echo "[pre-push] running tests..."
|
||||
dotnet test InboxIntel.sln -c Release --no-build --nologo \
|
||||
|| { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
|
||||
|
||||
# Frontend build (only if the app is present and npm is installed).
|
||||
if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
|
||||
echo "[pre-push] frontend build..."
|
||||
( cd frontend && npm run build --silent ) \
|
||||
|| { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
|
||||
fi
|
||||
|
||||
echo "[pre-push] OK — safe to push."
|
||||
@@ -0,0 +1,24 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Point git at the version-controlled hooks in scripts/git-hooks.
|
||||
.DESCRIPTION
|
||||
Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
|
||||
shared, updatable) instead of the un-tracked .git/hooks directory. Run once
|
||||
per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
|
||||
.EXAMPLE
|
||||
./scripts/install-hooks.ps1
|
||||
#>
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Push-Location $root
|
||||
try {
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
# Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
|
||||
# once the hooks are tracked; ignored on a first run before they're committed.
|
||||
foreach ($h in 'pre-commit','pre-push') {
|
||||
try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
|
||||
}
|
||||
Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
|
||||
Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
|
||||
}
|
||||
finally { Pop-Location }
|
||||
Reference in New Issue
Block a user