How-to › Model, configure and generate

How to diff rendered manifests per environment before you merge#

Render each overlay from a pull request and from its base, diff every environment with dyff, and post the result as one comment before anything merges.

Audience
Platform team
Level
intermediate
Topic
Layer configuration across environments
Languages
YAML and bash
Verified

A pull request edits base/deployment.yaml to turn on debug logging for staging. The diff shows one line in a shared file, so the reviewer approves a staging change. Production inherits the same base, so its pods start logging at debug on the next deploy. Kustomize applies the overlays at render time, after the review.

What you get

You will end up with a pull-request check that renders every environment from both sides and keeps one comment listing each environment’s changes. This is for you if your overlays share a base.

Short answer

Render every overlay with kustomize build twice, from the pull request’s merge commit and from the base it merges into. Compare each environment’s pair with dyff between, and post the result as one pull-request comment. Keep kubectl diff for the deploy step, because an environment not yet deployed diffs as whole objects, defaulted fields included.

You will need

Kustomize overlays for two or more environments, a CI system that can comment on pull requests, and Node 22 or later to run the npm ci that fetches the tools. Verified 2026-10-21 against Node 22.22.0, Kustomize 5.8.3, dyff 1.12.0, and kubectl, kube-apiserver, and etcd from envtest 1.37.0. npm ci downloads each tool from its GitHub release into node_modules/.bin and checks it against a hash pinned in install-tools.sh. The API server and etcd come from the envtest bundle that controller tests use, and only the cluster diff needs them. The sample keeps the two checkouts as plain directories, main/ and pr/, so it runs without a repository.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
dyffTwo rendered files and no cluster, for a comment people readA second binary to pin, and no view of the cluster, so drift stays out of the commentYou need to know what an apply would change in the cluster
helm diff pluginHelm releases, compared with what the cluster has deployedHelm only, a plugin on every runner, and cluster credentials in the CI jobThe manifests come from Kustomize, or CI must not reach the cluster
kubectl diffThe deploy step, run against the cluster it is about to changeCluster credentials in CI, and whole objects with defaulted fields for anything not yet deployedPull requests from forks, or a comment a reviewer must read quickly

kubectl diff answers the deploy step’s question, what this apply changes in this cluster, at the price of credentials and server-side detail nobody wrote. The helm diff plugin answers the same question for Helm releases, with the same credentials. dyff compares two files, so it needs nothing but the renders and cannot see drift in the cluster. Use dyff in the pull-request check and kubectl diff in the deploy step.

Render both sides of the pull request

The check needs two renders of every environment: the pull request as it would merge, and the base it merges into. render.sh builds each overlay of one checkout into its own file:

set -euo pipefail
export PATH="$(cd "$(dirname "$0")" && pwd)/node_modules/.bin:$PATH"

checkout=$1
out=$2
mkdir -p "$out"
for overlay in "$checkout"/overlays/*/; do
  env=$(basename "$overlay")
  kustomize build "$overlay" > "$out/$env.yaml"
done

kustomize build prints the overlay as the cluster would receive it. For a Helm chart, run helm template with each environment’s values file instead, and the rest of the check stays the same. In CI, the pull request’s side is the merge commit GitHub builds for the run, and the base side is the first parent of that commit.1 The diff then shows what merging would change, not what the branch changed since it forked.

Diff each environment and write the comment

diff-envs.sh renders both checkouts, compares each environment’s pair with dyff between, and prints a Markdown comment.2 An environment that exists on one side only is compared with an empty file, so a new overlay shows as documents added. dyff exits 0 for no difference, 1 for differences, and 255 for an error, so only an exit code greater than 1 stops the script:

envs=$(cd "$work" && ls base head | grep '\.yaml$' | sed 's/\.yaml$//' | sort -u)
for env in $envs; do
  touch "$work/base/$env.yaml" "$work/head/$env.yaml"
  set +e
  dyff between --omit-header --color off --output github --set-exit-code \
    "$work/base/$env.yaml" "$work/head/$env.yaml" > "$work/$env.diff"
  status=$?
  set -e
  if [ "$status" -gt 1 ]; then
    echo "dyff failed on $env" >&2
    exit 1
  fi
done

dyff matches documents by their Kubernetes identity and list entries by name, so a change reads as a path and two values rather than as shifted lines. Run it on the pull request that turns on debug logging:

bash diff-envs.sh main pr
### Rendered manifests: this pull request against its base

| Environment | Changes |
| --- | --- |
| production | 1 |
| staging | 1 |

#### production

~~~diff
@@ spec.template.spec.containers.checkout.env.LOG_LEVEL.value @@
# apps/v1/Deployment/production/checkout
! ± value change
- info
+ debug
~~~

#### staging

~~~diff
@@ spec.template.spec.containers.checkout.env.LOG_LEVEL.value @@
# apps/v1/Deployment/staging/checkout
! ± value change
- info
+ debug
~~~

The table is the review. production | 1 says the change meant for staging reaches production, because the edit sits in the shared base. Moving the variable into the staging overlay leaves the production render as it was.

Post the comment on every push

The workflow builds the two checkouts from real commits and posts the comment with the GitHub CLI:

      # The merge commit GitHub builds for the pull request. Its first parent
      # is the commit of the base branch it was merged onto.
      - uses: actions/checkout@v6
        with:
          path: pr
          fetch-depth: 2

      - name: Check out the base beside it
        run: git -C pr worktree add ../main HEAD^1

      - name: Fetch the pinned kustomize and dyff
        working-directory: pr/ci
        env:
          SKIP_ENVTEST: 1
        run: npm ci --no-audit --no-fund

      - name: Render and diff every environment
        run: bash pr/ci/diff-envs.sh main pr > comment.md

      # One comment per pull request, edited on every push.
      - name: Post the comment
        env:
          GH_TOKEN: ${{ github.token }}
          PR: ${{ github.event.pull_request.number }}
        run: gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --edit-last --create-if-none --body-file comment.md

--edit-last with --create-if-none, both documented in the gh pr comment manual, keeps one comment per pull request instead of one per push. It edits the newest comment by the token’s user, and every workflow’s GITHUB_TOKEN posts as the same bot, so this holds only while no other workflow comments that way. Two quick pushes start two runs, and the comment ends up holding whichever render finished last. A concurrency group at the top of the workflow cancels the older run:

concurrency:
  group: manifest-diff-${{ github.event.pull_request.number }}
  cancel-in-progress: true

SKIP_ENVTEST=1 leaves out the API server, which only the cluster diff below needs. The job declares pull-requests: write, and GitHub grants it only to pull requests from branches in the same repository.

Check it worked

A pull request that changes a production-only value must produce a comment showing that change and nothing else. check.sh builds that pull request from a copy of main/, with production’s replicas raised from three to four, then checks two more cases and the workflow’s concurrency setting:

bash check.sh
ok 1 - a production-only change shows one change in production
ok 2 - and none in staging
ok 3 - and names the field and both values, line for line
ok 4 - an unchanged tree shows no changes anywhere
ok 5 - a base edit shows up in every environment
ok 6 - the workflow cancels the run for an older push
6 of 6 checks passed

Check 3 compares the production section line for line, from @@ spec.replicas @@ to - 3 and + 4, so a comment that grows unrelated lines fails it.

When it goes wrong

kubectl diff against a cluster looks like the natural check, but on its first run it buries the change. cluster-diff.sh starts a throwaway API server, sets dyff as kubectl’s external differ, and diffs production before anything is deployed:

bash cluster-diff.sh fresh
  spec:
    progressDeadlineSeconds: 600
    replicas: 3
    revisionHistoryLimit: 10
    selector:
      matchLabels:
        app: checkout
    strategy:
      type: RollingUpdate
      rollingUpdate:
        maxSurge: 25%
        maxUnavailable: 25%
    template:
      metadata:
        labels:
          app: checkout
      spec:
        containers:
        - name: checkout
          image: "registry.example.com/checkout:1.4.2"
          imagePullPolicy: IfNotPresent
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
          env:
          - name: LOG_LEVEL
            value: info
          ports:
          - containerPort: 8080
            protocol: TCP
          resources:
            limits:
              memory: 512Mi
        dnsPolicy: ClusterFirst
        restartPolicy: Always
        schedulerName: default-scheduler
        securityContext: {}
        terminationGracePeriodSeconds: 30
  status: {}

The namespace holds nothing yet, so kubectl diff prints both objects whole. Sixteen of the Deployment’s spec lines are fields no manifest set, from progressDeadlineSeconds to terminationGracePeriodSeconds, filled in by the API server’s defaults.3 Diff renders against each other in the pull request, and keep the cluster diff for the deploy step, where the objects exist:

bash cluster-diff.sh deployed
service/checkout created
deployment.apps/checkout created
kubectl diff -f the pull request's production render

metadata.generation  (apps/v1/Deployment/production/checkout)
  ± value change
    - 1
    + 2

spec.template.spec.containers.checkout.env.LOG_LEVEL.value  (apps/v1/Deployment/production/checkout)
  ± value change
    - info
    + debug

kubectl diff exited 1

With production applied, the same pull request diffs as the one real change, plus metadata.generation, a counter the server raises on every spec change.

The comment step also fails on a pull request from a fork, because GitHub gives a fork’s run a read-only token. Upload comment.md as an artifact, and post it from a second workflow triggered by workflow_run, which never runs the fork’s code, as GitHub Security Lab describes.

When not to do this

Do not run kubectl diff against production from the pull-request job. It needs production credentials in a job that runs the branch’s own scripts, and a run from a fork cannot have them.

Do not switch the trigger to pull_request_target to get a write token for forks while checking out the pull request. That runs the fork’s scripts with the base repository’s permissions.

Do not read a clean render diff as a clean deploy. The renders cannot see drift in the cluster, such as a manual kubectl edit or replicas that a HorizontalPodAutoscaler owns. The kubectl diff in the deploy step is the check for that.

Do not build this for one environment with no shared base. The pull request’s own diff already shows every line that reaches the cluster.

Last verified

Verified 2026-10-21 against Node 22.22.0, Kustomize 5.8.3, dyff 1.12.0, and kubectl, kube-apiserver, and etcd from envtest 1.37.0. Every output block is what the command preceding it printed. The cluster is a local API server with no controllers or nodes, started by cluster-diff.sh and deleted when it exits. The GitHub Actions workflow was not run: main/ and pr/ stand in for its two checkouts, and the comment it would post is the output of diff-envs.sh. The helm diff plugin was not run, because the sample renders with Kustomize.

Footnotes

  1. GitHub’s documentation sets GITHUB_REF to refs/pull/<number>/merge for a pull_request run. The page gives the reason in a sentence of its own: tests “run against the merged result” instead of the branch alone. The commit the check renders as the pull request’s side is a trial merge that no branch contains and nobody wrote. ↩︎ Back to text

  2. The project’s README spells the name with a Greek delta and two florin signs, and gives its pronunciation in the International Phonetic Alphabet as /ˈdʏf/. The line under the title is plainer: “A diff tool for YAML files, and sometimes JSON.” ↩︎ Back to text

  3. The two at the top of the list each get a sentence in the Deployment documentation. Ten old ReplicaSet objects are kept for rollback by default, and the documentation adds that the ideal number depends on how often and how reliably you deploy. A rollout that stops making progress is reported as stalled once progressDeadlineSeconds runs out, and on that field the documentation is brief: “This defaults to 600.” ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.