johanneskueber.com

OCI-First GitOps Promotion with Flux, Kargo, and Renovate

OCI-First GitOps Promotion with Flux, Kargo, and Renovate

This article describes a promotion architecture where the deployment definition lives in Git but is never deployed from Git. Instead, every merge builds a signed OCI artifact containing the deployment manifests, and that artifact — immutable, versioned, digest-pinned — is what promotes through dev → staging → prod and what Flux actually deploys.

Three tools, one responsibility each:

  • Renovate updates the deployment definition in Git — new application image versions, third-party chart bumps, dependency updates. Its merged PRs are how change enters the pipeline.
  • Kargo promotes the resulting OCI artifact across environments, running automated tests at each gate.
  • Flux deploys the artifact into each cluster, verifying its signature before reconciling.

No human touches the routine path. Humans appear only at the optional prod approval gate and in emergencies.


Why deploy from OCI instead of Git?

The conventional GitOps setup points Flux directly at the Git repository holding the manifests. It works, but it has structural weaknesses that show up at promotion time:

Git branches and directories are mutable. “What exactly is running in staging” is answered by a commit SHA plus a directory path plus whatever Kustomize resolves at that commit — reconstructible, but not a single immutable unit. An OCI artifact is one digest. What’s running is sha256:abc..., full stop.

Promotion as Git diffs accumulates noise. Promoting by editing image tags inside YAML produces thousands of bot commits interleaved with human changes. Promoting by bumping one ref.tag pointer per environment keeps the audit trail readable: each promotion is exactly one one-line commit.

Signing Git content is awkward; signing OCI is solved. Cosign keyless signing, registry-side storage of signatures, and Flux’s native OCIRepository.verify give you an end-to-end verification chain with the same tooling already used for container images. Config and code ride the same security infrastructure.

Rollback becomes deterministic. “Point staging back at artifact 0.5.118” is one field change to a known-good immutable unit. No git revert archaeology across interleaved commits.

The cost: a CI step between merge and deployability, and a registry as a runtime dependency for config (it already is one for images). For most teams that trade is clearly favorable.


The architecture

flowchart TB
  subgraph appRepo["app-myapp (Git)"]
    src["source code<br/>Dockerfile"]
  end

  img["registry/myapp:1.4.3<br/>(signed image)"]

  subgraph depRepo["deployment repo (Git)"]
    apps["apps/myapp/<br/>base/ + overlays/{dev,staging,prod}"]
    clusters["clusters/{dev,staging,prod}"]
    plat["kargo/ &nbsp;&nbsp; flux-system/"]
  end

  renovate["Renovate PRs"]
  kargoCommits["Kargo commits"]

  artifact["registry/deployments/myapp:0.5.123<br/>(signed config artifact,<br/>image tag baked in)"]

  subgraph kargo["Kargo Warehouse → Freight"]
    stages["Stages: dev → staging → prod<br/>promotion = bump ref.tag in clusters/&lt;env&gt;/ (Git commit)<br/>verification = AnalysisTemplates"]
  end

  flux["Flux per cluster: pull OCI artifact,<br/>verify cosign signature, reconcile"]
  admission["Admission controller re-verifies the<br/>workload image signature at pod start"]

  src -->|"CI: build, sign"| img
  renovate --> apps
  kargoCommits --> clusters
  depRepo -->|"CI on merge of apps/**: build, sign"| artifact
  img -.->|"referenced inside"| artifact
  artifact --> kargo
  kargo --> flux
  flux --> admission

The key insight: Git holds two different things with two different lifecycles.

  1. The deployment definition (apps/) — the Kustomize source. Humans and Renovate edit it; every merge produces a new artifact. This content is never consumed by Flux directly.
  2. The environment pointers (clusters/) — tiny manifests saying “dev runs artifact 0.5.123, prod runs 0.5.118.” Only Kargo writes here (plus humans in emergencies). Flux does consume this from Git — it’s the bootstrap layer that tells each cluster which artifact to pull.

Renovate and Kargo never write to the same path. Renovate owns apps/ (and app repos); Kargo owns clusters/.


Repository and registry layout

One deployment repository:

deployment/
├── apps/
│   └── myapp/
│       ├── base/
│       │   ├── deployment.yaml          # image tag here ← Renovate bumps this
│       │   ├── service.yaml
│       │   ├── networkpolicy.yaml
│       │   └── kustomization.yaml
│       └── overlays/
│           ├── dev/                     # env-specific: replicas, hostnames...
│           ├── staging/
│           └── prod/
├── clusters/
│   ├── dev/
│   │   └── myapp.yaml                   # OCIRepository ref.tag ← Kargo bumps this
│   ├── staging/
│   └── prod/
├── kargo/
│   ├── project.yaml
│   ├── warehouse.yaml
│   └── stages.yaml
├── flux-system/                          # bootstrap per cluster
├── .gitea/workflows/
│   └── build-deployment-artifact.yml
└── renovate.json

Registry layout:

registry.internal/
├── myapp:1.4.3                          # application images (from app repos)
├── deployments/myapp:0.5.123            # deployment artifacts (from this repo)
└── ...

App repositories stay as they are: source code, Dockerfile, CI that builds, signs, and pushes the application image. They need no knowledge of the deployment repo.

Access boundaries. Developers write to app repos only. Renovate writes to apps/ (via PRs). Kargo writes to clusters/ (direct commit or PR). The platform team owns kargo/, flux-system/, and clusters/ structurally. CODEOWNERS enforces this:

*                       @org/platform-team
/apps/myapp/            @org/myapp-team
/clusters/              @org/platform-team
/kargo/                 @org/platform-team
/flux-system/           @org/platform-team

Branch protection on main: required CODEOWNERS review, signed commits, required status checks (manifest lint, kustomize build dry-run), no force-push. The Renovate and Kargo bot identities are exempted from review on exactly the paths they own — their commits still pass status checks.


Step 1: Application images (app repo CI)

Standard build-sign-push. The only conventions that matter downstream:

  • Semver tags, immutable. myapp:1.4.3, never re-pushed. Include a -rc. or -dev. pre-release suffix if you build from non-release branches and want Renovate to ignore them.
  • Keyless cosign signing bound to the workflow’s OIDC identity:
1
2
3
4
5
# app-myapp/.gitea/workflows/build.yml (signing step)
- name: Sign image
  env:
    COSIGN_EXPERIMENTAL: '1'
  run: cosign sign --yes registry.internal/myapp@${DIGEST}
  • Registry push rights are CI-only. No human PATs can push tags. Each repo’s CI uses OIDC federation to the registry where supported; otherwise a scoped robot account per repo.

Step 2: Renovate brings the new version into the deployment definition

Renovate watches the registry and the deployment repo. When myapp:1.4.3 appears, it opens a PR bumping the tag in apps/myapp/base/deployment.yaml. This is the entry gate: a new application version exists in the world, but it enters the pipeline only when this PR merges.

renovate.json in the deployment repo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "kubernetes": { "fileMatch": ["apps/.+\\.ya?ml$"] },
  "packageRules": [
    {
      "description": "Own app images: auto-merge on green checks",
      "matchFileNames": ["apps/**"],
      "matchPackageNames": ["registry.internal/**"],
      "matchUpdateTypes": ["patch", "minor"],
      "automerge": true,
      "automergeType": "branch",
      "platformAutomerge": true
    },
    {
      "description": "Major version bumps of own apps: human review",
      "matchFileNames": ["apps/**"],
      "matchPackageNames": ["registry.internal/**"],
      "matchUpdateTypes": ["major"],
      "automerge": false
    },
    {
      "description": "Never touch the pointer plane — Kargo owns it",
      "matchFileNames": ["clusters/**"],
      "enabled": false
    },
    {
      "description": "Platform tooling: never auto-merge, soak 7 days",
      "matchPackageNames": ["fluxcd/**", "ghcr.io/akuity/kargo**", "renovate/**"],
      "automerge": false,
      "minimumReleaseAge": "7 days",
      "reviewers": ["team:platform-engineering"]
    }
  ]
}

Renovate also keeps doing its usual inbound work — base images in app-repo Dockerfiles, language dependencies, CI action versions, third-party Helm charts. All of that flows through the same gate: PR → merge → new deployment artifact → promotion pipeline. A cert-manager chart bump gets exactly the same dev → staging → prod gating as your own code, because everything that reaches a cluster is an artifact that went through the stages.

The Renovate bot uses a dedicated identity with a token scoped to the repos it manages, sourced from a secret store, rotated on schedule. Never an org-admin token.


Step 3: CI builds the signed deployment artifact

On every merge to main touching apps/**, CI packages the deployment definition into an OCI artifact:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# deployment/.gitea/workflows/build-deployment-artifact.yml
name: build-deployment-artifact
on:
  push:
    branches: [main]
    paths: ['apps/**']

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Compute version
        id: v
        run: |
          count=$(git rev-list --count HEAD -- apps/)
          echo "version=0.5.${count}" >> "$GITHUB_OUTPUT"

      - name: Validate manifests
        run: |
          for env in dev staging prod; do
            kustomize build apps/myapp/overlays/$env > /dev/null
          done

      - name: Install flux CLI
        run: curl -fsSL https://fluxcd.io/install.sh | sudo bash

      - name: Login to registry
        run: echo "${{ secrets.REGISTRY_TOKEN }}" \
             | docker login registry.internal -u ci --password-stdin

      - name: Push artifact
        run: |
          flux push artifact \
            oci://registry.internal/deployments/myapp:${{ steps.v.outputs.version }} \
            --path=./apps/myapp \
            --source="${{ gitea.server_url }}/${{ gitea.repository }}" \
            --revision="${{ gitea.sha }}"

      - name: Sign artifact
        env:
          COSIGN_EXPERIMENTAL: '1'
        run: |
          cosign sign --yes \
            registry.internal/deployments/myapp:${{ steps.v.outputs.version }}

Properties of the resulting artifact:

  • It contains the full Kustomize treebase/ plus all three overlays. Each environment’s Flux points at its overlay path inside the artifact. One artifact serves all environments; what differs per environment is which version of it they run.
  • The application image tag is baked in. The artifact at 0.5.123 references myapp:1.4.3 immutably. There is no separate “image promotion” — promoting the artifact promotes the image.
  • It’s signed with the deployment-build workflow’s identity — distinct from the app-image signing identity. Both signatures are verified downstream.
  • The Git commit is recorded in the artifact annotations (--revision), so any running artifact traces back to its exact source commit.

For multiple services, run one such artifact per service (deployments/myapp, deployments/api, …) with path-filtered triggers, or one combined artifact if your services deploy as a unit.


Step 4: Flux consumes the artifact, per environment

Each cluster’s pointer manifest in clusters/<env>/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# clusters/staging/myapp.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: myapp
  namespace: flux-system
spec:
  interval: 2m
  url: oci://registry.internal/deployments/myapp
  ref:
    tag: 0.5.118                          # ← the only line Kargo ever changes
  verify:
    provider: cosign
    matchOIDCIdentity:
      - issuer: '^https://gitea\.internal$'
        subject: '^https://gitea\.internal/org/deployment/.*build-deployment-artifact\.yml.*'
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: myapp
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: OCIRepository
    name: myapp
  path: ./overlays/staging                # this env's overlay, inside the artifact
  prune: true
  wait: true
  timeout: 10m
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: myapp
      namespace: myapp

Notes:

  • verify is mandatory, not optional. Flux refuses to reconcile an artifact not signed by the deployment-build workflow identity. A tampered or repushed artifact never reaches the cluster.
  • wait: true plus health checks make the Kustomization’s Ready condition mean “deployed and healthy,” which Kargo’s verification depends on.
  • Pin by tag, not semver range. The whole point is that promotion is an explicit, gated act. A semver range would auto-deploy every new artifact, bypassing the stages.
  • Flux itself is installed minimally: source-controller, kustomize-controller, helm-controller, notification-controller. The image-automation controllers are not installed — nothing in this architecture uses them.

The clusters/ directory itself is synced by Flux from Git (the classic bootstrap GitRepository + Kustomization per cluster). Optionally enable GitRepository.spec.verify so Flux also checks commit signatures on the pointer plane — then even Kargo’s commits must be signed by a trusted key.


Step 5: Kargo promotes the artifact through environments

Kargo runs in a dedicated management cluster — not in any workload cluster. Its Git credentials (write access to clusters/) are the highest-value secret in the stack: scope them to that path’s repo, source them from a secret store, rotate them, and audit the bot’s commit history.

Warehouse: watch the deployment artifact

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: kargo.akuity.io/v1alpha1
kind: Warehouse
metadata:
  name: myapp
  namespace: kargo-myapp
spec:
  subscriptions:
    - image:
        repoURL: registry.internal/deployments/myapp
        semverConstraint: '^0.0.0'
        discoveryLimit: 20

One subscription. Because the application image is baked into the artifact, the Freight is the single deployment artifact digest — the indivisible promotable unit. (If several services must move atomically, add their deployment artifacts as further subscriptions; Kargo then produces Freight only when all have new versions, and the bundle promotes as one.)

Stages: dev auto, staging gated, prod PR-gated

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
apiVersion: kargo.akuity.io/v1alpha1
kind: Stage
metadata:
  name: dev
  namespace: kargo-myapp
spec:
  requestedFreight:
    - origin: { kind: Warehouse, name: myapp }
      sources: { direct: true }
  promotionTemplate:
    spec:
      steps:
        - uses: git-clone
          config:
            repoURL: https://gitea.internal/org/deployment
            checkout: [{ branch: main, path: ./out }]
        - uses: yaml-update
          as: bump
          config:
            path: ./out/clusters/dev/myapp.yaml
            updates:
              - key: spec.ref.tag
                value: ${{ imageFrom("registry.internal/deployments/myapp").Tag }}
        - uses: git-commit
          config: { path: ./out, message: 'dev: myapp → ${{ imageFrom("registry.internal/deployments/myapp").Tag }}' }
        - uses: git-push
          config: { path: ./out, targetBranch: main }
  verification:
    analysisTemplates:
      - name: dev-smoke-tests
---
apiVersion: kargo.akuity.io/v1alpha1
kind: Stage
metadata:
  name: staging
  namespace: kargo-myapp
spec:
  requestedFreight:
    - origin: { kind: Warehouse, name: myapp }
      sources: { stages: [dev] }          # only Freight verified at dev
  promotionTemplate:
    spec:
      steps:
        # identical, targeting clusters/staging/myapp.yaml
        - uses: git-clone
          config:
            repoURL: https://gitea.internal/org/deployment
            checkout: [{ branch: main, path: ./out }]
        - uses: yaml-update
          config:
            path: ./out/clusters/staging/myapp.yaml
            updates:
              - key: spec.ref.tag
                value: ${{ imageFrom("registry.internal/deployments/myapp").Tag }}
        - uses: git-commit
          config: { path: ./out, message: 'staging: myapp → ${{ imageFrom("registry.internal/deployments/myapp").Tag }}' }
        - uses: git-push
          config: { path: ./out, targetBranch: main }
  verification:
    analysisTemplates:
      - name: staging-integration
      - name: staging-smoke
      - name: staging-blackbox
      - name: staging-whitebox
---
apiVersion: kargo.akuity.io/v1alpha1
kind: Stage
metadata:
  name: prod
  namespace: kargo-myapp
spec:
  requestedFreight:
    - origin: { kind: Warehouse, name: myapp }
      sources: { stages: [staging] }      # only Freight verified at staging
  promotionTemplate:
    spec:
      steps:
        - uses: git-clone
          config:
            repoURL: https://gitea.internal/org/deployment
            checkout: [{ branch: main, path: ./out }]
        - uses: yaml-update
          config:
            path: ./out/clusters/prod/myapp.yaml
            updates:
              - key: spec.ref.tag
                value: ${{ imageFrom("registry.internal/deployments/myapp").Tag }}
        - uses: git-commit
          config: { path: ./out, message: 'prod: myapp → ${{ imageFrom("registry.internal/deployments/myapp").Tag }}' }
        - uses: git-open-pr               # prod goes through a PR
          as: pr
          config:
            path: ./out
            sourceBranch: kargo/prod-${{ ctx.freight.name }}
            targetBranch: main
        - uses: git-wait-for-pr
          config: { prNumber: ${{ outputs.pr.prNumber }} }

Auto-promotion policy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: kargo.akuity.io/v1alpha1
kind: ProjectConfig
metadata:
  name: myapp
  namespace: kargo-myapp
spec:
  promotionPolicies:
    - stageSelector: { name: dev }
      autoPromotionEnabled: true
    - stageSelector: { name: staging }
      autoPromotionEnabled: true
    - stageSelector: { name: prod }
      autoPromotionEnabled: true          # set false for a human approval gate

With prod: true, the pipeline is fully hands-off: staging tests green → Kargo opens the prod PR → the Git platform auto-merges on green status checks → Flux reconciles prod. With prod: false, Freight becomes eligible when staging verifies, and a human triggers kargo promote --stage prod --freight <name> (or applies a Promotion YAML — everything in Kargo is CRDs, so the approval itself can be a reviewed Git commit). Either way the PR step keeps prod changes inside branch protection, CODEOWNERS, and the normal audit trail.

Verification: the tests at each gate

Tests live in AnalysisTemplate resources. The workhorse pattern is a Job running the suite against the just-deployed environment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: staging-integration
  namespace: kargo-myapp
spec:
  metrics:
    - name: integration
      count: 1
      failureLimit: 0
      provider:
        job:
          spec:
            backoffLimit: 0
            activeDeadlineSeconds: 1800
            ttlSecondsAfterFinished: 600
            template:
              spec:
                restartPolicy: Never
                serviceAccountName: verification-runner
                containers:
                  - name: tests
                    image: registry.internal/tests/integration:latest
                    env:
                      - { name: TARGET, value: https://myapp.staging.internal }
                    envFrom:
                      - secretRef: { name: staging-test-credentials }   # via External Secrets
                    command: ['/run-tests.sh', '--suite=full']
                    securityContext:
                      runAsNonRoot: true
                      allowPrivilegeEscalation: false
                      capabilities: { drop: [ALL] }

All listed templates must pass before Freight is marked verified at a stage — multiple entries are AND-gated. Two other useful providers:

  • Prometheus soak gatescount: 30, interval: 1m sampling an error-rate query gives you “healthy for 30 minutes” before prod eligibility, with failureCondition to fail fast.
  • Existing CI suites — a thin Job that dispatches a Gitea Actions workflow and polls for its conclusion lets test suites stay in CI while Kargo remains the source of truth for verification state.

Verification Jobs get their credentials from environment-specific External Secrets scoped to the verification namespace — never the application’s runtime secrets.


What changes per environment — and what doesn’t

The artifact contains all overlays; the environment pointer selects one. So:

  • Shared config (base manifests, NetworkPolicies, sidecars, probes) lives in base/, is part of the artifact, and promotes — a NetworkPolicy edit produces a new artifact version that rides the same dev → staging → prod gates as a code change. No config shortcut to prod.
  • Environment-specific config (replicas, hostnames, resource limits, log levels) lives in overlays/<env>/, is also part of the artifact, but only takes effect in its environment. Note the subtlety: editing the prod overlay still produces a new artifact that must travel through dev and staging first. Dev and staging won’t exercise the prod overlay’s content, but the artifact version carrying it gets verified anyway. This is a feature (prod overlay changes can’t skip the pipeline), at the cost of promotion latency for prod-only tweaks.
  • Secrets are never in the artifact. The artifact carries ExternalSecret references; each environment’s operator resolves them against its own vault path. Values never touch Git or the registry.

The security chain, end to end

Four independent verification layers, each failing closed:

#LayerMechanismDefeats
1App image signaturecosign keyless at build CItampered/injected application images
2Deployment artifact signaturecosign keyless at deployment CI; Flux OCIRepository.verifytampered manifests, repushed artifacts, rogue registry writes
3Pointer commit signature (optional)signed Kargo/Renovate commits; Flux GitRepository.verify on clusters/stolen Git credentials without the signing key
4Admission controlSigstore policy-controller / Kyverno verifying layer-1 signatures at pod startanything that bypassed layers 1–3, including hand-edited pointers

An attacker must compromise the CI OIDC identities (1, 2), the signing keys (3), and the admission policy (4) to land an unauthorized workload in prod. Each layer is independently operated and independently auditable.

Registry hardening completes the picture: authenticated pulls, CI-only pushes via per-workflow identities, immutable tags enforced where the registry supports it.


Emergency procedures

The routine path needs no humans; these are for when it does.

Pause promotion — stop Kargo from moving anything further:

1
2
kargo pause stage prod --project myapp        # one stage
kargo pause project myapp                     # everything

Roll back an environment — point it at the last good artifact. Either through Kargo (preferred, keeps Freight state consistent):

1
kargo promote --stage prod --freight <previous-good-freight>

or by hand: edit spec.ref.tag in clusters/prod/myapp.yaml to the known-good version, commit (signed, through the PR process), and pause the stage so Kargo doesn’t immediately re-promote the bad Freight. Mark the bad Freight failed so it never becomes eligible again:

1
2
kargo verify-freight --project myapp --stage staging \
  --freight <bad-freight> --result fail

Freeze everything — suspend Flux reconciliation while you think:

1
2
flux suspend kustomization myapp -n flux-system          # one workload
flux suspend source git deployment -n flux-system        # the whole pointer plane

Existing workloads keep running; nothing new is applied.

Break-glass direct fix — if you must kubectl a hotfix: suspend the Kustomization first (or Flux will revert you within minutes), apply the fix, open a tracking issue, and reconcile Git/artifact state with the cluster within the same incident. Drift between the cluster and its declared artifact is the most common source of GitOps post-mortems. Note that admission control still applies — a hotfix image must carry a valid layer-1 signature, which is exactly the safety net working as intended.

The self-upgrade caveat. Kargo cannot promote its own upgrade (circular), and a broken Flux can’t fix Flux. Platform tooling (Flux, Kargo, Renovate) is updated by the platform team via reviewed PRs with the 7-day soak from the Renovate config above — manually, one environment at a time, outside the artifact pipeline.


Need to know

  • Prod-only overlay tweaks take the long road. A replica-count change for prod still builds an artifact and walks through dev and staging verification before the prod pointer moves. Deliberate, but it adds latency to trivial changes.
  • The registry is now availability-critical for deploys. Flux can’t reconcile a new artifact if the registry is down (running workloads are unaffected). Treat the registry like the tier-0 service it already was for images.
  • Kargo-with-Flux lacks the direct-sync integration Kargo has with Argo CD. Promotion latency includes Flux’s pull interval (mitigate with short interval on OCIRepository, or notification-controller webhooks).
  • Stateful workloads still need expand-contract discipline. The artifact promotes manifests; nobody rolls back a schema. Migrations run as Jobs ordered via dependsOn or Helm hooks, in backwards-compatible phases, tested against realistic data in staging.
  • Cluster-scoped cross-cutting changes (CRD installs, Pod Security Standard bumps) don’t fit a per-service artifact and are promoted manually, one environment at a time.

None of these are dealbreakers; all of them are things to know before you commit.


Summary

Change enters through Renovate: a new app image, a chart bump, a dependency update — each becomes a reviewed (or auto-merged) PR against the deployment definition in Git. Every merge produces a signed, immutable OCI artifact containing the full deployment tree with the image version baked in. Kargo treats that artifact as Freight and walks it through dev → staging → prod, bumping one ref.tag pointer per environment and running the test suites as verification at each gate, with prod going through a PR. Flux pulls the artifact per environment, verifies its signature before reconciling, and admission control re-verifies the application image at pod start.

Git remains the auditable control plane — but what runs in your clusters is never “a directory at a commit.” It’s a digest that was signed twice, tested at every stage, and promoted on purpose.


stat /posts/2026-06-19-oci-gitpos-automatic-promotion/

2026-06-19: Initial publication of the article