diff --git a/AGENTS.md b/AGENTS.md
index 7aa02cd..871e789 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -57,6 +57,9 @@ Read these files to understand the project setup, conventions, and development w
- `README.md` - user-facing usage, API reference table, installation
- `CONTRIBUTING.md` - development setup, workflow, coding conventions
+- `docs/architecture.md` - system architecture, reconciliation flow, component interactions
+- `docs/release.md` - release process, branching model, versioning
- `docs/development/` - developer guides (e.g. Gitea integration, test patterns)
+- `docs/plans/` - design documents capturing rationale and tradeoffs for past decisions
After implementing a feature or making significant changes, check whether these docs need updating. The API reference table in README.md must stay in sync with `api/v1alpha1/function_types.go`.
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7f125a9..30756e3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,6 +17,12 @@ If you find a bug or have a feature request, please [open an issue](https://gith
All CI checks must pass before a pull request can be merged.
+## Further Reading
+
+- [Architecture Overview](docs/architecture.md) — system components, reconciliation flow, CRD lifecycle
+- [Release Process](docs/release.md) — branching model, versioning, automated tag management
+- [Gitea Integration](docs/development/gitea-integration.md) — e2e test infrastructure details
+
## Development
### Prerequisites
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..afd40f5
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,114 @@
+# Architecture Overview
+
+## What This Operator Does
+
+func-operator is a Kubernetes operator that monitors serverless functions deployed with the [Knative `func` CLI](https://github.com/knative/func). Its primary job is **middleware lifecycle management**: it detects when a function's middleware is outdated and automatically rebuilds the function using the latest version.
+
+The operator does **not** handle initial deployment. Functions must first be deployed with `func deploy`. The operator then takes over ongoing maintenance.
+
+## Components
+
+- **FunctionReconciler** (`internal/controller/`) — The central controller. Watches `Function` custom resources and reconciles them. Also watches the `func-operator-controller-config` ConfigMap to re-reconcile functions when the operator-wide `autoUpdateMiddleware` default changes. Runs up to 10 concurrent reconciliations.
+
+- **FuncCliManager** (`internal/funccli/`) — Wraps the Knative `func` CLI binary. Periodically checks GitHub for new releases and downloads them (with SHA256 checksum verification and atomic install). Runs `func deploy`, `func describe`, and `func version` as subprocesses. The download logic (`DownloadAndInstall`) is shared with e2e test utilities via `internal/funccli/download.go`.
+
+- **GitManager** (`internal/git/`) — Clones function source repositories with authentication support: HTTP/HTTPS (token or basic auth) and SSH (private key with optional passphrase and known_hosts). Uses go-git for pure-Go shallow cloning (single-branch, depth 1).
+
+- **StatusTracker** (`internal/controller/status_tracker.go`) — Buffers status changes during reconciliation and persists them in a single API call at the end via `Flush()`. Supports mid-reconcile flushes for long-running operations (e.g., before a deployment starts) so users see progress.
+
+## CRD: Function
+
+Defined in `api/v1alpha1/function_types.go`. A `Function` resource represents a deployed serverless function that the operator should monitor.
+
+**Spec** (user-provided):
+- `repository.url` — Git repository containing the function source
+- `repository.branch` — Branch to track (optional, defaults to repo default)
+- `repository.path` — Subdirectory within the repo (for monorepos)
+- `repository.authSecretRef` — Secret for private repo authentication
+- `registry.authSecretRef` — Secret for container registry authentication
+- `autoUpdateMiddleware` — Override operator default (optional)
+
+**Status** (operator-managed):
+- `git` — Resolved branch, observed commit, last check time
+- `deployment` — Current image, build time, deployer, runtime
+- `middleware` — Current/available versions, auto-update config, rebuild state
+- `service` — URL and readiness of the underlying Knative Service
+- `conditions` — Standard Kubernetes conditions (see below)
+- `history` — Last 20 reconciliation events
+
+## Reconciliation Flow
+
+```mermaid
+flowchart TD
+ start["Reconcile()"] --> get["Get Function CR"]
+ get -->|Not found| ignore["Exit — already deleted"]
+ get -->|Found| tracker["Create StatusTracker"]
+ tracker --> prepare
+
+ subgraph prepare ["prepareSource()"]
+ fetchsecret["Fetch auth secret
(if configured)"]
+ fetchsecret --> clone["Clone git repository"]
+ clone --> readmeta["Read func.yaml metadata"]
+ readmeta --> sourceready["Mark SourceReady"]
+ end
+
+ prepare --> describe["func describe
(check if deployed)"]
+ describe -->|Not deployed| notdeployed["Mark NotDeployed
Return"]
+ describe -->|Deployed| checkMW["Check middleware version"]
+
+ checkMW -->|Up to date| uptodate["Update status
Mark MiddlewareUpToDate"]
+ checkMW -->|Outdated| autocheck{"Auto-update
enabled?"}
+
+ autocheck -->|No| skip["Mark as intentionally
not updated"]
+ autocheck -->|Yes| flush["Flush status
(mid-reconcile)"]
+ flush --> deploy["func deploy --remote
(rebuild via Tekton)"]
+ deploy --> updatedesc["func describe
(get new state)"]
+ updatedesc --> updated["Update status
Mark MiddlewareUpToDate"]
+
+ uptodate --> cleanup["Remove func annotations"]
+ skip --> cleanup
+ updated --> cleanup
+ cleanup --> flushfinal["StatusTracker.Flush()
Calculate Ready, persist"]
+```
+
+### Conditions
+
+The operator maintains five conditions on each Function:
+
+| Condition | Meaning |
+|-----------|---------|
+| `Ready` | Overall health (calculated from all other conditions) |
+| `SourceReady` | Git clone and metadata read succeeded |
+| `Deployed` | Function exists in the cluster |
+| `MiddlewareUpToDate` | Function uses the latest middleware version |
+| `ServiceReady` | Underlying Knative Service is ready |
+
+`Ready` is automatically calculated: it is `True` only when all other conditions are `True`.
+
+## Deployment and Build
+
+The operator uses **Tekton Pipelines** for building function images. During `func deploy --remote`, the func CLI creates a Tekton PipelineRun in the function's namespace. The operator sets up the necessary RBAC (Role + RoleBinding) for the pipeline to run.
+
+Supported builders: `pack` (Cloud Native Buildpacks) and `s2i` (Source-to-Image).
+
+Supported deployers: `knative` (Knative Serving) and `raw` (plain Kubernetes Deployment), with experimental `keda` support (HTTP-based autoscaling).
+
+## Configuration
+
+The operator reads its default config from a ConfigMap named `func-operator-controller-config` in the operator's namespace:
+
+| Key | Description | Default |
+|-----|-------------|---------|
+| `autoUpdateMiddleware` | Whether to auto-rebuild functions with outdated middleware | `true` |
+
+Per-function overrides are possible via `spec.autoUpdateMiddleware` on the Function CR.
+
+## E2E Test Infrastructure
+
+E2E tests run in a KinD cluster with:
+- **Gitea** (in-cluster Git server) for complete test isolation
+- **Local container registry** with self-signed TLS
+- **Tekton Pipelines** for build execution
+- **Knative Serving** (or raw/keda deployer depending on test matrix)
+
+See [Gitea Integration](development/gitea-integration.md) for details on the test infrastructure.
\ No newline at end of file
diff --git a/docs/plans/2026-03-13-gitea-e2e-integration-design.md b/docs/plans/2026-03-13-gitea-e2e-integration-design.md
new file mode 100644
index 0000000..d4027ec
--- /dev/null
+++ b/docs/plans/2026-03-13-gitea-e2e-integration-design.md
@@ -0,0 +1,375 @@
+# Gitea E2E Integration Design
+
+**Date:** 2026-03-13
+**Status:** Approved
+
+## Overview
+
+This design integrates Gitea into our e2e test infrastructure to eliminate dependency on GitHub for test function repositories. Tests will use an in-cluster Gitea instance for all git operations, providing complete isolation and enabling testing of private repositories and various authentication methods.
+
+## Goals
+
+- Remove GitHub dependency from e2e tests
+- Enable per-test isolation with dedicated git repositories
+- Support testing private repositories with token authentication
+- Provide infrastructure for future SSH authentication testing
+- Keep test code focused on operator logic, not git infrastructure
+
+## Architecture
+
+The integration consists of three layers:
+
+### 1. Infrastructure Layer
+Cluster setup with Gitea installation and configuration
+
+### 2. Utilities Layer
+Go client and helpers for managing Gitea resources
+
+### 3. Test Integration Layer
+Updates to existing e2e tests to use Gitea instead of GitHub
+
+## Infrastructure Layer
+
+### Gitea Installation (`hack/create-kind-cluster.sh`)
+
+Update the existing but disabled `install_gitea()` function:
+
+```bash
+function install_gitea() {
+ header_text "Installing Gitea"
+
+ helm repo add gitea-charts https://dl.gitea.com/charts/
+ helm install gitea gitea-charts/gitea --namespace gitea --create-namespace \
+ --set service.http.type=NodePort \
+ --set service.http.nodePort=30000 \
+ --set service.ssh.type=NodePort \
+ --set service.ssh.nodePort=30022 \
+ --set gitea.admin.username=giteaadmin \
+ --set gitea.admin.password=giteapass \
+ --set gitea.admin.email=admin@gitea.local \
+ --set persistence.enabled=false
+
+ header_text "Waiting for Gitea to become ready"
+ kubectl wait deployment --all --timeout=-1s --for=condition=Available --namespace gitea
+
+ # Get Gitea endpoint for tests
+ GITEA_NODE_IP=$(docker inspect kind-control-plane --format '{{.NetworkSettings.Networks.kind.IPAddress}}')
+
+ # Create ConfigMap with Gitea endpoint info
+ kubectl apply -f - <:30000`
+- **Why this works:**
+ - Kind node IP is reachable from host (via Docker networking)
+ - Same IP is reachable from pods inside cluster (node IPs)
+ - No port-forwarding or DNS tricks needed
+ - Single consistent address for tests and operator
+
+## Utilities Layer
+
+### Dependencies
+
+Add to `go.mod`:
+```
+code.gitea.io/sdk/gitea v0.x.x
+```
+
+### GiteaClient Structure (`test/utils/gitea.go`)
+
+```go
+package utils
+
+import (
+ "fmt"
+ "os/exec"
+
+ "code.gitea.io/sdk/gitea"
+ "k8s.io/apimachinery/pkg/util/rand"
+)
+
+type GiteaClient struct {
+ client *gitea.Client
+ baseURL string // http://172.18.0.2:30000
+ adminUser string
+ adminPass string
+}
+
+// NewGiteaClient discovers Gitea endpoint from ConfigMap and creates client
+func NewGiteaClient() (*GiteaClient, error) {
+ // Read gitea-endpoint ConfigMap from kube-public namespace
+ // Extract baseURL from data.http
+ // Create Gitea SDK client with admin credentials
+ // Return initialized GiteaClient
+}
+
+// User management
+func (g *GiteaClient) CreateUser(username, password, email string) error
+
+func (g *GiteaClient) CreateRandomUser() (username, password, email string, err error) {
+ username = "user-" + rand.String(8)
+ password = "pass-" + rand.String(8)
+ email = username + "@test.local"
+
+ err = g.CreateUser(username, password, email)
+ return username, password, email, err
+}
+
+func (g *GiteaClient) DeleteUser(username string) error
+
+// Repository management
+func (g *GiteaClient) CreateRepo(owner, name string, private bool) (string, error) {
+ // Returns repository URL: http://172.18.0.2:30000/owner/name.git
+}
+
+func (g *GiteaClient) CreateRandomRepo(owner string, private bool) (name, url string, err error) {
+ name = "repo-" + rand.String(8)
+ url, err = g.CreateRepo(owner, name, private)
+ return name, url, err
+}
+
+func (g *GiteaClient) DeleteRepo(owner, name string) error
+
+// Token authentication
+func (g *GiteaClient) CreateAccessToken(username, password, tokenName string) (string, error)
+```
+
+### Helper Functions (`test/e2e/gitea_helpers.go`)
+
+```go
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/functions-dev/func-operator/test/utils"
+ "k8s.io/apimachinery/pkg/util/rand"
+)
+
+// buildAuthURL embeds credentials into git URL
+func buildAuthURL(repoURL, username, password string) string {
+ return strings.Replace(repoURL, "http://",
+ fmt.Sprintf("http://%s:%s@", username, password), 1)
+}
+
+// InitializeRepoWithFunction clones an empty Gitea repo, initializes a function, and pushes it
+func InitializeRepoWithFunction(repoURL, username, password, language string) (repoDir string, err error) {
+ repoDir = fmt.Sprintf("%s/func-test-%s", os.TempDir(), rand.String(10))
+
+ // Build authenticated URL
+ authURL := buildAuthURL(repoURL, username, password)
+
+ // Clone empty repo
+ cmd := exec.Command("git", "clone", authURL, repoDir)
+ if _, err = utils.Run(cmd); err != nil {
+ return "", err
+ }
+
+ // Initialize function
+ cmd = exec.Command("func", "init", "-l", language)
+ cmd.Dir = repoDir
+ if _, err = utils.Run(cmd); err != nil {
+ return "", err
+ }
+
+ // Commit and push
+ exec.Command("git", "-C", repoDir, "add", ".").Run()
+ exec.Command("git", "-C", repoDir, "commit", "-m", "Initial function").Run()
+ exec.Command("git", "-C", repoDir, "push").Run()
+
+ return repoDir, nil
+}
+
+// CommitAndPushFuncYaml commits and pushes func.yaml changes after deployment
+func CommitAndPushFuncYaml(repoDir string) error {
+ exec.Command("git", "-C", repoDir, "add", "func.yaml").Run()
+ exec.Command("git", "-C", repoDir, "commit", "-m", "Update func.yaml after deploy").Run()
+ return exec.Command("git", "-C", repoDir, "push").Run()
+}
+```
+
+## Test Integration Layer
+
+### Suite Setup (`test/e2e/e2e_suite_test.go`)
+
+Add package-level variable:
+```go
+var (
+ k8sClient client.Client
+ ctx context.Context
+
+ registry string
+ registryInsecure bool
+
+ giteaClient *utils.GiteaClient // NEW
+)
+```
+
+Update BeforeSuite:
+```go
+var _ = BeforeSuite(func() {
+ ctx = context.Background()
+
+ // ... existing k8sClient setup ...
+
+ // ... existing registry setup ...
+
+ // Initialize Gitea client
+ var err error
+ giteaClient, err = utils.NewGiteaClient()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(giteaClient).NotTo(BeNil())
+})
+```
+
+### Test Pattern
+
+Standard pattern for tests using Gitea:
+
+```go
+var _ = Describe("Test Suite", func() {
+ var (
+ username string
+ password string
+ repoName string
+ repoURL string
+ repoDir string
+ )
+
+ BeforeEach(func() {
+ var err error
+
+ // Create random user
+ username, password, _, err = giteaClient.CreateRandomUser()
+ Expect(err).NotTo(HaveOccurred())
+
+ // Create random repo
+ repoName, repoURL, err = giteaClient.CreateRandomRepo(username, false)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Initialize with function code
+ repoDir, err = InitializeRepoWithFunction(repoURL, username, password, "go")
+ Expect(err).NotTo(HaveOccurred())
+
+ // Deploy function (example)
+ cmd := exec.Command("func", "deploy",
+ "--path", repoDir,
+ "--registry", registry,
+ "--registry-insecure", strconv.FormatBool(registryInsecure))
+ out, err := utils.Run(cmd)
+ Expect(err).NotTo(HaveOccurred())
+
+ // Commit func.yaml changes
+ CommitAndPushFuncYaml(repoDir)
+ })
+
+ AfterEach(func() {
+ // Cleanup
+ os.RemoveAll(repoDir)
+ giteaClient.DeleteRepo(username, repoName)
+ giteaClient.DeleteUser(username)
+ })
+
+ It("should do something", func() {
+ // Test uses repoURL in Function spec
+ function := &functionsdevv1alpha1.Function{
+ Spec: functionsdevv1alpha1.FunctionSpec{
+ Source: functionsdevv1alpha1.FunctionSpecSource{
+ RepositoryURL: repoURL,
+ },
+ // ...
+ },
+ }
+ // ...
+ })
+})
+```
+
+### Updates to Existing Tests
+
+**`test/e2e/func_deploy_test.go`:**
+- Replace `git clone https://github.com/creydr/func-go-hello-world` with Gitea workflow
+- Replace hardcoded `RepositoryURL: "https://github.com/..."` with `repoURL`
+- Add cleanup for Gitea resources in AfterEach
+
+**`test/e2e/bundle_test.go`:**
+- Update `CreateFunctionAndWaitForReady()` to accept `repoURL` parameter
+- Update `createNamespaceAndDeployFunction()` to use Gitea workflow
+- Replace GitHub URLs throughout
+
+## Implementation Steps
+
+1. **Update cluster setup script**
+ - Enable `install_gitea()` function
+ - Add call to main flow
+ - Test cluster creation end-to-end
+
+2. **Create utilities layer**
+ - Add Gitea SDK dependency
+ - Implement `test/utils/gitea.go`
+ - Implement `test/e2e/gitea_helpers.go`
+
+3. **Update test suite**
+ - Add giteaClient initialization in BeforeSuite
+ - Test basic connectivity and operations
+
+4. **Migrate tests**
+ - Update `func_deploy_test.go`
+ - Update `bundle_test.go`
+ - Run e2e tests to verify
+
+5. **Documentation**
+ - Update README with Gitea information
+ - Document how to access Gitea UI during development
+
+## Benefits
+
+1. **No External Dependencies:** Tests run without internet or GitHub access
+2. **Complete Isolation:** Each test gets fresh user and repository
+3. **Faster Tests:** No network latency to external services
+4. **Reproducible:** Identical Gitea state on every test run
+5. **Extended Testing:** Ready for private repos, SSH, multiple auth methods
+6. **Self-Contained:** All test infrastructure in the cluster
+
+## Future Enhancements
+
+Once basic integration is complete, we can add:
+
+1. **SSH Key Authentication**
+ - `GenerateSSHKeyPair()` method
+ - `AddSSHKey()` method
+ - SSH URL helper functions
+
+2. **Advanced Git Scenarios**
+ - Multiple branches
+ - Tags and releases
+ - Submodules
+ - Webhooks
+
+3. **Performance Testing**
+ - Test with large repositories
+ - Concurrent git operations
+
+4. **Failure Scenarios**
+ - Invalid credentials
+ - Repository permissions
+ - Network failures
\ No newline at end of file
diff --git a/docs/plans/2026-04-01-middleware-update-e2e-test-design.md b/docs/plans/2026-04-01-middleware-update-e2e-test-design.md
new file mode 100644
index 0000000..8c548c0
--- /dev/null
+++ b/docs/plans/2026-04-01-middleware-update-e2e-test-design.md
@@ -0,0 +1,103 @@
+# E2E Test for Middleware Update Design
+
+**Date:** 2026-04-01
+
+## Overview
+
+Add e2e test to verify that the operator updates middleware when it detects a function was deployed with an old version. The operator automatically detects version mismatch by comparing against the currently installed func CLI version on the operator pod.
+
+## Implementation Plan
+
+### 1. Create func CLI Wrapper Functions
+
+**File:** `test/utils/func.go`
+
+**Two functions:**
+
+1. **RunFunc(args ...string) (string, error)**
+ - Executes func CLI with current/latest version
+ - Wraps `exec.Command("func", args...)`
+ - Uses existing `Run()` helper for consistent output handling
+ - Returns combined stdout/stderr and error
+
+2. **RunFuncWithVersion(version string, args ...string) (string, error)**
+ - Executes func CLI with a specific version
+ - Downloads and caches the specified version if not already cached
+ - Same return signature for consistency
+
+**Version caching:**
+- Cache location: `/bin/func-cli/`
+- Directory structure: `bin/func-cli/v1.20.0/func`, etc.
+- Each version in its own subdirectory
+- Already covered by `bin/` gitignore pattern
+
+**Download logic:**
+1. Get project directory using `GetProjectDir()`
+2. Check if `/bin/func-cli//func` exists
+3. If exists → use it
+4. If not exists:
+ - Create `bin/func-cli//` directory
+ - Download binary directly from: `https://github.com/knative/func/releases/download//func__`
+ - Write to `bin/func-cli//func`
+ - Make executable (`chmod +x`)
+5. Execute with provided args
+
+**Platform detection:** Use `runtime.GOOS` and `runtime.GOARCH`
+
+**Error handling:**
+- Clear errors for missing versions: "failed to download func v1.20.0: HTTP 404"
+- Clear errors for download/write failures
+- Preserve func CLI error output
+
+### 2. Refactor Existing Tests (Separate Commit)
+
+Replace all `exec.Command("func", ...)` calls with `utils.RunFunc(...)`:
+- `func_deploy_test.go` line 66-71 (deploy command)
+- `func_deploy_test.go` line 77 (delete command)
+- `git.go` line 42 in `InitializeRepoWithFunction` (init command)
+
+### 3. Add New Middleware Update Test (Separate Commit)
+
+**File:** `test/e2e/func_middleware_update_test.go`
+
+**Test flow:**
+
+1. **Setup:**
+ - Create repository provider resources (user, repo)
+ - Initialize repo with function code using `InitializeRepoWithFunction`
+ - Create test namespace
+
+2. **Deploy with old func CLI:**
+ - Use `RunFuncWithVersion("v1.20.0", "deploy", "--namespace", ns, "--path", repoDir, ...)`
+ - Creates initial deployment with old middleware
+ - Commit func.yaml changes to git
+
+3. **Create Function CR:**
+ - Create Function resource pointing to repository
+ - Triggers operator reconciliation
+
+4. **Verify update:**
+ - Eventually verify Function CR becomes Ready
+ - This confirms operator successfully updated the middleware
+
+**Old version:** Use `v1.20.0` as the hardcoded "old" version
+
+## Dependencies
+
+**New imports needed:**
+- `io`
+- `net/http`
+- `runtime` (for OS/arch detection)
+
+**No external dependencies required.**
+
+## Testing
+
+- Download logic tested automatically on first run of middleware update test
+- Subsequent runs use cached binary
+- Manual cleanup for testing: `rm -rf bin/func-cli/`
+
+## Commit Strategy
+
+1. **First commit:** Create wrapper functions and refactor existing tests to use RunFunc
+2. **Second commit:** Add new middleware update e2e test
diff --git a/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md b/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md
new file mode 100644
index 0000000..9a514e8
--- /dev/null
+++ b/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md
@@ -0,0 +1,171 @@
+# Private Git Repository Authentication E2E Test Design
+
+## Overview
+
+Add end-to-end tests to verify that the func-operator correctly handles private Git repositories using authentication credentials specified via `.spec.repository.authSecretRef`.
+
+## Background
+
+The func-operator supports private Git repositories by allowing users to specify a reference to an authentication secret in the Function CR. The controller reads this secret and uses the credentials to clone the repository. The implementation in `internal/git/manager.go` supports two authentication methods:
+
+1. **Token-based**: Secret contains `token` key
+2. **Username/Password**: Secret contains `username` and `password` keys
+
+Currently, there are no e2e tests verifying this functionality works end-to-end.
+
+## Test Structure
+
+All tests will be added to `test/e2e/func_deploy_test.go` to keep deployment-related tests consolidated.
+
+### Test Contexts
+
+Four new test contexts will be added:
+
+1. **"with a private repository using token authentication - success"**
+ - Verifies Function becomes Ready when authSecretRef is provided
+
+2. **"with a private repository using token authentication - failure"**
+ - Verifies Function fails with authentication error when authSecretRef is missing
+
+3. **"with a private repository using username/password authentication - success"**
+ - Verifies Function becomes Ready when authSecretRef is provided
+
+4. **"with a private repository using username/password authentication - failure"**
+ - Verifies Function fails with authentication error when authSecretRef is missing
+
+## Implementation Details
+
+### Test Flow (Token Authentication - Success)
+
+**BeforeEach:**
+1. Create random Gitea user using `repoProvider.CreateRandomUser()`
+2. Create private repository using `repoProvider.CreateRandomRepo(username, true)`
+3. Create access token using `repoProvider.CreateAccessToken(username, password, "e2e-token")`
+4. Initialize repository with function code using `utils.InitializeRepoWithFunction()`
+5. Deploy function using func CLI (authenticates with username/password for git operations)
+6. Commit func.yaml changes
+7. Create test namespace
+
+**Test Body:**
+1. Create Kubernetes Secret with token data using k8sClient:
+ ```go
+ secret := &v1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "git-auth-",
+ Namespace: functionNamespace,
+ },
+ Data: map[string][]byte{
+ "token": []byte(token),
+ },
+ }
+ ```
+2. Create Function CR with `spec.repository.authSecretRef.name` pointing to the secret
+3. Verify Function becomes Ready:
+ ```go
+ funcBecomeReady := func(g Gomega) {
+ fn := &functionsdevv1alpha1.Function{}
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: functionName, Namespace: functionNamespace}, fn)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ for _, cond := range fn.Status.Conditions {
+ if cond.Type == functionsdevv1alpha1.TypeReady {
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+ return
+ }
+ }
+ g.Expect(false).To(BeTrue(), "Ready condition not found")
+ }
+ Eventually(funcBecomeReady, 6*time.Minute).Should(Succeed())
+ ```
+
+**Cleanup:**
+- Delete Function CR
+- Delete Secret
+- Cleanup namespace, repo, user (via DeferCleanup)
+
+### Test Flow (Token Authentication - Failure)
+
+**BeforeEach:** Same as success case
+
+**Test Body:**
+1. Do NOT create authentication secret
+2. Create Function CR WITHOUT `spec.repository.authSecretRef`
+3. Verify Function does NOT become Ready and has authentication error:
+ ```go
+ funcFailsWithAuthError := func(g Gomega) {
+ fn := &functionsdevv1alpha1.Function{}
+ err := k8sClient.Get(ctx, types.NamespacedName{Name: functionName, Namespace: functionNamespace}, fn)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ // Check it's NOT Ready
+ for _, cond := range fn.Status.Conditions {
+ if cond.Type == functionsdevv1alpha1.TypeReady {
+ g.Expect(cond.Status).NotTo(Equal(metav1.ConditionTrue))
+ }
+ // Check for SourceReady condition with auth error
+ if cond.Type == functionsdevv1alpha1.TypeSourceReady {
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Message).To(Or(
+ ContainSubstring("authentication"),
+ ContainSubstring("Authentication"),
+ ContainSubstring("401"),
+ ContainSubstring("Unauthorized"),
+ ))
+ return
+ }
+ }
+ g.Expect(false).To(BeTrue(), "SourceReady condition not found")
+ }
+ Eventually(funcFailsWithAuthError, 2*time.Minute).Should(Succeed())
+ ```
+
+**Cleanup:** Same as success case
+
+### Test Flow (Username/Password Authentication)
+
+Same as token authentication, but:
+- No token creation
+- Secret contains username and password:
+ ```go
+ secret := &v1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "git-auth-",
+ Namespace: functionNamespace,
+ },
+ Data: map[string][]byte{
+ "username": []byte(username),
+ "password": []byte(password),
+ },
+ }
+ ```
+
+## Verification Criteria
+
+### Positive Tests
+- Function CR is created successfully
+- Function status reaches Ready condition with status=True
+- No error conditions present
+- Timeout: 6 minutes (matching existing deployed function tests)
+
+### Negative Tests
+- Function CR is created successfully
+- Function status does NOT reach Ready condition
+- SourceReady condition exists with status=False
+- SourceReady condition message contains authentication-related error keywords
+- Timeout: 2 minutes (should fail faster)
+
+## Testing Infrastructure Requirements
+
+All required infrastructure already exists:
+- Gitea client with user/repo/token management in `test/utils/gitea.go`
+- Repository initialization helpers in `test/utils/git.go`
+- k8sClient available in test suite
+- Test namespace management
+
+## Future Enhancements
+
+Potential follow-up work (not in scope for this implementation):
+- Refactor duplicated `funcBecomeReady` closures into reusable test helpers
+- Test SSH-based authentication (if/when implemented)
+- Test secret updates/rotation
+- Test invalid credentials handling
\ No newline at end of file
diff --git a/docs/plans/2026-04-10-status-tracker-design.md b/docs/plans/2026-04-10-status-tracker-design.md
new file mode 100644
index 0000000..d00db07
--- /dev/null
+++ b/docs/plans/2026-04-10-status-tracker-design.md
@@ -0,0 +1,186 @@
+# Status Tracker for Immediate Status Updates
+
+**Date:** 2026-04-10
+**Status:** Approved
+
+## Problem
+
+The Function controller currently only updates status at the end of reconciliation (lines 98-103 in `function_controller.go`). This causes delayed status updates during long operations like redeployment, leaving users without visibility into what's happening.
+
+## Solution
+
+Introduce a `StatusTracker` that:
+1. Captures the original function state at reconciliation start
+2. Lives in the context for easy access
+3. Automatically flushes status updates when changes occur
+4. Reduces unnecessary API calls by comparing before updating
+
+## Design
+
+### StatusTracker Structure
+
+```go
+type StatusTracker struct {
+ client client.Client
+ original *v1alpha1.Function
+}
+
+func NewStatusTracker(client client.Client, function *v1alpha1.Function) *StatusTracker {
+ return &StatusTracker{
+ client: client,
+ original: function.DeepCopy(), // snapshot current state
+ }
+}
+
+func (t *StatusTracker) Flush(ctx context.Context, current *v1alpha1.Function) error {
+ // Always calculate ready condition before comparing
+ current.CalculateReadyCondition()
+
+ // Compare and update if changed
+ if !equality.Semantic.DeepEqual(t.original.Status, current.Status) {
+ if err := t.client.Status().Update(ctx, current); err != nil {
+ return err
+ }
+ // Update our snapshot to the new state
+ t.original = current.DeepCopy()
+ }
+ return nil
+}
+```
+
+### Context Integration
+
+```go
+type statusTrackerKey struct{}
+
+// WithStatusTracker adds a status tracker to the context
+func WithStatusTracker(ctx context.Context, tracker *StatusTracker) context.Context {
+ return context.WithValue(ctx, statusTrackerKey{}, tracker)
+}
+
+// GetStatusTracker retrieves the tracker from context
+func GetStatusTracker(ctx context.Context) *StatusTracker {
+ tracker, ok := ctx.Value(statusTrackerKey{}).(*StatusTracker)
+ if !ok {
+ return nil
+ }
+ return tracker
+}
+
+// FlushStatus is a convenience helper that gets tracker from context and flushes
+func FlushStatus(ctx context.Context, function *v1alpha1.Function) error {
+ tracker := GetStatusTracker(ctx)
+ if tracker == nil {
+ return nil // gracefully handle missing tracker
+ }
+ return tracker.Flush(ctx, function)
+}
+```
+
+### Reconciler Integration
+
+```go
+func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ logger := log.FromContext(ctx).WithValues("function", fmt.Sprintf("%s/%s", req.Namespace, req.Name))
+ ctx = log.IntoContext(ctx, logger)
+
+ original := &v1alpha1.Function{}
+ err := r.Get(ctx, req.NamespacedName, original)
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ logger.Info("function resource not found. Ignoring since object must be deleted")
+ return ctrl.Result{}, nil
+ }
+ logger.Error(err, "Failed to get function")
+ return ctrl.Result{}, err
+ }
+
+ function := original.DeepCopy()
+
+ // Create tracker and add to context
+ tracker := NewStatusTracker(r.Client, function)
+ ctx = WithStatusTracker(ctx, tracker)
+
+ reconcileErr := r.reconcile(ctx, function)
+
+ // Final flush at the end (handles ready condition calculation)
+ if err := tracker.Flush(ctx, function); err != nil {
+ logger.Error(err, "Unable to update Function status")
+ return ctrl.Result{}, err
+ }
+
+ if reconcileErr != nil {
+ logger.Error(reconcileErr, "Failed to reconcile Function")
+ return ctrl.Result{}, reconcileErr
+ }
+
+ logger.Info("Reconciliation complete")
+ return ctrl.Result{}, nil
+}
+```
+
+### Checkpoint Placement Strategy
+
+**Flush on phase completions, not on errors** (final flush catches errors anyway):
+
+1. **After source preparation** - Users see git clone succeeded, commit info available
+2. **Before long operations** - Users see "about to deploy" before redeployment starts
+3. **Final flush** - Always happens in `Reconcile()` regardless of success/failure
+
+```go
+func (r *FunctionReconciler) reconcile(ctx context.Context, function *v1alpha1.Function) error {
+ function.InitializeConditions()
+
+ repo, metadata, err := r.prepareSource(ctx, function)
+ if err != nil {
+ return fmt.Errorf("prepare source failed: %w", err)
+ }
+ defer repo.Cleanup()
+
+ r.updateFunctionStatusGit(function, repo)
+ FlushStatus(ctx, function) // Checkpoint 1: Source ready
+
+ if err := r.ensureDeployment(ctx, function, repo, metadata); err != nil {
+ return fmt.Errorf("deploying function failed: %w", err)
+ }
+
+ r.updateFunctionStatus(function, metadata)
+ return nil
+}
+
+func (r *FunctionReconciler) handleMiddlewareUpdate(ctx context.Context, function *v1alpha1.Function, repo *git.Repository, metadata *funcfn.Function) error {
+ isOnLatestMiddleware, err := r.isMiddlewareLatest(ctx, metadata, function.Namespace)
+ if err != nil {
+ function.MarkMiddlewareNotUpToDate("MiddlewareCheckFailed", "Failed to check middleware version: %s", err.Error())
+ return fmt.Errorf("failed to check if function is using latest middleware: %w", err)
+ }
+
+ if !isOnLatestMiddleware {
+ logger.Info("Function is not on latest middleware. Will redeploy")
+ function.MarkMiddlewareNotUpToDate("MiddlewareOutdated", "Middleware is outdated, redeploying")
+ FlushStatus(ctx, function) // Checkpoint 2: Before long deploy operation
+
+ if err := r.deploy(ctx, function, repo); err != nil {
+ function.MarkDeployNotReady("DeployFailed", "Redeployment failed: %s", err.Error())
+ return fmt.Errorf("failed to redeploy function: %w", err)
+ }
+ }
+
+ function.MarkMiddlewareUpToDate()
+ function.MarkDeployReady()
+ return nil
+}
+```
+
+## Benefits
+
+1. **Immediate visibility** - Users see status updates as phases complete
+2. **No signature changes** - Context pattern avoids passing tracker through every function
+3. **Automatic deduplication** - Tracker prevents unnecessary API calls when status unchanged
+4. **Error handling** - Final flush ensures errors are captured even without intermediate flushes
+5. **Encapsulation** - All comparison/update logic lives in one place
+
+## Future Enhancements
+
+- Add retry logic with `retry.RetryOnConflict` for handling conflicts during intermediate updates
+- Add metrics for tracking flush frequency and update patterns
\ No newline at end of file
diff --git a/docs/plans/2026-04-23-reconcile-state-design.md b/docs/plans/2026-04-23-reconcile-state-design.md
new file mode 100644
index 0000000..7a8bcc0
--- /dev/null
+++ b/docs/plans/2026-04-23-reconcile-state-design.md
@@ -0,0 +1,211 @@
+# Reconcile State Design
+
+Centralize all status and condition updates in the function controller by introducing a `reconcileState` struct and a single `syncStatus` function.
+
+## Problem
+
+Conditions (`Mark*` calls) and status field writes are scattered across 6 methods in 2 files. It is hard to reason about what state the Function ends up in after reconciliation. The `reconcileError` type wraps condition info into errors, coupling error handling with status logic.
+
+## Design
+
+Inspired by the Kubernetes Job controller's `syncJobCtx` pattern: a state struct accumulates data during reconciliation, and one function translates it into status writes and conditions.
+
+### Structs
+
+```go
+type reconcileState struct {
+ source *sourceState
+ deployment *deploymentState
+ middleware *middlewareState
+}
+
+type sourceState struct {
+ name string
+ branch string
+ commit string
+ failReason string
+ failMessage string
+}
+
+type deploymentState struct {
+ deployed bool
+ deployer string
+ runtime string
+ image string
+ ready string // "true"/"false"/unknown from describe
+ failReason string
+ failMessage string
+}
+
+type middlewareState struct {
+ updateEnabled bool
+ updateSource string
+ isLatest bool
+ currentVersion string
+ latestVersion string
+ pendingRebuild bool
+ redeployed bool
+ lastRebuild metav1.Time
+ failReason string
+ failMessage string
+}
+```
+
+Each phase struct is nil until that phase runs. Nil means conditions stay Unknown (from `InitializeConditions`).
+
+### `syncStatus`
+
+Single function that translates state into `Mark*` calls and `function.Status` field writes. It is the only place in the controller that calls `Mark*` or writes to `function.Status`.
+
+Each section follows the same shape:
+1. Guard: if phase struct is nil, return
+2. Check failure: if `failReason` is set, set the failure condition, return
+3. Set success condition
+4. Write status fields
+
+```go
+func syncStatus(function *v1alpha1.Function, state *reconcileState) {
+ // --- Source ---
+ if state.source == nil {
+ return
+ }
+ if state.source.failReason != "" {
+ function.MarkSourceNotReady(state.source.failReason, "%s", state.source.failMessage)
+ return
+ }
+ function.MarkSourceReady()
+ function.Status.Name = state.source.name
+ function.Status.Git.ResolvedBranch = state.source.branch
+ function.Status.Git.ObservedCommit = state.source.commit
+ function.Status.Git.LastChecked = metav1.Now()
+
+ // --- Deployment ---
+ if state.deployment == nil {
+ return
+ }
+ if state.deployment.failReason != "" {
+ function.MarkDeployNotReady(state.deployment.failReason, "%s", state.deployment.failMessage)
+ return
+ }
+ if !state.deployment.deployed {
+ function.MarkDeployNotReady("NotDeployed", "Function not deployed yet")
+ return
+ }
+ function.MarkDeployReady()
+ function.Status.Deployment.Deployer = state.deployment.deployer
+ function.Status.Deployment.Runtime = state.deployment.runtime
+ function.Status.Deployment.Image = state.deployment.image
+ markServiceStatus(state.deployment.ready, function)
+
+ // --- Middleware ---
+ if state.middleware == nil {
+ return
+ }
+ if state.middleware.failReason != "" {
+ function.MarkMiddlewareNotUpToDate(state.middleware.failReason, "%s", state.middleware.failMessage)
+ return
+ }
+ switch {
+ case state.middleware.isLatest:
+ function.MarkMiddlewareUpToDate()
+ case !state.middleware.updateEnabled:
+ function.Status.Middleware.Available = ptr.To(state.middleware.latestVersion)
+ function.MarkMiddlewareNotUpToDateIntentionally("SkipMiddlewareUpdate",
+ "Skipping middleware update as update is disabled (source: %s)", state.middleware.updateSource)
+ case state.middleware.redeployed:
+ function.Status.Middleware.Available = nil
+ function.Status.Middleware.LastRebuild = state.middleware.lastRebuild
+ function.Status.Deployment.ImageBuilt = state.middleware.lastRebuild
+ function.MarkMiddlewareUpToDate()
+ function.MarkDeployReady()
+ }
+ function.Status.Middleware.AutoUpdate.Enabled = state.middleware.updateEnabled
+ function.Status.Middleware.AutoUpdate.Source = state.middleware.updateSource
+ function.Status.Middleware.Current = state.middleware.currentVersion
+ function.Status.Middleware.PendingRebuild = state.middleware.pendingRebuild
+}
+```
+
+### `Reconcile` and `reconcile` flow
+
+`Reconcile()` calls `syncStatus` once before flushing, so `reconcile()` never thinks about status:
+
+```go
+func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ // ... get function, deep copy ...
+ state := &reconcileState{}
+ statusTracker := NewStatusTracker(r.Client, function)
+ ctx = WithStatusTracker(ctx, statusTracker)
+
+ reconcileErr := r.reconcile(ctx, function, state)
+ syncStatus(function, state)
+
+ if err := statusTracker.Flush(ctx, function); err != nil {
+ return ctrl.Result{}, err
+ }
+ // ...
+}
+
+func (r *FunctionReconciler) reconcile(ctx context.Context, function *v1alpha1.Function, state *reconcileState) error {
+ function.InitializeConditions()
+
+ repo, err := r.prepareSource(ctx, function, state)
+ if err != nil {
+ return fmt.Errorf("prepare source failed: %w", err)
+ }
+ defer repo.Cleanup()
+
+ applyLastDeployedAnnotation(ctx, function, state)
+
+ if err := r.ensureDeployment(ctx, function, repo, state); err != nil {
+ return fmt.Errorf("deploying function failed: %w", err)
+ }
+ return nil
+}
+```
+
+### Mid-reconcile flush
+
+For long-running deploys, `redeployMiddleware` updates the state struct and explicitly flushes:
+
+```go
+state.middleware.pendingRebuild = true
+state.middleware.failReason = "MiddlewareOutdated"
+state.middleware.failMessage = fmt.Sprintf("Middleware is outdated (%s available), redeploying...", state.middleware.latestVersion)
+
+syncStatus(function, state)
+FlushStatus(ctx, function)
+
+// ... deploy happens ...
+
+state.middleware.pendingRebuild = false
+state.middleware.redeployed = true
+state.middleware.failReason = ""
+state.middleware.failMessage = ""
+```
+
+### Helpers
+
+Helpers populate the state struct and return plain errors. They never call `Mark*` or write to `function.Status`.
+
+`reconcileError` is removed.
+
+### Files
+
+| File | Change |
+|---|---|
+| `function_status.go` | New: `reconcileState`, `sourceState`, `deploymentState` structs, `syncStatus` function |
+| `function_controller.go` | `Reconcile()` calls `syncStatus`, `reconcile()` passes state, `reconcileError` removed, `prepareSource`/`ensureDeployment` rewritten |
+| `function_middleware.go` | All methods take state, no `Mark*` calls, `middlewareState` extended |
+| `function_deploy.go` | Unchanged |
+| `function_rbac.go` | Unchanged |
+| `status_tracker.go` | Unchanged |
+| `function_lifecycle.go` | Unchanged |
+| `function_controller_test.go` | Behavior-compatible, no structural changes expected |
+
+### Tradeoffs
+
+- **Pro**: All condition logic in one place, easy to read the full status story
+- **Pro**: Helpers are pure data gatherers, easy to test
+- **Pro**: Mid-reconcile flushes use the same mechanism
+- **Con**: State struct and `syncStatus` must be kept in sync with helpers — two places to update when adding new status fields
\ No newline at end of file
diff --git a/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md b/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md
new file mode 100644
index 0000000..48c408d
--- /dev/null
+++ b/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md
@@ -0,0 +1,78 @@
+# Refactor `handleMiddlewareUpdate`
+
+## Problem
+
+`handleMiddlewareUpdate` (lines 251-332 in `function_controller.go`) interleaves three concerns:
+
+1. **Querying state** - two `Describe` calls, `isMiddlewareUpdateEnabled`, `isMiddlewareLatest`, `GetLatestMiddlewareVersion`
+2. **Decision logic** - should we redeploy or not?
+3. **Status bookkeeping** - ~15 status field assignments scattered throughout
+
+This makes the method hard to follow. There's also a redundant `Describe` call — the second one (line 321) re-fetches data that only changes after a redeploy.
+
+## Design
+
+### Sum-type pattern for middleware check results
+
+Introduce a sealed interface with two concrete types, separating "what we observed" from "what we should do":
+
+```go
+type middlewareCheck interface {
+ middlewareCheck() // sealed marker
+}
+
+type middlewareUpToDate struct {
+ currentImage string
+ serviceReady string
+ currentVersion string
+ autoUpdate autoUpdateStatus
+}
+
+type middlewareOutdated struct {
+ currentImage string
+ serviceReady string
+ currentVersion string
+ availableVersion string
+ autoUpdate autoUpdateStatus
+}
+
+type autoUpdateStatus struct {
+ enabled bool
+ source string
+}
+```
+
+- `middlewareUpToDate`: deployed version == latest. No `availableVersion` field needed.
+- `middlewareOutdated`: deployed version != latest. Carries `availableVersion` and `autoUpdate.enabled` to distinguish "needs update" from "update disabled."
+
+### `checkMiddlewareState` method
+
+Consolidates all querying into one method. Uses a single `Describe` call (instead of two) since we already have the current version from `Describe` and only need `GetLatestMiddlewareVersion` to compare:
+
+```go
+func (r *FunctionReconciler) checkMiddlewareState(
+ ctx context.Context,
+ function *v1alpha1.Function,
+ metadata *funcfn.Function,
+) (middlewareCheck, error)
+```
+
+### Refactored `handleMiddlewareUpdate`
+
+Becomes a thin orchestrator with a type switch:
+
+- `middlewareUpToDate`: set status fields, mark middleware up-to-date
+- `middlewareOutdated` + `autoUpdate.enabled == false`: set status fields, mark intentionally not up-to-date
+- `middlewareOutdated` + `autoUpdate.enabled == true`: flush status, deploy, re-describe, update status, record history event
+
+The second `Describe` call only happens inside the deploy branch (where it's actually needed).
+
+### Cleanup
+
+`isMiddlewareLatest` is no longer needed — the version comparison happens inside `checkMiddlewareState` using data from the single `Describe` call.
+
+## Decisions
+
+- **Keep status field duplication across switch cases** — each case is self-contained and readable top-to-bottom
+- **`autoUpdate` lives as a field on `middlewareOutdated`** rather than being a third type — the two outdated cases share the same data
+- **All types are unexported** — this is controller-internal
\ No newline at end of file
diff --git a/docs/plans/2026-04-29-ssh-url-support-design.md b/docs/plans/2026-04-29-ssh-url-support-design.md
new file mode 100644
index 0000000..e7fa30a
--- /dev/null
+++ b/docs/plans/2026-04-29-ssh-url-support-design.md
@@ -0,0 +1,71 @@
+# SSH URL Support for Function CR
+
+## Overview
+
+Add support for SSH repository URLs in the Function CR, allowing users to specify repositories using SCP-style (`git@host:owner/repo.git`) or `ssh://` URLs. Currently, only HTTP/HTTPS URLs are supported.
+
+## Design Decisions
+
+- **URL-based auth detection**: The URL scheme determines the transport (SSH vs HTTP). No need for a secret to exist for public repos cloned over SSH.
+- **Optional host key verification**: The auth secret can include a `known_hosts` field. If absent, host key checking is skipped (`InsecureIgnoreHostKey`).
+- **go-git's `transport.ParseURL()`**: Replaces `net/url.Parse()` for URL parsing. Handles SCP-style URLs natively by normalizing them to `ssh://` scheme.
+- **Random temp directories**: Simplify temp dir naming to fully random (`repo-*`) instead of deriving from the URL.
+
+## Changes
+
+### 1. Git Manager (`internal/git/manager.go`)
+
+**`CloneRepository`:**
+- Replace `neturl.Parse(repoUrl)` with `transport.ParseURL(repoUrl)` from `github.com/go-git/go-git/v6/plumbing/transport`.
+- Simplify temp dir pattern to `os.MkdirTemp(cloneBaseDir, "repo-*")`.
+- Pass the parsed URL to `getClientOptions` so it can branch on scheme.
+
+**`getClientOptions`:**
+- Signature changes to accept the parsed `*url.URL`.
+- For `ssh` scheme:
+ - If secret has `sshPrivateKey`: build `ssh.PublicKeys` via `ssh.NewPublicKeys("git", pemBytes, password)`.
+ - If secret has `known_hosts`: set `HostKeyCallback` from the known_hosts data.
+ - If no `known_hosts`: use `gossh.InsecureIgnoreHostKey()`.
+ - If no secret: return `WithSSHAuth` using `InsecureIgnoreHostKey` only (public repo).
+ - Wrap in `client.WithSSHAuth()`.
+- For `http`/`https` scheme: existing token/username-password logic unchanged.
+
+**Auth secret fields (SSH):**
+- `sshPrivateKey` (required for private repos) — PEM-encoded private key
+- `sshPrivateKeyPassword` (optional) — passphrase for encrypted keys
+- `known_hosts` (optional) — known_hosts file content for host key verification
+
+### 2. Unit Tests (`internal/git/manager_test.go`)
+
+New test file covering `getClientOptions`:
+- Empty secret + SSH URL → nil auth with InsecureIgnoreHostKey
+- `sshPrivateKey` + SSH URL → WithSSHAuth with PublicKeys
+- `sshPrivateKey` + `known_hosts` + SSH URL → SSH auth with known_hosts callback
+- `sshPrivateKey` + `sshPrivateKeyPassword` + SSH URL → decrypts key
+- Existing HTTP cases still work (token, username/password, empty)
+- URL parsing: SCP-style, `ssh://`, `http://`, `https://` all resolve correctly
+
+### 3. E2E Test Utilities
+
+**`test/utils/gitea.go`:**
+- `GetSSHEndpoint()` — reads `ssh` key from `gitea-endpoint` ConfigMap
+- `CreateSSHKey(username, password, title, publicKey string)` — registers SSH public key via Gitea SDK `CreatePublicKey()`
+- `SSHRepoURL(owner, repo string)` — builds SCP-style URL from SSH endpoint
+
+**`test/utils/git.go`:**
+- `WithSSHKey(privateKeyPath string)` option — configures `InitializeRepoWithFunction` to clone/push via SSH using `GIT_SSH_COMMAND` with the provided private key
+
+### 4. E2E Tests (`test/e2e/func_deploy_test.go`)
+
+Three new test cases under a new `Context("with an SSH repository URL", ...)`:
+
+1. **Public repo with SSH URL** — Create public repo, push via HTTP, create Function CR with SSH URL, verify function becomes ready.
+2. **Private repo with SSH key auth** — Generate SSH keypair, register public key in Gitea, create Secret with `sshPrivateKey`, create Function CR with SSH URL + authSecretRef, verify function becomes ready.
+3. **Private repo without auth secret** — Create private repo, create Function CR with SSH URL but no authSecretRef, verify function fails with auth error.
+
+### 5. README Updates
+
+Add SSH examples to the existing "Git Authentication" section:
+- Secret format with `sshPrivateKey`, `sshPrivateKeyPassword`, `known_hosts`
+- Function CR example with SCP-style SSH URL + authSecretRef
+- Function CR example with SSH URL for public repo (no secret)
\ No newline at end of file
diff --git a/docs/release.md b/docs/release.md
new file mode 100644
index 0000000..8164cd4
--- /dev/null
+++ b/docs/release.md
@@ -0,0 +1,65 @@
+# Release Process
+
+## Versioning
+
+The project uses [semantic versioning](https://semver.org/) with a `v` prefix: `v{MAJOR}.{MINOR}.{PATCH}` (e.g. `v0.3.0`).
+
+## Branch Model
+
+- **`main`** — Active development. All PRs target `main`.
+- **`release-{MAJOR}.{MINOR}`** — Maintenance branches for each minor version (e.g. `release-0.3`). Created when a minor version is ready to ship.
+
+## Creating a New Minor Release
+
+1. Create a `release-{MAJOR}.{MINOR}` branch from `main` and push it
+2. The `manage-release-tags` workflow detects the new branch and automatically creates the `v{MAJOR}.{MINOR}.0` tag
+3. The `release` workflow triggers, builds multi-arch images, and creates the GitHub release
+
+## Patch Releases
+
+Patch releases are **automated**:
+
+1. Cherry-pick fixes to the `release-{MAJOR}.{MINOR}` branch (manually, or via `/cherry-pick release-X.Y` comment on a merged PR)
+2. Every Tuesday at 08:00 UTC, the `manage-release-tags` workflow checks all release branches for new commits since the last tag
+3. If new commits exist, it increments the patch version (e.g. `v0.3.0` → `v0.3.1`) and creates a new tag
+4. The `release` workflow builds and publishes the patch release
+
+To trigger a patch release immediately (without waiting for Tuesday), manually run the `manage-release-tags` workflow via GitHub Actions.
+
+## What the Release Workflow Produces
+
+For each tag, the `release` workflow:
+
+1. **Builds multi-arch container images** (`linux/amd64`, `linux/arm64`) and pushes to GHCR:
+ - `ghcr.io/functions-dev/func-operator:{VERSION}`
+ - `ghcr.io/functions-dev/func-operator:{MAJOR}.{MINOR}`
+ - `ghcr.io/functions-dev/func-operator:{MAJOR}`
+ - `ghcr.io/functions-dev/func-operator:latest` (only for the newest version)
+
+2. **Generates an install manifest** (`func-operator.yaml`) with CRDs, RBAC, and deployment (using image digest for reproducibility)
+
+3. **Builds an OLM bundle image** for catalog distribution:
+ - `ghcr.io/functions-dev/func-operator-bundle:{VERSION}`
+
+4. **Creates a GitHub release** with auto-generated release notes and the install manifest attached
+
+## Nightly Builds
+
+The `nightly-build` workflow runs daily at 00:00 UTC and pushes images from `main` tagged as `:main`.
+
+## Cherry-Pick Automation
+
+The `cherry-pick` workflow supports automated backporting:
+- Comment `/cherry-pick release-X.Y` on a merged PR
+- A new PR with the cherry-picked changes is automatically created against the target branch
+
+## Emergency Release
+
+For urgent fixes outside the Tuesday schedule:
+1. Merge the fix to the release branch
+2. Either manually trigger `manage-release-tags` via GitHub Actions, or push a tag directly:
+ ```bash
+ git tag v{MAJOR}.{MINOR}.{PATCH}
+ git push origin v{MAJOR}.{MINOR}.{PATCH}
+ ```
+3. The `release` workflow triggers automatically on tag push
\ No newline at end of file