This guide explains the real configuration objects that drive gitops-reverser after the install steps in the root README.
The short version:
GitProviderdefines where and how to pushGitTargetdefines which branch and repository path to write intoWatchRuledefines which namespaced resources should produce Git writesClusterWatchRuledoes the same for cluster-scoped or cross-namespace watchingCommitRequestoptionally asks the operator to close the current commit window now
The chart's optional quickstart values are just a convenience layer that creates starter
instances of those same resources.
For a first trial, use the root README quick start. It runs committer-only: Git writes work without kube-apiserver audit delivery, and every commit uses the configured committer identity. Add audit attribution later only when you need named Kubernetes users or service accounts in Git history.
The usual flow is:
- Create a
GitProviderfor repository access and commit behavior. - Create a
GitTargetthat points at that provider plus a branch and repository path. - Create one or more
WatchRuleorClusterWatchRuleobjects that point at that target. - Create a
CommitRequestonly when you want to flush an open window before the normal timer.
That means one repository connection can back multiple targets, and one target can be fed by multiple watch rules.
GitProvider defines the Git remote, credentials, allowed branches, push strategy, and commit
behavior.
The important fields are:
spec.url: repository URLspec.secretRef.name: Secret with Git credentials such as SSH or HTTPS authspec.knownHostsRef: optional ConfigMap/Secret with SSHknown_hostsshared across providersspec.allowedBranches: branches this provider is allowed to writespec.push.commitWindow: rolling silence window that coalesces events into one commit per authorspec.commit: committer identity, commit templates, and signing
Example:
apiVersion: configbutler.ai/v1alpha3
kind: GitProvider
metadata:
name: example-provider
namespace: default
spec:
url: git@github.com:example-org/example-repo.git
secretRef:
name: git-creds
allowedBranches:
- mainThe referenced Secret holds the Git credentials. The examples use the Kubernetes-native keys,
which match the built-in Secret types and the tooling around them (kubectl create secret generic --type=…, Sealed Secrets, External Secrets, SOPS):
| Auth | Keys |
|---|---|
| SSH | ssh-privatekey (+ optional ssh-password passphrase, known_hosts) |
| HTTP basic | username + password |
| HTTP bearer token | bearerToken (GitHub fine-grained PAT, GitLab access token; no username) |
The credential reader's design is inspired by both Flux and Argo CD: it accepts their Secret key names alongside the native ones, so you can reuse a Git credentials Secret you already have instead of re-authoring it. The keys read for each auth method:
| Credential | Native key (recommended) | Flux key (also read) | Argo CD key (also read) |
|---|---|---|---|
| SSH private key | ssh-privatekey |
identity |
sshPrivateKey |
| SSH key passphrase | ssh-password |
password (when an SSH key is present) |
— (unsupported by Argo) |
| SSH host keys | known_hosts |
known_hosts |
external ConfigMap → supply via spec.knownHostsRef |
| HTTP basic auth | username + password |
username + password |
username + password |
| HTTP bearer token | bearerToken |
bearerToken |
bearerToken |
Auth precedence is SSH key → HTTP basic → bearer token. Client certificates (mTLS), custom CA certificates, and GitHub App credentials are not supported.
A reused Secret needs write access. Flux and Argo CD only clone, so their Git credentials are often read-only (a read-only deploy key, a read-scoped token). GitOps Reverser pushes commits, so a reused Secret's key or token must have write access on the repository — otherwise the commits will fail to push.
SSH host keys are resolved in priority order: the credentials Secret's own known_hosts, then
spec.knownHostsRef (a namespace-local ConfigMap or Secret keyed known_hosts, or ssh_known_hosts
for data copied out of Argo's argocd-ssh-known-hosts-cm), then an install-level default known-hosts
ConfigMap in the controller's namespace (--default-known-hosts-configmap). If none yields a valid
host key, SSH fails closed. Host-key rotation is an admin-owned declarative update; verify
fingerprints out of band. The controller flag --insecure-allow-missing-known-hosts relaxes this for
throwaway/dev clusters only — it permits SSH when no source provided any known_hosts; a
known_hosts that is present but unparseable is always a hard error.
spec.push.commitWindow controls how arriving events are grouped into commits. The timer resets
on every event; when it has been silent for the configured duration, the buffered events for a
given (author, gitTarget) are written as one commit. The default is 5s. Setting 0s opts into
per-event commits in the steady-state.
spec:
push:
commitWindow: "5s"A burst (e.g. kubectl apply -k, helm upgrade, an ArgoCD sync wave) becomes one commit per
author with a summary subject; isolated edits still produce one commit each.
spec.commit configures how gitops-reverser writes commits:
committer: the operator identity written as the Git committermessage: the subject format for per-event and batch commitssigning: the SSH signing key configuration
If spec.commit is omitted, gitops-reverser uses its built-in defaults.
These are different on purpose:
- Author: who made the cluster change
- Committer: who wrote the Git commit object
For mirrored-resource commits, the author comes from the configured committer identity unless
attribution.enabled=true and a matching kube-apiserver audit event names the Kubernetes user or
service account. Snapshot/reconcile commits are operator-authored.
That distinction is useful in practice:
git log --author=aliceanswers "what did Alice change?"git log --committer="GitOps Reverser"answers "what did the operator write?"
When signing is enabled, Git hosting platforms usually verify the committer identity, not the Kubernetes author.
Use spec.commit.committer to control the bot identity written as the Git committer:
spec:
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.comDefaults:
name:GitOps Reverseremail:noreply@configbutler.ai
If signing is enabled, spec.commit.committer.email should be an email that the Git hosting
platform recognizes for the account that owns the signing key.
There are three templates, one per commit shape:
spec.commit.message.eventTemplate: per-event commits (only used whencommitWindowis0s).spec.commit.message.groupTemplate: grouped commits produced by the commit window (the common case).spec.commit.message.reconcileTemplate: reconcile commits (the mark-and-sweep reconcile path; one commit per synced type).
spec:
commit:
message:
eventTemplate: "[{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"
groupTemplate: "{{.Author}} on {{.GitTarget}}: {{.Count}} resource(s)"
reconcileTemplate: "reconciled {{.Count}} {{.Resource}}"eventTemplate can use:
OperationGroupVersionResourceNamespaceNameAPIVersionUsernameGitTarget
groupTemplate can use:
AuthorGitTargetCountOperations(map ofCREATE/UPDATE/DELETEcounts)Resources(slice of{Group, Version, Resource, Namespace, Name})
reconcileTemplate can use:
CountGitTargetGroupVersionResourceAPIVersionRevision
Group/Version/Resource/APIVersion name the synced type for a per-type reconcile and
Revision is the cluster resourceVersion the reconcile was pinned to. The default,
reconciled {{.Count}} {{if .Resource}}{{.Resource}}{{else}}resources{{end}}{{if .Revision}} (last resourceVersion: {{.Revision}}){{end}},
renders e.g. reconciled 6 secrets (last resourceVersion: 1331). The type and revision fields are
empty for a whole-target reconcile or a pure sweep, so guard a template that references them
(the default uses {{if .Resource}} / {{if .Revision}}) to avoid an identity-less subject.
Examples:
spec:
commit:
message:
eventTemplate: "chore: [{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"spec:
commit:
message:
eventTemplate: "[{{.Operation}}] {{.Resource}}/{{.Name}} ({{.Username}})"spec:
commit:
message:
reconcileTemplate: "reconciled {{.Count}} {{.Resource}}@{{.Revision}}"GitOps Reverser signs commits from spec.commit.signing.
The signing Secret uses these data keys:
signing.key: PEM-encoded SSH private keypassphrase: optional passphrase for encrypted private keyssigning.pub: optional convenience copy of the public key
The operator publishes the effective public key in .status.signingPublicKey.
Let the operator generate the signing key:
apiVersion: configbutler.ai/v1alpha3
kind: GitProvider
metadata:
name: example-provider
namespace: default
spec:
url: git@github.com:example-org/example-repo.git
allowedBranches:
- main
secretRef:
name: git-creds
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.com
signing:
secretRef:
name: gitops-reverser-signing-key
generateWhenMissing: trueBring your own signing key:
ssh-keygen -t ed25519 -f /tmp/gitops-reverser-signing -N ""
kubectl create secret generic gitops-reverser-signing-key \
-n default \
--from-file=signing.key=/tmp/gitops-reverser-signing \
--from-file=signing.pub=/tmp/gitops-reverser-signing.pubspec:
commit:
committer:
name: GitOps Reverser
email: 12345678+gitops-reverser-bot@users.noreply.github.com
signing:
secretRef:
name: gitops-reverser-signing-keyIf you start from the Helm chart quickstart, edit the generated GitProvider directly when you
want custom spec.commit behavior because the starter values do not currently expose those fields.
For the platform-facing behavior behind "valid signature" versus "verified badge", see commit-signing.md.
GitTarget decides where inside the repository resources are written.
The important fields are:
spec.providerRef: whichGitProviderbacks this targetspec.branch: which allowed branch to write tospec.path: required relative path inside the repository; use.only when you deliberately want the repository rootspec.encryption: howSecretresources should be encrypted before commitspec.placement: optional policy for where new resources are written (see Where new resources are written); omit it to follow the repository's existing layout
Example:
apiVersion: configbutler.ai/v1alpha3
kind: GitTarget
metadata:
name: example-target
namespace: default
spec:
providerRef:
name: example-provider
branch: main
path: live-clusterspec.path is required so a target never writes to the repository root by accident. Use a path
such as live-cluster for the first install. To deliberately target the repository root, set
path: ".". Do not use a leading slash, and do not add a trailing slash.
The target path is authoritative for snapshot reconciliation. A root target can create, update, and
delete managed manifest files at the repository root, so use . only for a repository layout that is
dedicated to this target.
If you enable spec.encryption, that applies to Secret resource writes for this target. For SOPS
and age details, see sops-age-guide.md.
spec.providerRef references a GitProvider in the same namespace as the GitTarget. Its group
and kind default to configbutler.ai / GitProvider, so in practice you only set name.
The most useful status fields are:
Ready: true when the target is valid, the Git path is accepted, and watched streams are running.Reconciling: true while initial replay, a recheck, or another coarse progress step is in flight.Stalled: true when the target is blocked until a human fixes configuration, RBAC, or Git path content.ValidatedandEncryptionConfigured: control-plane details.StreamsRunning: true when the source watches are past initial replay and routing live events.GitPathAccepted: true when the target Git path is safe to materialize.status.streams: bounded counts for tracked, running, replaying, and blocked streams.
Use conditions for automation.
Placement decides the file path for a resource that has no document in Git yet. Once a document exists, updates and deletes always edit it in place at its current location (found by manifest identity, not path), so changing placement never moves an existing file — it only affects resources created after the change.
For each new resource the operator walks this order and stops at the first that produces a path:
spec.placement.byType[<exact type>]— an explicit template for that resource's type, if you declared one.spec.placement.default— your explicit catch-all template, if you declared one.- Sibling inference — follow the layout the repository already uses for resources like this one (described next).
- Built-in canonical path —
{namespace}/{group}/{resource}/{name}.yaml: namespace first, the group omitted for core resources, no version segment,_cluster/in place of the namespace for cluster-scoped resources (an illegal namespace name, so it can never clash with a real one), and a.sops.yamlsuffix for sensitive resources.
If you set no spec.placement, only steps 3 and 4 run — which is why pointing a target at an
existing repository "just continues" that repo's conventions, and a brand-new empty repo gets the tidy
canonical layout.
This is the part that looks like magic but isn't: the operator never reverse-engineers a template. It reads the files already in the target and makes two observed decisions for the new resource — which directory (the nearest cohort of resources like it — same type, then same type in any namespace) and one-file-or-bundle (does that cohort keep one resource per file, or share a multi-document file?).
Worked example — a target at spec.path: clusters/prod already looks like:
clusters/prod/
all.yaml # 9 ConfigMaps in one multi-document file (a "bundle")
team-a/secrets/db.sops.yaml # one Secret, encrypted, one file per Secret
- A new ConfigMap
cachearrives: its type-cohort (ConfigMaps) lives entirely in theall.yamlbundle → the new document is appended toall.yaml. No new file, no canonical tree is created. - A new Secret
api-tokenarrives: it is sensitive, so plaintext siblings are ignored; the only encrypted cohort isteam-a/secrets/(one-per-file) → a new encrypted fileteam-a/secrets/api-token.sops.yaml. - A new ConfigMap in a brand-new namespace
billing: the ConfigMap cohort is still theall.yamlbundle, which is namespace-agnostic, so it is appended toall.yamltoo — the new namespace needs no new segment.
The boundaries that keep it predictable:
- A sensitive resource never infers from (or is appended into) a plaintext file; it only follows encrypted siblings, otherwise it uses the secure canonical path.
- When a type genuinely lives in two layouts at once, the tie-break is deterministic (the cohort with the most members wins, then the lexically smallest path) — never a coin-flip.
- Inference can only continue a layout that already exists. It cannot invent a greenfield one — "I
want all ConfigMaps bundled even though none exist yet" is a job for
byTypebelow.
The full ladder, tie-break rules, and edge cases are in design/manifest/version2/gittarget-new-file-placement-rules.md; the vision behind it is design/manifest/file-agnostic-placement.md.
Set spec.placement when you want to prescribe a layout rather than follow the repo (for example a
greenfield repo, or a convention inference can't reach):
spec:
placement:
byType:
v1/configmaps: "{namespace}/configmaps.yaml" # bundle every ConfigMap of a namespace into one file
v1/secrets: "{namespace}/secrets/{name}.yaml" # one file per Secret
default: "{namespaceOrCluster}/{group}/{resource}/{name}.yaml"byTypemaps an exact[group/]version/resourcekey (core resources omit the group, e.g.v1/configmaps; grouped resources include it, e.g.apps/v1/deployments) to a path template.defaultis the template for any type with nobyTypeentry. Omit it to fall through to sibling inference and then the built-in path.- Templates are small brace-variable path templates (see the table below), validated statically as
part of the
Validatedgate — an unknown variable, a path that escapesspec.path(a leading/or..), or a non-.yaml/.ymlsuffix fails the target before any write.
Every value is sanitized for use as a single path segment. An empty segment (an omitted variable,
e.g. {group} for a core resource) is dropped from the final path, so {group}/{resource}/{name}.yaml
renders configmaps/app.yaml, not /configmaps/app.yaml. Example values are for an apps/v1 Deployment
named api in namespace team-a:
| Variable | Renders | Example |
|---|---|---|
{name} |
resource name | api |
{namespace} |
the resource's namespace; empty for a cluster-scoped resource | team-a |
{namespaceOrCluster} |
the namespace, or the literal _cluster for a cluster-scoped resource |
team-a (a Node → _cluster) |
{resource} |
plural resource name | deployments |
{group} |
API group; empty for core resources | apps (a ConfigMap → empty) |
{groupPath} |
the API group as a path segment; equivalent to {group} today (the empty core-group segment is dropped either way) |
apps |
{version} |
API version | v1 |
{apiVersion} |
manifest apiVersion — group/version, or just version for core |
apps/v1 (a ConfigMap → v1) |
{kind} |
manifest kind | Deployment |
{scope} |
namespaced or cluster (a readable label — not a namespace-position value) |
namespaced |
{sensitiveSuffix} |
.sops.yaml for a sensitive resource, .yaml otherwise |
.yaml (a Secret → .sops.yaml) |
{namespace}vs{namespaceOrCluster}— the one to get right. For a cluster-scoped resource{namespace}is empty, so its whole path segment vanishes: a template{namespace}/{resource}/{name}.yamlrendersclusterroles/admin.yamlfor a ClusterRole (no scope folder at all). Use{namespaceOrCluster}when a single template must also place cluster-scoped resources — it keeps a stable_cluster/segment (_cluster/clusterroles/admin.yaml) so namespaced and cluster-scoped resources stay cleanly separated.{scope}is a descriptor (cluster/namespaced), not a substitute — don't use it as the folder for cluster resources.
Sensitivity is enforced by the operator whatever path is chosen. A Secret (and any operator-configured
sensitive type) is always written encrypted, is never appended to an existing file, and is never
co-mingled with a plaintext document. Two consequences for your templates:
- A
byTyperoute for a sensitive type must be identity-complete — it must contain{name}and a scope such as{namespace}— so two of them can never collide onto one file. - A bundling
defaultthat is not identity-complete (e.g."all.yaml") is rejected unless every sensitive type has its own identity-completebyTypeentry, so a Secret can never fall through into a shared file. If an operator-configured sensitive type still reaches such a path at write time, that resource is skipped fail-safe — logged and counted in the resync summary (placementSkipped) — rather than written unsafely. It is not surfaced as a dedicated status condition today.
Core Kubernetes Secret resources always use the encrypted Git write path. For a Secret-shaped
custom resource such as CozyStack tenantsecrets, add the resource type to the controller startup
values:
controllerManager:
additionalSensitiveResources:
- core.cozystack.io/tenantsecretsEntries are resource for the core API group or group/resource for grouped APIs. The match ignores
API version, so a served CRD version change does not change the sensitive classification. The custom
resource still needs a GitTarget with spec.encryption configured before Git writes can succeed.
A target path may contain kustomization.yaml files. The operator retains them as build directives
(it never sweeps them) and understands a deliberately small, round-trippable subset:
namespace:+resources:/bases(local files and directory bases): a namespace-less resource file inherits its namespace from the kustomization that references it, andmetadata.namespaceis kept out of the file on write.images:andreplicas:overrides: a live change produced by an override entry — an image tag, name, or digest pinned byimages:, or a replica count pinned byreplicas:(includingkubectl scale) — is written back to that entry, preserving comments, and the source manifest keeps its bytes. Only fields the entry already declares are updated; the operator never adds or removes entries. Note that one entry is a shared knob, exactly as in kustomize itself: updating it affects every resource in the build whose image matches.
Anything outside that subset refuses the whole target path before anything is written:
patches/patchesStrategicMerge/patchesJson6902, generators, components, Helm fields,
replacements, transformers, namePrefix/nameSuffix, remote bases, and images:/replicas:
values that do not parse (those would fail kustomize build too). A refusal is loud: the target
reports GitPathAccepted=False, Stalled=True, and Ready=False with reason UnsupportedContent
until the path is cleaned up.
Two situations fall back to plain in-place editing of the source manifest instead of refusing:
a resource file reachable from more than one render root with differing override chains
(ambiguous — the operator will not guess which chain governs), and a live change an entry cannot
express (for example a removed digest, or two containers demanding different values from one
entry). These fallbacks are recorded as store diagnostics — visible in the analyzer CLI and, for
the running operator, in the logs at debug verbosity (manifest store diagnostic).
For design details and the exact boundary, see design/gitops-api/f1-images-replicas-edit-through.md.
WatchRule is the normal namespaced watcher. It only watches resources in its own namespace and
writes them to the referenced GitTarget.
Status uses ResourcesResolved for selector resolution, StreamsRunning for source-watch readiness, and
GitTargetReady for the referenced target's write readiness. A rule can have StreamsRunning=True and
still remain Ready=False when its GitTarget reports GitPathAccepted=False.
The important fields are:
spec.targetRef.name: target to write tospec.rules: one or more resource-match rules
Each entry in spec.rules is a logical OR. A resource matching any rule is watched. The rule fields
are:
operations:CREATE,UPDATE,DELETE, or*; omitted means all operations.apiGroups:""for the core group,*for all groups, or omitted to resolve the named resource across the served API surface.apiVersions: a served version such asv1; omitted means the preferred served version.resources: plural resource names such asconfigmaps,secrets, or*.
Subresources such as deployments/scale are not valid rule resources. GitOps Reverser mirrors
top-level resources; selected subresource effects are handled separately by the controller.
Example:
apiVersion: configbutler.ai/v1alpha3
kind: WatchRule
metadata:
name: example-watchrule
namespace: default
spec:
targetRef:
name: example-target
rules:
- operations: [CREATE, UPDATE, DELETE]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["configmaps", "secrets"]Use WatchRule when the watched resources and the GitTarget live in the same namespace.
ClusterWatchRule is the cluster-scoped variant. Use it when you need to watch:
- cluster-scoped resources such as
nodes,clusterroles, or CRDs - namespaced resources across multiple namespaces
Because it is cluster-scoped, its targetRef must include the namespace of the referenced
GitTarget.
Example:
apiVersion: configbutler.ai/v1alpha3
kind: ClusterWatchRule
metadata:
name: cluster-rbac
spec:
targetRef:
name: example-target
namespace: default
rules:
- operations: [CREATE, UPDATE, DELETE]
apiGroups: ["rbac.authorization.k8s.io"]
apiVersions: ["v1"]
resources: ["clusterroles", "clusterrolebindings"]
scope: ClusterUse this sparingly. It is the more powerful option and usually belongs to cluster-admin-managed setups.
CommitRequest is a one-shot "save now" signal for a same-namespace GitTarget. It does not create
or change watch rules. Instead, it asks the branch worker to finalize a matching open commit window
for the request's author instead of waiting for GitProvider.spec.push.commitWindow.
The important fields are:
spec.targetRef.name: target whose open window should be finalizedspec.message: optional verbatim commit messagespec.closeDelaySeconds: optional 0-300 second delay before the open window is closed, after the request author is known — an extra collect window
Example:
apiVersion: configbutler.ai/v1alpha3
kind: CommitRequest
metadata:
name: save-now
namespace: default
spec:
targetRef:
name: example-target
message: "save default/example-target"
closeDelaySeconds: 2The entire spec is immutable. Create a new CommitRequest for each save attempt.
Progress and outcome are reported through kstatus-compatible conditions (no phase string).
kubectl get commitrequest surfaces Ready, AuthorAttributed, and Pushed; kubectl wait --for=condition=Ready blocks until the request settles:
- Ready (summary):
Trueonce the request reached a non-error terminal outcome. TheReadycondition'sreasonsays which:Committed(a commit was pushed;status.branch/status.shaset), or a benign no-commit —NoWindowInGrace,WindowMismatch, orAlreadyPresent. A failed finalize isReady=Falsewith reasonFinalizeFailed. - Reconciling / Stalled: the kstatus progress/blocked pair.
Reconciling=Truewhile the request is finalizing or waiting throughcloseDelaySeconds;Stalled=Truewhen the finalize failed and needs attention (kstatus reports the object Failed). - AuthorAttributed:
Truewith reasonAttributedFromAdmissionwhen the internal commands admission webhook captured the request submitter.Falsewith reasonCommitterFallbackwhen no admission record exists; that is not a failure, and the command still commits as the configured committer. - Pushed:
Trueonce the commit is in the remote repository.
Object state comes from Kubernetes watch, not from audit. Audit is an optional attribution lookup:
kube-apiserver posts audit events to a single HTTP path, /audit-webhook, and the operator extracts a
minimal attribution fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID,
status, timestamps) into a Redis attribution index keyed for the join. A resolver attaches the commit
author to each watch event by matching a fact (by resourceVersion/UID) within a bounded grace window.
The same Redis connection also stores per-watch resume cursors, so short reconnects can resume a normal
watch from the last processed resourceVersion when the apiserver can still serve that history.
Redis/Valkey is required: it stores each GitTarget's watch resume cursors (state continuity, so a
restart or reconnect resumes where it left off), and when attribution is enabled it also stores the audit
facts. The Helm chart defaults to committer-only (attribution.enabled: false): the audit webhook is
unused and every mirrored-resource commit is authored by the configured committer. Redis stays required
either way.
queue:
redis:
addr: "valkey:6379"
auth:
existingSecret: "valkey-auth"
existingSecretKey: "password"When attribution is enabled, these flags tune the join:
--author-attribution-ttl(default10m): how long an attribution fact is retained waiting for the matching watch event to join it.--author-attribution-grace(default3s): bounded per-event wait for a matching audit fact before a watch event ships authored by the committer.
A matched actor is always named by its own username — humans and service accounts alike (e.g.
system:serviceaccount:flux-system:kustomize-controller); there is no option to collapse service
accounts to the committer.
attribution:
ttl: "10m"
grace: "3s"Keep using the root README quickstart when you want the fastest first commit.
The chart's quickstart values create a starter GitProvider, GitTarget, and WatchRule for you.
The starter GitTarget writes under live-cluster by default. Override
quickstart.gitTarget.path=. only when you want the starter target to own the repository root.
Move to hand-managed resources when you want:
- more than one
GitTarget - more than one watch rule
- cluster-scoped watching with
ClusterWatchRule - ad hoc save requests with
CommitRequest - direct control over
GitProvider.spec.commit - direct control over encryption settings
The chart value reference for the starter quickstart block lives in
charts/gitops-reverser/README.md.
- commit-signing.md for signing behavior on Git hosting platforms
- github-setup-guide.md for GitHub auth setup
- sops-age-guide.md for
GitTarget.spec.encryption