diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..8706419
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+# Build + test on every PR into main or develop (and on direct pushes to develop).
+on:
+ pull_request:
+ branches: [main, develop]
+ push:
+ branches: [develop]
+
+# ⚠️ RUNNER LABELS — must match how your Mac runner was registered with act_runner.
+# Check your runner's labels in Gitea: Settings → Actions → Runners (click the runner).
+# If it registered as e.g. `macos` or `self-hosted`, change `runs-on` below to match.
+# `[self-hosted, macOS]` means "a runner that has BOTH labels".
+jobs:
+ build-and-test:
+ runs-on: [self-hosted, macos]
+
+ env:
+ SCHEME: KisaniCal
+ PROJECT: KisaniCal.xcodeproj
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # The .xcodeproj is generated from project.yml via XcodeGen.
+ # Regenerate it so CI never builds a stale project. No-op if xcodegen isn't installed.
+ - name: Regenerate Xcode project (XcodeGen)
+ run: |
+ if command -v xcodegen >/dev/null 2>&1; then
+ xcodegen generate
+ else
+ echo "xcodegen not found on runner — using committed ${PROJECT}."
+ echo "Install with: brew install xcodegen"
+ fi
+
+ - name: Show toolchain
+ run: |
+ xcodebuild -version
+ xcrun simctl list runtimes | grep -i ios || true
+
+ # Pick a concrete, bootable iOS Simulator (needed for `xcodebuild test`).
+ - name: Select iOS Simulator destination
+ id: sim
+ run: |
+ # First available iPhone simulator on any installed iOS runtime.
+ UDID=$(xcrun simctl list devices available -j | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; devs=[x for k,v in d.items() if re.search('iOS',k) for x in v if x.get('isAvailable') and 'iPhone' in x['name']]; print(devs[0]['udid'] if devs else '')")
+ if [ -z "$UDID" ]; then
+ echo "No available iPhone simulator found on the runner." >&2
+ echo "Install an iOS runtime via Xcode → Settings → Components." >&2
+ exit 1
+ fi
+ echo "udid=$UDID" >> "$GITHUB_OUTPUT"
+ echo "Using simulator UDID: $UDID"
+
+ - name: Build
+ run: |
+ set -o pipefail
+ xcodebuild build \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO \
+ | xcbeautify || xcodebuild build \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Test
+ run: |
+ set -o pipefail
+ xcodebuild test \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO \
+ | xcbeautify || xcodebuild test \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Debug \
+ -destination "id=${{ steps.sim.outputs.udid }}" \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..f233725
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,86 @@
+name: Release
+
+# Runs only when code lands on main (i.e. after a PR is merged).
+on:
+ push:
+ branches: [main]
+
+# ⚠️ Same runner-label caveat as ci.yml — adjust `runs-on` to match your registered runner.
+jobs:
+ archive-and-export:
+ runs-on: [self-hosted, macos]
+
+ env:
+ SCHEME: KisaniCal
+ PROJECT: KisaniCal.xcodeproj
+ # A real distribution build MUST be signed. This requires an Apple
+ # Distribution certificate + provisioning profile installed in the
+ # runner's login keychain (see notes at the bottom of this file).
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Regenerate Xcode project (XcodeGen)
+ run: |
+ if command -v xcodegen >/dev/null 2>&1; then xcodegen generate; fi
+
+ - name: Archive
+ run: |
+ set -o pipefail
+ xcodebuild archive \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
+ -allowProvisioningUpdates
+
+ - name: Export IPA
+ run: |
+ set -o pipefail
+ xcodebuild -exportArchive \
+ -archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
+ -exportOptionsPlist ExportOptions.plist \
+ -exportPath "$RUNNER_TEMP/export" \
+ -allowProvisioningUpdates
+ ls -la "$RUNNER_TEMP/export"
+
+ # ------------------------------------------------------------------
+ # PLACEHOLDER: Upload to TestFlight / App Store Connect.
+ #
+ # Add these as Gitea repo secrets: Settings → Actions → Secrets
+ # APP_STORE_CONNECT_API_KEY_ID (the "Key ID" from App Store Connect)
+ # APP_STORE_CONNECT_ISSUER_ID (the "Issuer ID")
+ # APP_STORE_CONNECT_API_KEY_P8 (contents of the AuthKey_XXXX.p8 file)
+ #
+ # Then uncomment ONE of the approaches below.
+ # ------------------------------------------------------------------
+
+ # --- Option A: modern notarytool / App Store Connect API key (recommended) ---
+ # - name: Write API key to file
+ # run: |
+ # mkdir -p ~/private_keys
+ # echo "${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}" > ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8
+ #
+ # - name: Upload to TestFlight (altool with API key)
+ # run: |
+ # xcrun altool --upload-app \
+ # --type ios \
+ # --file "$RUNNER_TEMP/export/KisaniCal.ipa" \
+ # --apiKey "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
+ # --apiIssuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}"
+
+ # --- Option B: notarytool (for notarizing a macOS build, not iOS TestFlight) ---
+ # - name: Notarize
+ # run: |
+ # xcrun notarytool submit "$RUNNER_TEMP/export/KisaniCal.ipa" \
+ # --key ~/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}.p8 \
+ # --key-id "${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}" \
+ # --issuer "${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}" \
+ # --wait
+
+ - name: Notice
+ run: |
+ echo "IPA exported. TestFlight upload step is a commented-out placeholder."
+ echo "Add App Store Connect API-key secrets and uncomment Option A in release.yml."
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3aea7bd
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,61 @@
+# Contributing to KisaniCal
+
+## Branch workflow
+
+```
+feature/* ─PR→ develop ─PR→ main
+```
+
+- **`main`** — protected, release-ready. Never commit or push directly. Every
+ change arrives via a reviewed pull request. Pushes to `main` trigger the
+ release workflow (archive → IPA → TestFlight).
+- **`develop`** — the default working branch and integration target. CI runs on
+ every PR into it.
+- **`feature/*`** — short-lived branches for a single change, e.g.
+ `feature/voice-quick-add`, `fix/icloud-restore`. Branch off `develop`.
+
+## Day-to-day
+
+```bash
+git checkout develop
+git pull
+git checkout -b feature/my-change
+
+# ...work, commit...
+
+git push -u origin feature/my-change
+# then open a PR in Gitea: feature/my-change → develop
+```
+
+When `develop` is ready to ship, open a PR **`develop` → `main`**.
+
+## Pull request requirements
+
+A PR into `main` (and `develop`) cannot be merged until:
+
+1. **CI passes** — the `build-and-test` job in `.gitea/workflows/ci.yml` builds
+ the app and runs `KisaniCalTests` on an iOS Simulator.
+2. **At least 1 approval** — reviewed and approved.
+3. **No unresolved change requests** — a rejected review blocks the merge.
+4. Your branch is **up to date** with the base branch.
+
+Direct pushes, force-pushes, and deletion of `main` are blocked server-side by
+Gitea branch protection, and a local `pre-push` hook blocks accidental direct
+pushes to `main` from this clone.
+
+## CI / CD
+
+- **CI** (`.gitea/workflows/ci.yml`) — runs on every PR into `main`/`develop`
+ and on pushes to `develop`. Builds + tests. A red check blocks the merge.
+- **Release** (`.gitea/workflows/release.yml`) — runs only on push to `main`
+ (i.e. after a PR merges). Archives, exports an IPA via `ExportOptions.plist`,
+ and (once configured) uploads to TestFlight.
+
+Both workflows run on a **self-hosted macOS runner** with Xcode — there is no
+Gitea-hosted Mac runner.
+
+## Versioning
+
+`MARKETING_VERSION` (e.g. `2.0`) and `CURRENT_PROJECT_VERSION` (build number)
+live in `project.yml`. The build number must **increase** for every TestFlight
+upload — App Store Connect rejects a build number it has already seen.
diff --git a/ExportOptions.plist b/ExportOptions.plist
new file mode 100644
index 0000000..7bd125e
--- /dev/null
+++ b/ExportOptions.plist
@@ -0,0 +1,45 @@
+
+
+
+
+
+ method
+ app-store
+
+ teamID
+ K8BLMMR883
+
+ signingStyle
+ automatic
+
+ uploadSymbols
+
+
+ uploadBitcode
+
+
+
+
+
diff --git a/scripts/gitea-setup.sh b/scripts/gitea-setup.sh
new file mode 100755
index 0000000..579409d
--- /dev/null
+++ b/scripts/gitea-setup.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#
+# One-shot Gitea setup for KisaniCal:
+# 1. Creates the `develop` branch on the server (from main) if missing.
+# 2. Sets `develop` as the repo's default branch.
+# 3. Adds branch protection on `main` (require PR + 1 approval + status check,
+# block direct pushes / force pushes / deletion).
+#
+# USAGE:
+# export GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxx # a Gitea access token with repo scope
+# ./scripts/gitea-setup.sh
+#
+# Create the token in Gitea: click your avatar → Settings → Applications →
+# "Generate New Token" (scopes: at least write:repository ).
+#
+set -euo pipefail
+
+GITEA_URL="http://10.10.1.21:3002"
+OWNER="kutesir"
+REPO="KisaniCal"
+
+# The commit-status context that must pass before merge into main.
+# For Gitea Actions this is the JOB name from ci.yml ("build-and-test").
+# After your first CI run, confirm the exact context string at:
+# ${GITEA_URL}/${OWNER}/${REPO}/commits (hover the check) and adjust if needed.
+STATUS_CONTEXT="build-and-test"
+
+: "${GITEA_TOKEN:?Set GITEA_TOKEN env var first (see header).}"
+
+API="${GITEA_URL}/api/v1"
+AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json")
+
+echo "==> 1/3 Ensuring 'develop' branch exists on server..."
+if curl -fsS "${AUTH[@]}" "${API}/repos/${OWNER}/${REPO}/branches/develop" >/dev/null 2>&1; then
+ echo " develop already exists."
+else
+ curl -fsS -X POST "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}/branches" \
+ -d '{"new_branch_name":"develop","old_branch_name":"main"}' >/dev/null
+ echo " created develop from main."
+fi
+
+echo "==> 2/3 Setting default branch to 'develop'..."
+curl -fsS -X PATCH "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}" \
+ -d '{"default_branch":"develop"}' >/dev/null
+echo " default branch = develop."
+
+echo "==> 3/3 Applying branch protection on 'main'..."
+# If a rule already exists this POST returns 409; we then PUT-update it.
+PROTECT_PAYLOAD=$(cat </dev/null 2>&1; then
+ echo " created protection rule for main."
+else
+ echo " rule may already exist — updating it..."
+ curl -fsS -X PATCH "${AUTH[@]}" \
+ "${API}/repos/${OWNER}/${REPO}/branch_protections/main" \
+ -d "${PROTECT_PAYLOAD}" >/dev/null
+ echo " updated protection rule for main."
+fi
+
+echo ""
+echo "Done. Note: Gitea automatically blocks force-pushes and deletion of a"
+echo "protected branch, so 'main' is now safe from both."