diff --git a/docs/CICD.md b/docs/CICD.md
new file mode 100644
index 0000000..9d31580
--- /dev/null
+++ b/docs/CICD.md
@@ -0,0 +1,75 @@
+# CI/CD Flow
+
+How a change travels from a local branch to TestFlight, and where the gates are.
+
+```mermaid
+flowchart TD
+ A["feature/* branch · local
pre-push hook blocks main"]
+ B["Pull request → develop
develop is the default branch"]
+ C["CI · ci.yml (macOS runner)
xcodebuild build + test"]
+ D{"Merge gate
CI green + 1 approval"}
+ E["Pull request develop → main
same gate"]
+ F["Release · release.yml
archive → IPA → TestFlight"]
+
+ A -->|git push · open PR| B
+ B -->|PR triggers CI| C
+ C -->|status check: build-and-test| D
+ D -->|promote when ready| E
+ E -->|on merge to main| F
+```
+
+## Steps
+
+1. **`feature/*` branch (local).** Branch off `develop`. A local
+ `.git/hooks/pre-push` hook rejects any direct push to `main`, so nothing
+ reaches the protected branch outside a PR.
+
+2. **PR → `develop`.** `develop` is the default branch and the integration
+ target for all feature work.
+
+3. **CI runs** — [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) on the
+ self-hosted **macOS runner**. It runs `xcodebuild build` then
+ `xcodebuild test` (the `KisaniCalTests` target on an iOS simulator). The
+ result is published as a commit status named **`build-and-test`**.
+
+4. **Merge gate.** Gitea branch protection blocks the merge until:
+ - the `build-and-test` status check is **green**,
+ - there is **≥ 1 approval**,
+ - there are no rejected reviews, and
+ - the branch is up to date with its base.
+
+ The same gate guards both `develop` and `main`.
+
+5. **Promote `develop` → `main`** via a second PR when a release is ready. It
+ passes through the identical gate.
+
+6. **Release runs** — [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml)
+ fires **only** on push to `main` (i.e. after a PR merges). It archives,
+ exports an IPA via [`ExportOptions.plist`](../ExportOptions.plist), and
+ uploads to **TestFlight** (currently a commented-out placeholder awaiting the
+ App Store Connect API-key secrets).
+
+## Two automation halves
+
+- **CI** (steps 3–4) gates **every** PR into `main`/`develop`.
+- **Release** (step 6) runs **only** on `main`.
+
+Blue/manual steps are actions you take; CI and Release run unattended on the
+runner.
+
+## Requirements & gotchas
+
+- **Runner labels.** Both workflows use `runs-on: [self-hosted, macOS]`. The
+ registered runner must carry **both** labels or jobs queue forever. Check at
+ Settings → Actions → Runners.
+- **Status-check name.** Branch protection requires a check literally named
+ `build-and-test` (the CI job name). If the rendered context differs, update
+ the rule (`scripts/gitea-setup.sh`, `STATUS_CONTEXT`).
+- **Simulator.** The runner needs an installed iOS runtime (Xcode → Settings →
+ Components) — CI auto-selects the first available iPhone simulator.
+- **Signing.** CI builds with `CODE_SIGNING_ALLOWED=NO`. The Release job needs a
+ distribution cert + profile in the runner's login keychain.
+- **Build number.** `CURRENT_PROJECT_VERSION` in `project.yml` must increase for
+ every TestFlight upload — App Store Connect rejects a repeated build number.
+
+See also [CONTRIBUTING.md](../CONTRIBUTING.md) for the branch workflow.