diff --git a/.env.staging.example b/.env.staging.example new file mode 100644 index 0000000..c893f60 --- /dev/null +++ b/.env.staging.example @@ -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 diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..669ca89 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -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; } diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..d38cf36 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -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" diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml new file mode 100644 index 0000000..fce1e4f --- /dev/null +++ b/.gitea/workflows/security.yml @@ -0,0 +1,54 @@ +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 the binary directly — the container-mode runner has no Docker socket, + # so `docker run` isn't available inside a job. + run: | + GITLEAKS_VERSION=8.18.4 + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o /tmp/gitleaks.tar.gz + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + /tmp/gitleaks detect --source=. --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) + # Match dotnet's own "has the following vulnerable packages" line rather + # than raw severity words, so package/project names can't false-positive. + run: | + dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt + if grep -q "has the following vulnerable" 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 (production deps, fail on high/critical) + # Only production dependencies ship to users; dev-only toolchain advisories + # (Vite/PostCSS/etc.) shouldn't block a merge. + working-directory: frontend + run: npm audit --omit=dev --audit-level=high diff --git a/.gitignore b/.gitignore index ec071ac..43dea55 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ lpt[1-9].* *.code-workspace .env.* !.env.example +!.env.staging.example .next/ dist/ build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..55eacfa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# 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] +### Security +- Patched all High/Moderate NuGet advisories the new dependency gate surfaced: + `System.Text.Json` 8.0.0→8.0.6, `Microsoft.Extensions.Caching.Memory` 8.0.0→8.0.1 + (+ `DependencyInjection.Abstractions`→8.0.2), `System.Security.Cryptography.Xml` + 8.0.1→8.0.3 (transitive pins), and `MailKit`/`MimeKit` 4.13.0→4.17.0. + +### 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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/deploy/down.ps1 b/deploy/down.ps1 index deb704e..403c073 100644 --- a/deploy/down.ps1 +++ b/deploy/down.ps1 @@ -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' +$root = Split-Path -Parent $PSScriptRoot -$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 diff --git a/deploy/up.ps1 b/deploy/up.ps1 index a6dfd5b..46f55b8 100644 --- a/deploy/up.ps1 +++ b/deploy/up.ps1 @@ -8,15 +8,28 @@ #> 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' +$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down) -if (-not (Test-Path $envFile)) { - throw "Missing $envFile. Create it from .env.example with your real secrets." +# 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. @@ -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 - 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 + & 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$(if($Staging){' -Staging'})" -ForegroundColor DarkGray } } finally { Pop-Location } diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..be62449 --- /dev/null +++ b/docker-compose.staging.yml @@ -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" diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md new file mode 100644 index 0000000..de116fc --- /dev/null +++ b/docs/WORKFLOW.md @@ -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. diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..4e8ca9e --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -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" diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 0000000..0827fc1 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -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." diff --git a/scripts/install-hooks.ps1 b/scripts/install-hooks.ps1 new file mode 100644 index 0000000..122b037 --- /dev/null +++ b/scripts/install-hooks.ps1 @@ -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 } diff --git a/src/InboxIntel.Application/InboxIntel.Application.csproj b/src/InboxIntel.Application/InboxIntel.Application.csproj index cf93528..ed04e5a 100644 --- a/src/InboxIntel.Application/InboxIntel.Application.csproj +++ b/src/InboxIntel.Application/InboxIntel.Application.csproj @@ -6,10 +6,13 @@ - + + + + diff --git a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj index 01875f8..37f6a69 100644 --- a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj +++ b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj @@ -19,7 +19,13 @@ - + + + + +