All posts

Kargo Promotion Steps: Building Advanced GitOps Promotion Pipelines

· Noah Burrell · 19 min read
kubernetes kargo gitops argo-cd automation
Kargo promotion steps pipeline

My introduction to Kargo covered the high-level architecture: Warehouses watch for new artifacts, Freight bundles them into promotable units, and Stages define how those units flow through your environments. The verification post added quality gates. And the v1.9 overview covered the latest platform features. But I have not yet gone deep on the piece that does the actual work: promotion steps.

Promotion steps are the individual operations that Kargo executes when it promotes Freight to a Stage. They are where the rubber meets the road. Cloning a Git repo, updating an image tag in a values file, pushing to a branch, telling Argo CD to sync. Every one of those actions is a discrete step, and Kargo ships a library of over 40 built-in steps that cover Git operations, configuration management, infrastructure-as-code, CI/CD integration, and external service management. On top of that, PromotionTasks let you package step sequences into reusable units, and a purpose-built expression language lets steps share data with each other.

One thing to note before diving in: Kargo is open source, but a subset of the more advanced promotion steps are only available when running Kargo on the Akuity Platform. I will call these out as they come up so you know what requires a platform subscription and what ships with the open-source project.

This post walks through all of it. If you have been writing promotion templates with just git-clone, yaml-update, git-commit, git-push, and argocd-update, there is a lot more available to you.

Anatomy of a Promotion Step

Every step in a promotion template follows the same structure:

steps:
  - uses: git-clone
    as: clone
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      checkout:
        - branch: main
          path: ./src
    if: ${{ success() }}
    continueOnError: false
    retry:
      errorThreshold: 3
      timeout: 2m

The uses field selects the built-in step to run. The optional as field gives the step an alias, which is how downstream steps reference its outputs through expressions like $. The config block is step-specific and varies depending on which step you are using. Everything else controls execution behavior: if for conditional execution, continueOnError to prevent a failing step from killing the entire promotion, and retry for transient failures.

The retry block deserves a closer look. The errorThreshold sets how many consecutive failures Kargo tolerates before giving up. The default is 1, meaning any failure is final. Setting it to 3 means the step can fail twice and retry, only failing permanently on the third consecutive error. The timeout sets the maximum wall-clock time for all attempts combined. If you omit it, the step will retry indefinitely until the threshold is hit. For steps that call external services like GitHub or Jira, setting both values is good practice.

The Built-in Step Library

Kargo organizes its built-in steps into several categories. I will cover the most important ones here with practical examples.

Git Operations

These are the workhorses of most promotion pipelines. Nearly every pipeline starts with a git-clone and ends with a git-push.

git-clone checks out a repository and optionally checks out specific branches, tags, or commits. It supports sparse checkout if you only need a subset of the repository:

- uses: git-clone
  as: clone
  config:
    repoURL: https://github.com/my-org/my-app-config.git
    checkout:
      - branch: main
        path: ./src

The path field tells Kargo where to place the working tree in the shared workspace. This matters because multiple steps operate on the same filesystem during a promotion. If you clone into ./src, your yaml-update step needs to target files under ./src.

git-commit creates a commit from working tree changes. You can customize the author name and email, and the commit message supports expressions:

- uses: git-commit
  as: commit
  config:
    path: ./src
    message: "promote ${{ ctx.stage }}: update image to ${{ imageFrom('my-registry.io/my-app').Tag }}"
    author:
      name: Kargo Bot
      email: [email protected]

git-push pushes the committed changes. By default it pushes to the branch that was checked out, but you can specify a different target:

- uses: git-push
  as: push
  config:
    path: ./src

For workflows that require human review before changes land, git-open-pr creates a pull request and git-wait-for-pr blocks the promotion until it is merged:

- uses: git-open-pr
  as: open-pr
  config:
    repoURL: https://github.com/my-org/my-app-config.git
    sourceBranch: ${{ outputs.push.branch }}
    targetBranch: main
    title: "Promote ${{ ctx.stage }}: ${{ imageFrom('my-registry.io/my-app').Tag }}"
    description: |
      Automated promotion by Kargo.
      Stage: ${{ ctx.stage }}
      Freight: ${{ ctx.targetFreight.name }}

- uses: git-wait-for-pr
  config:
    repoURL: https://github.com/my-org/my-app-config.git
    prNumber: ${{ outputs['open-pr'].pr.id }}
  retry:
    errorThreshold: 1
    timeout: 24h

The git-wait-for-pr step will poll until the PR is merged or closed. The retry timeout of 24 hours gives your team a full day to review the change. If it is not merged within that window, the promotion fails.

Kargo v1.8 added git-merge-pr for cases where you want to merge the PR programmatically rather than waiting for a human:

- uses: git-merge-pr
  config:
    repoURL: https://github.com/my-org/my-app-config.git
    prNumber: ${{ outputs['open-pr'].pr.id }}

Finally, git-clear removes all files from a Git working tree while preserving the .git directory. This is useful when you want to completely replace the contents of a branch rather than updating individual files. A common pattern is to clone a stage-specific branch, clear it, render fresh configuration into it, then commit and push:

- uses: git-clear
  config:
    path: ./src

Configuration Management

These steps update the configuration files that Argo CD reads. Which one you use depends on how your configuration is structured.

yaml-update is the most common. It sets values in YAML files using dotted key paths:

- uses: yaml-update
  config:
    path: ./src/envs/staging/values.yaml
    updates:
      - key: image.tag
        value: ${{ imageFrom('my-registry.io/my-app').Tag }}
      - key: image.digest
        value: ${{ imageFrom('my-registry.io/my-app').Digest }}

yaml-parse does the opposite. It extracts values from YAML files so you can use them in later steps:

- uses: yaml-parse
  as: current
  config:
    path: ./src/envs/staging/values.yaml
    outputs:
      - name: currentTag
        fromExpression: image.tag

You can then reference ${{ outputs.current.currentTag }} in downstream steps. Here, current is the step alias (from as: current) and currentTag is the output name defined in the step's outputs config.

For Kustomize-based projects, kustomize-set-image updates the image references in a kustomization.yaml without touching other fields:

- uses: kustomize-set-image
  config:
    path: ./src/envs/staging
    images:
      - image: my-registry.io/my-app
        tag: ${{ imageFrom('my-registry.io/my-app').Tag }}

kustomize-build renders the Kustomize output to a file, which is useful when your Argo CD Application is configured to apply a pre-rendered manifest rather than running Kustomize at sync time:

- uses: kustomize-build
  config:
    path: ./src/envs/staging
    outPath: ./src/envs/staging/rendered.yaml

For Helm-based projects, helm-update-chart bumps dependency versions in a Chart.yaml. Given a chart with a dependency block like this:

# Chart.yaml
apiVersion: v2
name: my-app-umbrella
version: 0.1.0
dependencies:
  - name: backend
    version: 1.2.3
    repository: https://charts.my-org.com

The following step updates the version field of the matching dependency to whatever version the current Freight carries:

- uses: helm-update-chart
  config:
    path: ./src/charts/my-app-umbrella
    charts:
      - repository: https://charts.my-org.com
        name: backend
        version: ${{ chartFrom('https://charts.my-org.com', 'backend').Version }}

Kargo matches on the repository and name fields to find the right dependency entry, then writes the new version value in place. The rest of the Chart.yaml is left untouched.

And helm-template renders a Helm chart to static YAML:

- uses: helm-template
  config:
    path: ./src/charts/my-app
    releaseName: my-app
    namespace: my-namespace
    valuesFiles:
      - ./src/envs/staging/values.yaml
    outPath: ./src/envs/staging/rendered.yaml

There is also json-update and json-parse for JSON files, and hcl-update for HCL/OpenTofu configuration files. Note that hcl-update is Akuity Platform only.

Argo CD Integration

argocd-update is the step that triggers Argo CD to sync after your configuration changes have been pushed. It is unique among Kargo's built-in steps because it also registers a health check that Kargo monitors after the promotion completes:

- uses: argocd-update
  config:
    apps:
      - name: my-app-staging
        sources:
          - repoURL: https://github.com/my-org/my-app-config.git
            desiredRevision: ${{ outputs.push.commit }}

The name field references an existing Argo CD Application by name. The desiredRevision field pins that Application to the exact commit that your promotion produced. This prevents a race condition where another commit could land between your push and Argo CD's sync.

You can update multiple Applications in a single step if your promotion touches configuration for several services:

- uses: argocd-update
  config:
    apps:
      - name: frontend-staging
        sources:
          - repoURL: https://github.com/my-org/my-app-config.git
            desiredRevision: ${{ outputs.push.commit }}
      - name: backend-staging
        sources:
          - repoURL: https://github.com/my-org/my-app-config.git
            desiredRevision: ${{ outputs.push.commit }}

If you would rather not set these fields imperatively through argocd-update, another option is to use an app-of-apps pattern where your parent Application manages child Applications defined in Git. In that model, your promotion steps update the child Application manifests directly in the repository and Argo CD picks up the changes through its normal sync cycle. That approach is a topic for its own post, but it is worth knowing it exists as an alternative.

CI/CD Integration

Sometimes a promotion needs to trigger an external CI pipeline and wait for it to finish before proceeding. Kargo ships two steps for GitHub Actions integration. Both are Akuity Platform only.

gha-dispatch-workflow triggers a GitHub Actions workflow using the workflow_dispatch event:

- uses: gha-dispatch-workflow
  as: ci
  config:
    repoURL: https://github.com/my-org/my-app.git
    ref: main
    workflowFile: integration-tests.yaml
    inputs:
      environment: staging
      image_tag: ${{ imageFrom('my-registry.io/my-app').Tag }}

gha-wait-for-workflow blocks until the dispatched workflow completes, with configurable success criteria:

- uses: gha-wait-for-workflow
  config:
    repoURL: https://github.com/my-org/my-app.git
    runID: ${{ outputs.ci.runID }}
  retry:
    timeout: 30m

This pattern is useful when you need to run tests that cannot run inside the target cluster or when you have an existing CI pipeline you want to reuse.

External Service Integration

Kargo includes steps for notifications, issue tracking, and change management. All of the steps in this section are Akuity Platform only.

send-message pushes notifications to Slack or email, which is useful for keeping teams informed about promotion progress:

- uses: send-message
  config:
    channel:
      kind: MessageChannel
      name: deploy-notifications
    message: |
      Promotion to *${{ ctx.stage }}* completed.
      Image: ${{ imageFrom('my-registry.io/my-app').Tag }}
      Freight: ${{ ctx.targetFreight.name }}

The send-message step references a MessageChannel (or ClusterMessageChannel) resource that you define separately in your Project namespace. The channel resource holds the Slack webhook URL, email server config, or other delivery details, keeping those credentials out of your promotion templates.

jira provides full Jira integration within a promotion. You can create, update, and delete issues, search with JQL, add or remove comments, and wait for an issue to reach a specific status before the promotion continues. Between the issue lifecycle management and the ability to block a promotion until a ticket reaches a specific status, it provides full issue tracking integration for teams that use Jira as part of their release process. Here is an example that adds a comment to a tracked issue:

- uses: jira
  config:
    credentials:
      secretName: jira-creds
    commentOnIssue:
      issueKey: ${{ vars.jiraTicket }}
      body: "Promoted to ${{ ctx.stage }} by Kargo. Freight: ${{ ctx.targetFreight.name }}"

The ServiceNow steps (snow-create, snow-update, snow-query-for-records, snow-wait-for-condition, snow-delete) provide full change management integration for organizations that require it. You can create a change request before deploying, wait for it to be approved, and update it with the deployment result.

File and Network Operations

http makes arbitrary HTTP/HTTPS requests, which is useful for calling webhooks or REST APIs that Kargo does not have a dedicated step for:

- uses: http
  as: webhook
  config:
    method: POST
    url: https://api.my-org.com/deploy-hooks
    headers:
      - name: Authorization
        value: "Bearer ${{ secret('deploy-webhook-token').token }}"
      - name: Content-Type
        value: application/json
    body: |
      {
        "stage": "${{ ctx.stage }}",
        "image": "${{ imageFrom('my-registry.io/my-app').Tag }}"
      }

http-download and oci-download fetch files from remote sources. This is useful when your promotion needs to pull a configuration bundle or policy file from a central repository:

- uses: oci-download
  config:
    imageRef: oci://my-registry.io/configs/global-policies:latest
    outPath: ./policies

copy, delete, and untar round out the file operations, letting you move files between working directories, clean up temporary artifacts, and extract archives.

The Expression Language

The expression language is what ties steps together. Delimited by ${{ }}, expressions let you reference outputs from previous steps, access Freight metadata, read Kubernetes Secrets and ConfigMaps, and conditionally control step execution.

Context Variables

Every promotion has access to a ctx object with metadata about the current promotion:

  • ctx.project is the Kargo Project name
  • ctx.stage is the target Stage name
  • ctx.promotion is the Promotion resource name
  • ctx.targetFreight.name is the Freight being promoted
  • ctx.meta.promotion.actor is who or what triggered the promotion

Freight Accessors

The expression functions imageFrom(), commitFrom(), and chartFrom() extract artifact details from the Freight being promoted:

# imageFrom() fields
${{ imageFrom('my-registry.io/my-app').RepoURL }}
${{ imageFrom('my-registry.io/my-app').Tag }}
${{ imageFrom('my-registry.io/my-app').Digest }}
${{ imageFrom('my-registry.io/my-app').Annotations }}

# commitFrom() fields
${{ commitFrom('https://github.com/my-org/my-app.git').RepoURL }}
${{ commitFrom('https://github.com/my-org/my-app.git').ID }}
${{ commitFrom('https://github.com/my-org/my-app.git').Branch }}
${{ commitFrom('https://github.com/my-org/my-app.git').Tag }}
${{ commitFrom('https://github.com/my-org/my-app.git').Message }}
${{ commitFrom('https://github.com/my-org/my-app.git').Author }}
${{ commitFrom('https://github.com/my-org/my-app.git').Committer }}

# chartFrom() fields
${{ chartFrom('https://charts.my-org.com', 'backend').RepoURL }}
${{ chartFrom('https://charts.my-org.com', 'backend').Name }}
${{ chartFrom('https://charts.my-org.com', 'backend').Version }}

These functions take a repository URL as the first argument and optionally a Freight origin as a second (or third) argument when you need to disambiguate artifacts that come from different Warehouses.

Secrets and ConfigMaps

Steps often need credentials that should not be stored in YAML files. The secret() and configMap() functions let you reference Kubernetes resources in the Project namespace:

${{ secret('github-creds').token }}
${{ configMap('deploy-config').targetCluster }}

There is also sharedSecret() for secrets in the shared resources namespace (defaults to kargo-shared-resources, configurable via the Helm chart's global.sharedResources.namespace parameter) that are available across all projects. Note that sharedSecret() only works with secrets labeled kargo.akuity.io/cred-type: generic.

Semantic Version Functions

semverParse() breaks a version string into its components. Given a tag like 2.4.1-rc.1+build.123, you can access each part individually:

# For a tag of "2.4.1-rc.1+build.123":
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).Major }}        # 2
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).Minor }}        # 4
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).Patch }}        # 1
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).Prerelease }}   # rc.1
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).Metadata }}     # build.123
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).String }}       # 2.4.1-rc.1+build.123

It also provides increment helpers that return a new version with the specified component bumped:

# Starting from "2.4.1":
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).IncMajor }}  # 3.0.0
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).IncMinor }}  # 2.5.0
${{ semverParse(imageFrom('my-registry.io/my-app').Tag).IncPatch }}  # 2.4.2

semverDiff() compares two version strings and returns the level at which they differ. The possible return values are Major, Minor, Patch, Metadata, None (identical), and Incomparable (invalid input):

${{ semverDiff('1.2.3', '2.0.0') }}  # Major
${{ semverDiff('1.2.3', '1.3.0') }}  # Minor
${{ semverDiff('1.2.3', '1.2.4') }}  # Patch
${{ semverDiff('1.2.3', '1.2.3') }}  # None

This is useful for conditional logic. For example, you could skip a promotion step if the version bump is only a patch.

Type Coercion

Expressions evaluate to strings by default but automatically coerce to JSON types when the result looks like a number, boolean, object, array, or null. If you need to force a string, wrap the expression with quote():

# This might be coerced to a number if the tag is "123"
value: ${{ imageFrom('my-registry.io/my-app').Tag }}

# This is always a string
value: ${{ quote(imageFrom('my-registry.io/my-app').Tag) }}

Conditional Execution

Kargo v1.3 introduced the if field on steps, which lets you control whether a step runs based on the outcome of previous steps. The value must be an expression that evaluates to a boolean. While you can use any valid expression here, three built-in functions cover the most common cases:

success() returns true if all previous steps succeeded. This is the default behavior, so steps without an if field only run when everything before them passed.

failure() returns true if any previous step failed. This is useful for cleanup or notification steps that should only run when something goes wrong:

- uses: send-message
  if: ${{ failure() }}
  config:
    channel:
      kind: MessageChannel
      name: deploy-alerts
    message: "Promotion to ${{ ctx.stage }} failed. Check the Kargo dashboard for details."

always() evaluates to true regardless of whether previous steps passed or failed. Use this for cleanup logic that must execute no matter what:

- uses: git-clear
  if: ${{ always() }}
  config:
    path: ./src

You are not limited to these three functions. Any expression that evaluates to a boolean works, so you can build conditions based on step outputs, Freight metadata, or comparison operators. For example, you could gate a step on whether the version bump is major:

- uses: send-message
  if: ${{ semverDiff(outputs.current.previousTag, imageFrom('my-registry.io/my-app').Tag) == 'Major' }}
  config:
    channel:
      kind: MessageChannel
      name: deploy-alerts
    message: "Major version bump detected for ${{ ctx.stage }}. Manual review recommended."

When a step's if condition evaluates to false, the step is marked as skipped. Skipped steps are not counted as failures and do not affect the success() or failure() checks for subsequent steps.

Combined with continueOnError, this gives you fine-grained control over promotion flow. A step with continueOnError: true runs and may fail, but its failure does not cascade. A step with if: ${{ failure() }} only runs when something upstream has already failed. Together, they let you build resilient pipelines that handle errors gracefully.

PromotionTasks: Reusable Step Sequences

Once you have more than two or three Stages, you will notice that your promotion templates start looking identical. Each Stage clones the same repo, updates values, commits, pushes, and tells Argo CD to sync. The only differences are the branch name, the values file path, and the Argo CD Application name. PromotionTasks extract that shared logic into a reusable resource.

Defining a PromotionTask

A PromotionTask is a namespaced Kubernetes resource that defines a sequence of steps with configurable variables:

apiVersion: kargo.akuity.io/v1alpha1
kind: PromotionTask
metadata:
  name: standard-promotion
  namespace: my-project
spec:
  vars:
    - name: repoURL
    - name: branch
      value: main
    - name: valuesFile
    - name: appName
    - name: imageRepo
  steps:
    - uses: git-clone
      as: clone
      config:
        repoURL: ${{ vars.repoURL }}
        checkout:
          - branch: ${{ vars.branch }}
            path: ./src
    - uses: yaml-update
      config:
        path: ./src/${{ vars.valuesFile }}
        updates:
          - key: image.tag
            value: ${{ imageFrom(vars.imageRepo).Tag }}
    - uses: git-commit
      as: commit
      config:
        path: ./src
        message: "promote ${{ ctx.stage }}: update ${{ vars.imageRepo }} to ${{ imageFrom(vars.imageRepo).Tag }}"
    - uses: git-push
      as: push
      config:
        path: ./src
    - uses: argocd-update
      config:
        apps:
          - name: ${{ vars.appName }}
            sources:
              - repoURL: ${{ vars.repoURL }}
                desiredRevision: ${{ task.outputs.push.commit }}

Notice two things. First, variables declared without a value field are required. Variables with a value have a default. Second, within a PromotionTask, step outputs are accessed through task.outputs rather than just outputs. This scoping prevents collisions when a Stage references multiple tasks.

There is also a ClusterPromotionTask resource, which has the same spec but is cluster-scoped rather than namespaced. Use it when you need the same task available across multiple Projects.

Using Tasks in Stages

A Stage references a PromotionTask through the task field in its promotion template:

apiVersion: kargo.akuity.io/v1alpha1
kind: Stage
metadata:
  name: staging
  namespace: my-project
spec:
  requestedFreight:
    - sources:
        stages:
          - test
      origin:
        kind: Warehouse
        name: my-warehouse
  promotionTemplate:
    steps:
      - task:
          name: standard-promotion
          kind: PromotionTask
        variables:
          - name: repoURL
            value: https://github.com/my-org/my-app-config.git
          - name: valuesFile
            value: envs/staging/values.yaml
          - name: appName
            value: my-app-staging
          - name: imageRepo
            value: my-registry.io/my-app

The Stage provides the environment-specific values and the PromotionTask handles all the mechanics. When you need to change how promotions work across all Stages, updating the single PromotionTask propagates the change everywhere.

Exposing Task Outputs

A PromotionTask's internal step outputs are not directly visible to the parent Stage. To make them available, you use a compose-output step at the end of the task to explicitly expose the values you want the Stage to access. Inside the task, this step uses task.outputs to reference internal step outputs:

# Inside a PromotionTask definition
steps:
  # ...git-clone, yaml-update, git-commit, etc.
  - uses: git-push
    as: push
    config:
      path: ./src
  - uses: compose-output
    as: result
    config:
      commit: ${{ task.outputs.push.commit }}

At the Stage level, the composed outputs become available under the task step's alias. If the Stage references the task with as: promotion, the commit is accessible as ${{ outputs.promotion.commit }}:

# In a Stage's promotionTemplate
steps:
  - task:
      name: standard-promotion
      kind: PromotionTask
    as: promotion
    variables:
      - name: repoURL
        value: https://github.com/my-org/my-app-config.git
  - uses: http
    config:
      method: POST
      url: https://api.my-org.com/deploy-hooks
      body: |
        { "commit": "${{ outputs.promotion.commit }}" }

This scoping is what makes it possible to chain multiple tasks together in a single Stage. Each task exposes its own outputs under its own alias, and downstream steps or tasks reference them through ${{ outputs.<task-alias>.<output-name> }}.

Practical Patterns

PR-Based Promotion with Review Gate

For environments that require human approval, combine Git PR steps with the wait step:

steps:
  - uses: git-clone
    as: clone
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      checkout:
        - branch: main
          path: ./src
  - uses: yaml-update
    config:
      path: ./src/envs/production/values.yaml
      updates:
        - key: image.tag
          value: ${{ imageFrom('my-registry.io/my-app').Tag }}
  - uses: git-commit
    config:
      path: ./src
      message: "promote production: ${{ imageFrom('my-registry.io/my-app').Tag }}"
  - uses: git-push
    as: push
    config:
      path: ./src
      targetBranch: promote/production/${{ ctx.promotion }}
  - uses: git-open-pr
    as: open-pr
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      sourceBranch: ${{ outputs.push.branch }}
      targetBranch: main
      title: "[Production] Promote ${{ imageFrom('my-registry.io/my-app').Tag }}"
  - uses: git-wait-for-pr
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      prNumber: ${{ outputs['open-pr'].pr.id }}
    retry:
      timeout: 48h
  - uses: argocd-update
    config:
      apps:
        - name: my-app-production

This pipeline pushes to a feature branch, opens a PR, and then halts until someone merges it. Only after the merge does Argo CD sync the production cluster.

Issue Tracking Integration

For teams that gate production deploys on ticket approval, the jira step can create a deploy ticket, wait for it to be approved, and update it with the result. This pattern uses the jira step, which is Akuity Platform only:

steps:
  - uses: jira
    as: ticket
    config:
      credentials:
        secretName: jira-creds
      createIssue:
        projectKey: DEPLOY
        summary: "Deploy ${{ imageFrom('my-registry.io/my-app').Tag }} to ${{ ctx.stage }}"
        description: "Automated promotion triggered by Kargo. Freight: ${{ ctx.targetFreight.name }}"
        issueType: Task
        labels:
          - kargo
          - production
  - uses: jira
    config:
      credentials:
        secretName: jira-creds
      waitForStatus:
        issueKey: ${{ outputs.ticket.key }}
        expectedStatus: Approved
    retry:
      timeout: 72h
  - uses: git-clone
    as: clone
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      checkout:
        - branch: main
          path: ./src
  - uses: yaml-update
    config:
      path: ./src/envs/production/values.yaml
      updates:
        - key: image.tag
          value: ${{ imageFrom('my-registry.io/my-app').Tag }}
  - uses: git-commit
    config:
      path: ./src
      message: "promote production: ${{ imageFrom('my-registry.io/my-app').Tag }} (${{ outputs.ticket.key }})"
  - uses: git-push
    as: push
    config:
      path: ./src
  - uses: argocd-update
    config:
      apps:
        - name: my-app-production
          sources:
            - repoURL: https://github.com/my-org/my-app-config.git
              desiredRevision: ${{ outputs.push.commit }}
  - uses: jira
    config:
      credentials:
        secretName: jira-creds
      updateIssue:
        issueKey: ${{ outputs.ticket.key }}
        status: Done
      commentOnIssue:
        issueKey: ${{ outputs.ticket.key }}
        body: "Deployed successfully. Commit: ${{ outputs.push.commit }}"

The pipeline creates a Jira issue in the DEPLOY project, then blocks until someone moves it to Approved. After the deploy completes, it transitions the ticket to Done and adds a comment with the commit SHA. The ServiceNow steps (snow-create, snow-update, snow-wait-for-condition) support a similar pattern for organizations that use ServiceNow for change management.

Error Handling with Notifications

Use conditional steps to notify on failure while still completing cleanup. This example uses send-message (Akuity Platform only) for the notifications, but the conditional execution pattern with if, success(), failure(), and always() works with any step in open-source Kargo:

steps:
  - uses: git-clone
    as: clone
    config:
      repoURL: https://github.com/my-org/my-app-config.git
      checkout:
        - branch: main
          path: ./src
  - uses: yaml-update
    config:
      path: ./src/envs/staging/values.yaml
      updates:
        - key: image.tag
          value: ${{ imageFrom('my-registry.io/my-app').Tag }}
  - uses: git-commit
    config:
      path: ./src
      message: "promote staging: ${{ imageFrom('my-registry.io/my-app').Tag }}"
  - uses: git-push
    as: push
    config:
      path: ./src
  - uses: argocd-update
    config:
      apps:
        - name: my-app-staging
          sources:
            - repoURL: https://github.com/my-org/my-app-config.git
              desiredRevision: ${{ outputs.push.commit }}
  - uses: send-message
    if: ${{ failure() }}
    config:
      channel:
        kind: MessageChannel
        name: deploy-alerts
      message: "Promotion to ${{ ctx.stage }} FAILED. Check the Kargo dashboard."
  - uses: send-message
    if: ${{ success() }}
    config:
      channel:
        kind: MessageChannel
        name: deploy-notifications
      message: "Promoted ${{ imageFrom('my-registry.io/my-app').Tag }} to ${{ ctx.stage }}."
  - uses: git-clear
    if: ${{ always() }}
    config:
      path: ./src

Tips for Working with Promotion Steps

Alias every step that produces outputs. If you forget the as field, you cannot reference that step's outputs later. It costs nothing and saves debugging time.

Use quote() around image tags. Tags that look like numbers (such as 20260331 or 1234) will be coerced to integers by the expression engine. Wrapping them in quote() keeps them as strings and avoids subtle bugs in YAML updates.

Set retry timeouts on external calls. Steps like gha-wait-for-workflow, git-wait-for-pr, and snow-wait-for-condition can block a promotion indefinitely if the external system never responds. Always set a timeout that matches your SLA.

Use PromotionTasks early. Even if you only have two Stages today, extracting the promotion logic into a task pays off quickly. When you add a third Stage or need to change how promotions work, you only update one resource.

Keep steps focused. If you find yourself doing complex logic inside a single step's configuration, consider whether it should be split into two steps. The expression language is powerful but not a full programming language. Let each step do one thing and let expressions wire them together.

Test with manual promotions first. Kargo lets you manually promote specific Freight to a Stage through the CLI or UI. Use this to verify your promotion template before enabling auto-promotion. Manual promotions generate the same Promotion resource and execute the same steps, so the behavior is identical.

What's Next

Kargo's step library continues to expand with each release. The OpenTofu steps (tf-plan, tf-apply, tf-output) open the door to infrastructure promotions that go beyond Kubernetes manifests, though these are currently Akuity Platform only. The JFrog Artifactory evidence step (jfrog-evidence, also Platform only) brings supply chain attestation into the promotion workflow. Akuity has also announced OCI-based custom steps that would let teams package entirely custom logic as container images and register them as native Kargo steps, though this feature has not yet shipped in a released version of Kargo at the time of writing.

If you want to explore the full list of built-in steps and their configuration options, the Kargo promotion steps reference is the definitive resource. For help designing promotion pipelines for your organization, get in touch.