Skip to content

Maintaining CRDs

Use this checklist whenever you add a new API type or make a breaking or structural change to an existing CRD (new fields, renames, validation changes). It keeps user docs, OpenAPI metadata, and tests aligned.

Checklist for each CRD kind

1. Example manifest in internal/controller/testdata/

Add or update internal/controller/testdata/<Kind>.yaml (PascalCase filename matching the kind, for example PromotionStrategy.yaml).

  • Include a realistic spec with as many fields populated as practical so the example doubles as documentation and catches schema drift.
  • When the type has a status subresource, populate representative status fields too (conditions, phase, nested state) so status shape changes break tests early.
  • Use valid values that pass kubebuilder validation markers on the Go types.
  • Keep names, namespaces, and references consistent with how controllers and envtest suites create related objects.

2. Embed the example on the CRD Specs page

Add or update a ### <Kind> section in docs/crd-specs.md with a short narrative, then include the example:

### PromotionStrategy

…description…

```yaml
apiVersion: promoter.argoproj.io/v1alpha1
kind: PromotionStrategy
metadata:
  name: example-promotion-strategy
spec:
  gitRepositoryRef:
    name: example-git-repo
  activeCommitStatuses:
    - key: argocd-app-health
  proposedCommitStatuses:
    - key: security-scan
  environments:
    - branch: environment/dev
    - branch: environment/test
    - branch: environment/prod
      autoMerge: false
      activeCommitStatuses:
      - key: performance-test
      proposedCommitStatuses:
      - key: deployment-freeze
status:
  observedGeneration: 123 # compare with metadata.generation to detect stale status
  conditions:
    # The Ready condition indicates that the resource has been successfully reconciled, when there is an error during
    # reconciliation, the condition will be False with a reason of ReconciliationError. When we successfully reconcile the resource,
    # the condition will be True with a reason of ReconciliationSuccess. The Ready condition is essentially a way to show reconciliation
    # errors to the user. This condition exists on all resources that have reconciliation logic.
    - type: Ready
      lastTransitionTime: 2023-10-01T00:00:00Z
      message: Reconciliation succeeded
      reason: ReconciliationSuccess # ReconciliationSuccess, ReconciliationError, ChangeTransferPolicyNotReady, or PreviousEnvironmentCommitStatusNotReady
      status: "True" # "True," "False," or "Unknown"
      observedGeneration: 123
  environments:
  - branch: environment/dev
    # The proposed and active fields are pulled directly from the status of the environment's ChangeTransferPolicy resource.
    proposed:
      dry:
        author: "Author Name <author@example.com>"
        body: "Body of the commit message (i.e. excluding the subject line)"
        commitTime: 2023-10-01T00:00:00Z
        repoURL: "https://git.example.com/org/repo.git"
        sha: "abcdef1234567890abcdef1234567890abcdef12"
        subject: "chore: Example commit subject line"
        references:
          # An array of reference commits that where used to create the dry commit. This is used generally via CI systems to add tracing information to the commit.
          # For example, in a code/deployment repo setup this could contain the commit from the code repo that triggered the deployment commit in the deployment repo.
          - commit:
              author: '"Zach Aller" <code@example.com>'
              body: |-
                Commit message of the code commit

                Signed-off-by: Author Name <author@example.com>
              date: '2025-07-19T17:50:18Z'
              repoURL: https://github.com/org/repo
              sha: 9d5ccef278218dea4caa903bb6abb9ed974a1d90
              subject: This change fixes a bug in the code
      hydrated:
        # The hydrated field contains the same fields as proposed.dry, but the contents correspond to a hydrated commit
        # instead of a dry commit.
      note:
        # Dry SHA from the hydrator git note on the proposed hydrated commit (only drySha is populated today)
        drySha: "abcdef1234567890abcdef1234567890abcdef12"
      commitStatuses:
        - key: example-key
          phase: pending # pending, success, or failure
          url: https://example.com/checks/example-key
          description: Waiting for check
    active:
    # The active field contains the same fields as proposed.
    pullRequest:
      id: "848"
      state: open # open, closed, or merged; empty when externallyMergedOrClosed is true
      url: https://github.com/org/repo/pull/848
      prCreationTime: 2023-10-01T00:00:00Z
    history:
      # The history field contains a snapshot of each promotion that has occurred in the environment. The most recent promotion
      # is at the front of the list. The fields here are similar to those in proposed and active top level fields. They only differ in
      # that the proposed field does not contain a dry field, because in the context of history proposed becomes active when promoted.
      - active:
        # same as top level active
        proposed:
        # same as top level proposed but without dry field because in the context of history proposed.dry becomes active when promoted.
        pullRequest:
          # The pullRequest field contains information about the pull request that was merged to create this history item.
          id: '848'
          prCreationTime: '2025-08-04T19:50:15Z'
          url: https://github.com/org/repo/pull/848
    lastHealthyDryShas:
    - sha: "abcdef1234567890abcdef1234567890abcdef12"
      time: 2023-10-01T00:00:00Z
  - branch: environment/test
    # same fields as dev
  - branch: environment/prod
    # same fields as dev
```

MkDocs pulls the file in at build time via markdown_include (see mkdocs.yml). The heading text defines the anchor used by externalDocs (Material slugifies to lowercase, for example ### PromotionStrategy#promotionstrategy).

3. externalDocs on the root API type

On the //+kubebuilder:object:root=true struct in api/v1alpha1/<kind>_types.go, add a marker pointing at the stable docs URL for that section:

// +kubebuilder:externalDocs:url="https://gitops-promoter.readthedocs.io/en/stable/crd-specs/#promotionstrategy",description="CRD reference (examples and behavior)"

Regenerate CRDs with make manifests (or make build-installer). The URL must be a valid absolute URL; the fragment must match the CRD Specs heading anchor.

On Kubernetes 1.36+, clusters and kubectl can surface this link in kubectl explain as an EXTERNAL DOCS block (see kubernetes/kubernetes#136988). Older clusters still store the metadata in the CRD OpenAPI schema for other tooling.

4. Strict unmarshal test in the controller suite

In internal/controller/<kind>_controller_test.go (or the controller that owns the type), add:

//go:embed testdata/<Kind>.yaml
var test<Kind>YAML string

var _ = Describe("<Kind> Controller", func() {
    Context("When unmarshalling the test data", func() {
        It("should unmarshal the <Kind> resource", func() {
            err := unmarshalYamlStrict(test<Kind>YAML, &promoterv1alpha1.<Kind>{})
            Expect(err).ToNot(HaveOccurred())
        })
    })
    // …
})

unmarshalYamlStrict (in internal/controller/suite_test.go) unmarshals YAML and decodes with DisallowUnknownFields, so typos, removed fields, or wrong types in the example fail CI immediately.

Follow an existing controller test as a template, for example promotionstrategy_controller_test.go or commitstatus_controller_test.go.

If the type is reconciled and has status SSA behavior, also follow Maintaining resource status for field owners, observedGeneration, and status apply tests.

Regenerate and verify

After Go type and marker changes:

  1. make build-installer — CRD bases, deepcopy, applyconfiguration, extension icon styles, dist/install.yaml.
  2. go mod tidy if module deps changed.
  3. make test-parallel — includes the strict unmarshal tests above.
  4. make lint-docs if you edited docs/.

CI’s Check Codegen job runs make build-installer and fails on drift; see Continuous Integration.

API design reminders

  • Put validation on Go types with kubebuilder markers (// +kubebuilder:validation:…, CEL XValidation, and so on); do not hand-edit config/crd/bases/ except via generation.
  • Prefer // +k8s:immutable on fields that must not change after set (controller-gen v0.21+); it emits the same self == oldSelf rule as hand-written immutability XValidation (see PullRequest sourceBranch / targetBranch).
  • Use // +k8s:enum on a type Foo string plus const block only when every field typed Foo shares the same allowed values. Remove redundant field Enum markers only in that case. Do not put +k8s:enum on a type that is also used from status fields allowing "" or a subset of consts (controller-gen applies the type enum to all references; field Enum is not merged for extras like ""). Example in-tree: ContextMode on WebRequestCommitStatus. Plain string fields still use field Enum.
  • Express reconciler RBAC with // +kubebuilder:rbac on controllers; regenerate manifests rather than editing config/rbac/role.yaml by hand.
  • For SCM-facing types and commit-status controllers, see Adding an SCM Provider and Commit status development best practices.
Topic Page
User-facing CR reference CRD Specs
Status subresource / SSA Maintaining resource status
CI codegen checks Continuous Integration
Contributor overview Contributing overview