Commit Status Controller Best Practices
This document outlines best practices for implementing custom commit status controllers in GitOps Promoter.
Videos
- GitOps Promoter Commit Status Controllers Overview - An introduction to commit status controllers and their role in GitOps Promoter.
Required Labels
All commit status controllers should set the following standard labels on the CommitStatus resources they create. Use utils.CommitStatusStandardLabels(parent, branch, key) to set all three at once.
1. Commit Status Label
commitStatus.Labels[promoterv1alpha1.CommitStatusLabel] = "your-controller-key"
Purpose: The value must equal the gate key from the parent CR's spec.key (and the same key in the PromotionStrategy's activeCommitStatuses or proposedCommitStatuses). Controllers set this automatically; end users configure spec.key on the gate CR, not this label.
Usage in PromotionStrategy:
apiVersion: promoter.argoproj.io/v1alpha1
kind: PromotionStrategy
metadata:
name: my-app
spec:
activeCommitStatuses:
- key: argocd-health # same as ArgoCDCommitStatus.spec.key
- key: timer # same as TimedCommitStatus.spec.key
2. Environment Label
commitStatus.Labels[promoterv1alpha1.EnvironmentLabel] = utils.KubeSafeLabel(branch)
Purpose: This label identifies which environment/branch the commit status applies to. It enables efficient filtering and querying of commit statuses by environment.
Value: The environment branch name (e.g., "environment/development", "environment/staging"), converted to a Kubernetes-safe label value using utils.KubeSafeLabel().
Benefits:
- Easy filtering: kubectl get commitstatus -l promoter.argoproj.io/environment=environment-development
- Efficient lookups in controllers
- Clear organizational structure
3. Use a Unique Commit Status Key in Shared Active Branch Mode
If multiple PromotionStrategies share the same active branch (via PromotionStrategy.spec.activePath), commit statuses
for different apps can target the same active commit SHA. In this setup, controllers should use distinct
CommitStatusLabel keys per app/controller domain (for example argocd-health-payments), so gating remains isolated.
Existing Controllers
ArgoCDCommitStatus Controller
Uses ArgoCDCommitStatus.spec.key (default argocd-health).
TimedCommitStatus Controller
Uses TimedCommitStatus.spec.key (default timer).
Additional Best Practices
Owner References
Always set controller references for proper garbage collection:
if err := ctrl.SetControllerReference(owner, &commitStatus, r.Scheme); err != nil {
return fmt.Errorf("failed to set controller reference: %w", err)
}
This ensures that when the parent resource (e.g., ArgoCDCommitStatus, TimedCommitStatus) is deleted, all associated CommitStatus resources are automatically cleaned up.
Naming Convention
SCM status name (CommitStatus.spec.name)
Built-in controllers use {spec.key}/{environment-branch} for the name shown in the SCM (for example argocd-health/environment/staging). The key is the same value the PromotionStrategy references in activeCommitStatuses or proposedCommitStatuses.
commitStatus.Spec.Name = key + "/" + branch
commitStatus.Labels[promoterv1alpha1.CommitStatusLabel] = key
Kubernetes resource name
Use utils.CommitStatusResourceName for the CommitStatus resource name (distinct from spec.name):
resourceName := utils.CommitStatusResourceName(ctx, parent, branch)
parent may omit TypeMeta.Kind (common on typed objects from client Get in tests); the helper resolves Kind from the runtime scheme via apiutil.GVKForObject and panics only if resolution fails.
The kebab-case gate stem is derived from the parent Kind (for example TimedCommitStatus → timed, WebRequestCommitStatus → web-request, ArgoCDCommitStatus → argo-cd). The same stem is used for the resource-name suffix and for the parent-gate label key (promoter.argoproj.io/<stem>-commit-status).
The helper applies KubeSafeUniqueName to parent.metadata.name-branch-<stem>.
Example: my-app-environment-development-timed-<hash> (or ...-argo-cd-<hash> for Argo CD gates)
Set the standard CommitStatus labels with utils.CommitStatusStandardLabels(parent, branch, key) (parent gate, environment, and commit-status key).
Orphan cleanup
At the end of each reconcile, call utils.CleanupOrphanedCommitStatuses with the current valid []*CommitStatus slice. The helper derives the parent gate label from the owner's Kind and removes owned CommitStatuses that are no longer in the valid set.
Custom Labels
You can add additional labels specific to your controller, but the standard labels above are recommended:
commitStatus.Labels["my-controller.example.com/custom-info"] = "value"
Why These Labels Matter
- Discoverability: Users and tools can easily find all commit statuses created by a specific controller
- Debugging: When troubleshooting, you can quickly identify which controller created a commit status
- Filtering: Efficient queries like "show me all timer gates for the staging environment"
- Consistency: Standard labels create a predictable API across all commit status controllers
- Integration: Other controllers and tools can rely on these labels for their logic
Designing Good Commit Status Descriptions
The Description field in a CommitStatus is displayed to users in their SCM provider (GitHub, GitLab, etc.). Well-crafted descriptions improve user experience by clearly communicating what's happening and reducing confusion.
Use Action-Oriented Language
Use active, present-tense language to convey the current state of the system. This creates a sense that the system is continuously monitoring and reporting status, not just recording historical events.
Philosophy: Commit statuses should describe "what is happening now" rather than "what happened." This applies to all phases:
- Pending: Use progressive verbs (-ing) to show active work
- Success: Use present tense to describe the current healthy state
- Failure: Use present tense to describe the current problem
Guidelines by Phase
Pending Phase
Use present participles (verbs ending with -ing) to indicate active monitoring or processing:
- ✅ Use: "Waiting for approval"
- ❌ Avoid: "Approval pending"
- ✅ Use: "Checking application health"
- ❌ Avoid: "Health check in progress"
- ✅ Use: "Syncing to environment"
- ❌ Avoid: "Sync scheduled"
- ✅ Use: "Monitoring deployment status"
- ❌ Avoid: "Deployment queued"
Success Phase
Use present tense to describe the current state:
- ✅ Use: "All applications are healthy"
- ❌ Avoid: "Health checks passed"
- ✅ Use: "Approval is granted"
- ❌ Avoid: "Approved successfully"
- ✅ Use: "Time requirement is met"
- ❌ Avoid: "Timer completed"
- ✅ Use: "Environment is synced"
- ❌ Avoid: "Sync finished"
Failure Phase
Use present tense to describe the current problem:
- ✅ Use: "Applications are degraded"
- ❌ Avoid: "Health check failed"
- ✅ Use: "Approval is denied"
- ❌ Avoid: "Approval was rejected"
- ✅ Use: "Deployment is unhealthy: pods not ready"
- ❌ Avoid: "Deployment failed"
- ✅ Use: "Sync is timing out"
- ❌ Avoid: "Sync timed out"
Additional Tips
- Be specific: Include relevant details when failures occur (e.g., "Failed health check: 2/5 replicas ready")
- Stay concise: Keep descriptions under 100 characters when possible
- Avoid jargon: Use terminology that's clear to all users, not just Kubernetes experts
- Be consistent: Use similar phrasing across your controller for related states
- Include context: When helpful, mention the environment or application name
Example Implementation
// Pending phase - active monitoring
commitStatus.Spec.Phase = promoterv1alpha1.CommitPhasePending
commitStatus.Spec.Description = fmt.Sprintf("Waiting for %d/%d applications to sync", synced, total)
// Success phase - current state
commitStatus.Spec.Phase = promoterv1alpha1.CommitPhaseSuccess
commitStatus.Spec.Description = fmt.Sprintf("All %d applications are healthy", total)
// Failure phase - current problem
commitStatus.Spec.Phase = promoterv1alpha1.CommitPhaseFailure
commitStatus.Spec.Description = fmt.Sprintf("Applications are degraded: %s", errorDetail)
Watching PromotionStrategy
Gate CRDs reference a PromotionStrategy via spec.promotionStrategyRef. When that strategy changes (for example environments or commit-status keys are updated), every gate that references it should reconcile so it can refresh owned CommitStatus resources.
Built-in controllers watch PromotionStrategy and enqueue reconcile requests for matching gates. Use a cache field index on .spec.promotionStrategyRef.name so those lookups stay efficient and so client.MatchingFields works on the manager's cached client.
When to register an index
Register a field index when your controller (or another component using the same informer cache) lists gate resources with:
client.MatchingFields{".spec.promotionStrategyRef.name": promotionStrategyName}
Without IndexField registration, that List fails at runtime. Listing the whole namespace and filtering in memory works but does not scale and is easy to miss in unit tests.
This applies to cache-backed client.Client calls (the default from mgr.GetClient()). It is separate from CRD selectableFields, which affect API-server field selectors on direct API reads.
Register the index
In this repository, gate kinds share controller.PromotionStrategyRefField and controller.RegisterGatePromotionStrategyRefFieldIndexes in internal/controller/fieldindex.go. Registration runs from any process that serves cache-backed lists filtered by promotion strategy:
- Manager —
PromotionStrategyReconciler.SetupWithManager(alongside theCommitStatus.spec.shaindex). - Dashboard API —
internal/apiserver/run.goon the read cache before bundle assembly.
If you add a new in-repo gate kind, extend PromotionStrategyRefIndexValues and RegisterGatePromotionStrategyRefFieldIndexes with your type. See Maintaining CRDs.
For a controller outside this repo, register the index in your gate reconciler's SetupWithManager:
const promotionStrategyRefField = ".spec.promotionStrategyRef.name"
func (r *MyCommitStatusReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
if err := mgr.GetFieldIndexer().IndexField(ctx, &promoterv1alpha1.MyCommitStatus{}, promotionStrategyRefField,
func(rawObj client.Object) []string {
cs := rawObj.(*promoterv1alpha1.MyCommitStatus)
return []string{cs.Spec.PromotionStrategyRef.Name}
},
); err != nil {
return fmt.Errorf("failed to set field index %s: %w", promotionStrategyRefField, err)
}
// …
}
Watch handler
Watch PromotionStrategy and list gates with MatchingFields instead of scanning the namespace:
func (r *MyCommitStatusReconciler) enqueueMyCommitStatusForPromotionStrategy() handler.EventHandler {
return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
ps, ok := obj.(*promoterv1alpha1.PromotionStrategy)
if !ok {
return nil
}
var list promoterv1alpha1.MyCommitStatusList
if err := r.List(ctx, &list,
client.InNamespace(ps.Namespace),
client.MatchingFields{promotionStrategyRefField: ps.Name},
); err != nil {
log.FromContext(ctx).Error(err, "failed to list MyCommitStatus resources")
return nil
}
requests := make([]ctrl.Request, 0, len(list.Items))
for i := range list.Items {
requests = append(requests, ctrl.Request{
NamespacedName: client.ObjectKeyFromObject(&list.Items[i]),
})
}
return requests
})
}
Wire the watch in SetupWithManager:
ctrl.NewControllerManagedBy(mgr).
For(&promoterv1alpha1.MyCommitStatus{}).
Watches(&promoterv1alpha1.PromotionStrategy{}, r.enqueueMyCommitStatusForPromotionStrategy()).
Complete(r)
Built-in gate controllers follow this pattern; see timedcommitstatus_controller.go for a reference implementation.
Tests
Fake clients used in tests must register the same index, or MatchingFields lists will fail:
fake.NewClientBuilder().
WithScheme(scheme).
WithIndex(&promoterv1alpha1.MyCommitStatus{}, promotionStrategyRefField, indexFunc).
Build()
In-tree API server tests use newFakeClientBuilder() in internal/apiserver/builder_test.go, which registers indexes for all gate kinds.
Triggering Reconciliation of ChangeTransferPolicies
When your commit status controller detects important state transitions (e.g., a gate transitioning from pending to success), you may want to trigger immediate reconciliation of the affected ChangeTransferPolicy to minimize promotion latency.
The Pattern
Use the EnqueueCTP function to trigger reconciliation of the specific ChangeTransferPolicy for the environment that changed. This approach directly enqueues a reconcile request without modifying the CTP object.
Controller Setup
Add the EnqueueCTP field to your reconciler struct:
type MyCommitStatusReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
SettingsMgr *settings.Manager
// EnqueueCTP is a function to enqueue CTP reconcile requests without modifying the CTP object.
EnqueueCTP CTPEnqueueFunc
}
Triggering Reconciliation
Create a method to trigger CTP reconciliation when state transitions:
func (r *MyCommitStatusReconciler) touchChangeTransferPolicies(ctx context.Context, ps *promoterv1alpha1.PromotionStrategy, transitionedEnvironments []string) {
logger := log.FromContext(ctx)
for _, envBranch := range transitionedEnvironments {
// Generate the ChangeTransferPolicy name using the same logic as the PromotionStrategy controller
ctpName := utils.KubeSafeUniqueName(utils.GetChangeTransferPolicyName(ps.Name, envBranch))
logger.Info("Triggering ChangeTransferPolicy reconciliation",
"changeTransferPolicy", ctpName,
"branch", envBranch)
// Use the enqueue function to trigger reconciliation
if r.EnqueueCTP != nil {
r.EnqueueCTP(ps.Namespace, ctpName)
}
}
}
Wiring in main.go
Pass the enqueue function when creating your controller:
if err := (&controller.MyCommitStatusReconciler{
Client: localManager.GetClient(),
Scheme: localManager.GetScheme(),
Recorder: localManager.GetEventRecorderFor("MyCommitStatus"),
SettingsMgr: settingsMgr,
EnqueueCTP: ctpReconciler.GetEnqueueFunc(),
}).SetupWithManager(processSignalsCtx, localManager); err != nil {
panic(fmt.Errorf("unable to create MyCommitStatus controller: %w", err))
}
Standard Events
Commit status controllers should emit a consistent set of Kubernetes events so users can observe gate
behavior with kubectl get events. The built-in controllers (ArgoCD, Timed, Git, WebRequest) all emit the
following three standard events; custom controllers should do the same. See the
events reference for the per-controller tables.
| Event Reason | Type | When to emit |
|---|---|---|
CommitStatusPhaseChanged |
Normal / Warning | The phase computed for an environment changes from the phase computed on the previous reconcile. Emit only on a transition, after the new phase is persisted. Use Warning when the new phase is failure, Normal otherwise. |
CommitStatusesNotReady |
Warning | One or more of the CommitStatus resources the controller manages is not Ready. |
OrphanedCommitStatusDeleted |
Normal | An owned CommitStatus no longer applies (e.g., the environment/branch was removed) and was deleted during orphan cleanup. |
Validation
To verify your controller sets labels correctly, check a created CommitStatus:
kubectl get commitstatus <name> -o yaml
You should see:
metadata:
labels:
promoter.argoproj.io/commit-status: "your-key"
promoter.argoproj.io/environment: "environment-development"