Repository: helm-unittest/helm-unittest

Issue:
Add support for helm `post-renderer`
**Is your feature request related to a problem? Please describe.**
I have recently started running crossplane.  Crossplane provides a way to do some templating of resources, and there's a CLI to render those templates.  We deploy these crossplane templates (called `Compositions`) into our cluster using helm.  I would like to be able to use the crossplane CLI as a helm `--post-renderer` (with the same interface as the standard helm flag) to capture these outputs and assert on the results with helm-unittest.

**Describe the solution you'd like**
Add these to the yaml schema for each test, with an optional per-suite attribute:

```
postRenderer:
  cmd: "my-post-renderer"
  args: 
    - "-d=foo"
    - "--debug"
```

**Describe alternatives you've considered**
Add a flag, similar to the one on helm itself, to invoke a post-renderer after templating:

```
helm unittest . --values=foobar.yaml --post-renderer=my-post-renderer
```

  Run this post-render after templating but before manifest parsing.  I started to implement this but then realized it makes more sense on the TestJob object for us to be able to specify in-schema.

**Additional context**
I have dug through some code and I think I figured out [where](https://github.com/helm-unittest/helm-unittest/blob/c45088939e0ae92ccc1a21545a30f264bf00245e/pkg/unittest/test_job.go#L236) to make the change.

Here is where [helm itself](https://github.com/helm/helm/blob/main/pkg/action/action.go#L225-L230) does post-rendering.  We can probably re-use the post-renderer interface, although it operates on a string buffer and returns a buffer whereas the context at that place in code for helm-unittest has a `map[string]string` to work with, so we'd need to concat the mapped objects into a single buffer and then somehow parse them back out again before we call `parseManifestsFromOutputOfFiles`.

Discussion:
@quintush: @jcogilvie , @ivankatliarchuk ,

Thanks for the update and validation!
Checked all and it looked good for a first version.

Will add it directly to this release.

Greetings,
@quintush @quintush: Closed based on merged and released implementation #581 

Solution patch:
diff --git a/AlpineTest.Dockerfile b/AlpineTest.Dockerfile
index e0b24a6..e5fc96a 100644
--- a/AlpineTest.Dockerfile
+++ b/AlpineTest.Dockerfile
@@ -21,7 +21,7 @@ ENV HELM_DATA_HOME=/usr/local/share/helm
 
 # Ensure to have latest packages
 RUN apk upgrade --no-cache && \
-    apk add --no-cache --update ca-certificates curl git libc6-compat && \
+    apk add --no-cache --update ca-certificates curl git libc6-compat yq && \
     curl -L ${HELM_BASE_URL}/${HELM_TAR_FILE} |tar xvz && \
     mv ${TARGETOS}-${TARGETARCH}/helm /usr/bin/helm && \
     chmod +x /usr/bin/helm && \
@@ -32,11 +32,11 @@ RUN apk upgrade --no-cache && \
     addgroup -g 1000 -S helmgroup && \
     adduser -u 1000 -S -G helmgroup helmuser
 
-VOLUME ["/apps"] 
+VOLUME ["/apps"]
 
 USER 1000:1000
 
 WORKDIR /apps
 
 ENTRYPOINT ["helm", "unittest"]
-CMD ["--help"]
\ No newline at end of file
+CMD ["--help"]
diff --git a/DOCUMENT.md b/DOCUMENT.md
index fab3404..d27894d 100644
--- a/DOCUMENT.md
+++ b/DOCUMENT.md
@@ -47,6 +47,14 @@ kubernetesProvider:
       metadata:
         name: unittest
         namespace: default
+skip:
+    reason: "Unreleased feature"
+postRenderer:
+  cmd: "yq"
+  args:
+    - "eval"
+    - '.metadata.annotations.appended="new"'
+    - "-"
 tests:
   - it: should test something
     ...
@@ -84,6 +92,10 @@ tests:
 - **skip**: *object, optional*. Marks the test suite as having been skipped. Execution will continue at the next suite.
   - **reason**: *string, required*. Define the reason for skipping. Marks all tests as skipped.
 
+- **postRenderer**: *object, optional*. A helm [post-renderer](https://helm.sh/docs/topics/advanced/#post-rendering) to apply after chart rendering but before validation.  
+  - **cmd**: *string, required*. The full path to the command to invoke, or just its name if it's on `$PATH`.
+  - **args**: *array of strings*. Command-line arguments to pass to the above `cmd`.
+
 - **tests**: *array of test job, required*. Where you define your test jobs to run, check [Test Job](#test-job).
 
 ## Test Job
@@ -119,6 +131,14 @@ tests:
     chart:
       version: 1.0.0
       appVersion: 1.0.0
+    skip:
+      reason: "Unreleased feature"
+    postRenderer:
+      cmd: "yq"
+      args:
+        - "eval"
+        - '.metadata.annotations.appended="new"'
+        - "-"
     asserts:
       - equal:
           path: metadata.name
@@ -163,6 +183,10 @@ tests:
 - **skip**: *object, optional*. Marks the test as having been skipped. Execution will continue at the next test.
   - **reason**: *string, required*. Define the reason for skipping. If all tests skipped, marks 'suite' as skipped.
 
+- **postRenderer**: *object, optional*. A helm [post-renderer](https://helm.sh/docs/topics/advanced/#post-rendering) to apply after chart rendering but before validation.
+    - **cmd**: *string, required*. The full path to the command to invoke, or just its name if it's on `$PATH`.
+    - **args**: *array of strings*. Command-line arguments to pass to the above `cmd`.
+
 - **asserts**: *array of assertion, required*. The assertions to validate the rendered chart, check [Assertion](#assertion).
 
 ## Assertion
diff --git a/Makefile b/Makefile
index 3dc8847..ef06257 100644
--- a/Makefile
+++ b/Makefile
@@ -17,6 +17,7 @@ TEST_NAMES ?=basic \
 	with-files \
 	with-helm-tests \
 	with-k8s-fake-client \
+	with-post-renderer \
 	with-samenamesubsubcharts \
 	with-schema \
 	with-subchart \
@@ -29,8 +30,8 @@ help:
 
 .PHONY: plugin-dir
 plugin-dir:
-	HELM_3_PLUGINS := $(shell bash -c 'eval $$(helm env); echo $$HELM_PLUGINS')
-	HELM_PLUGIN_DIR := $(HELM_3_PLUGINS)/helm-unittest
+	$(eval HELM_3_PLUGINS := $(shell helm env | grep HELM_PLUGINS | cut -d '=' -f 2 | tr -d '"'))
+	$(eval HELM_PLUGIN_DIR := $(HELM_3_PLUGINS)/helm-unittest)
 
 .PHONY: install
 install: bootstrap build plugin-dir
@@ -64,6 +65,10 @@ build-debug: ## Compile packages and dependencies with debug flag
 build: ## Compile packages and dependencies
 	go build -o untt -ldflags $(LDFLAGS) ./cmd/helm-unittest
 
+.PHONY: build-amd64
+build-amd64: ## Compile packages and dependencies, pinned to amd64 for the docker image
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o untt -ldflags $(LDFLAGS) ./cmd/helm-unittest
+
 .PHONY: dist
 dist:
 	mkdir -p $(DIST)
@@ -95,14 +100,15 @@ dependency: ## Dependency maintanance
 	go mod tidy
 
 .PHONY: dockerimage
-dockerimage: build ## Build docker image
-	docker build --no-cache --build-arg HELM_VERSION=$(HELM_VERSION) -t $(DOCKER):$(VERSION) -f AlpineTest.Dockerfile .
+dockerimage: build-amd64 ## Build docker image
+	docker build --no-cache --build-arg HELM_VERSION=$(HELM_VERSION) --build-arg BUILDPLATFORM=amd64 -t $(DOCKER):$(VERSION) -f AlpineTest.Dockerfile .
 
 .PHONY: test-docker
 test-docker: dockerimage ## Execute 'helm unittests' in container
 	@for f in $(TEST_NAMES); do \
 		echo "running helm unit tests in folder '$(PROJECT_DIR)/test/data/v3/$${f}'"; \
 		docker run \
+		    --platform linux/amd64 \
 			-v $(PROJECT_DIR)/test/data/v3/$${f}:/apps:z \
 			--rm  $(DOCKER):$(VERSION) -f tests/*.yaml .;\
 	done
diff --git a/internal/common/utilities.go b/internal/common/utilities.go
index 8506b08..22de95f 100644
--- a/internal/common/utilities.go
+++ b/internal/common/utilities.go
@@ -83,3 +83,24 @@ func YmlMarshallTestHelper(in interface{}, t *testing.T) string {
 	assert.NoError(t, err)
 	return string(out)
 }
+
+func SplitBefore(s, sep string) []string {
+	var out []string
+
+	// this can be omitted if staying analogous with SplitAfter is not a requirement
+	if strings.HasPrefix(s, sep) || s == "" {
+		out = append(out, "")
+	}
+
+	for len(s) > 0 {
+		i := strings.Index(s[1:], sep)
+		if i == -1 {
+			out = append(out, s)
+			break
+		}
+
+		out = append(out, s[:i+1])
+		s = s[i+1:]
+	}
+	return out
+}
diff --git a/plugin-dbg.yaml b/plugin-dbg.yaml
index 4e2c372..25d0f89 100644
--- a/plugin-dbg.yaml
+++ b/plugin-dbg.yaml
@@ -1,5 +1,5 @@
 name: "unittest"
-version: "0.7.2"
+version: "0.8.0"
 usage: "unittest for helm charts"
 description: "Unit test for helm chart in YAML with ease to keep your chart functional and robust."
 ignoreFlags: false
diff --git a/plugin.yaml b/plugin.yaml
index cddc7c4..4b434eb 100644
--- a/plugin.yaml
+++ b/plugin.yaml
@@ -1,5 +1,5 @@
 name: "unittest"
-version: "0.7.2"
+version: "0.8.0"
 usage: "unittest for helm charts"
 description: "Unit test for helm chart in YAML with ease to keep your chart functional and robust."
 ignoreFlags: false
diff --git a/schema/helm-testsuite.json b/schema/helm-testsuite.json
index 3cfebf0..ccc9f27 100644
--- a/schema/helm-testsuite.json
+++ b/schema/helm-testsuite.json
@@ -38,6 +38,9 @@
     "skip": {
       "$ref": "#/definitions/skip"
     },
+    "postRenderer": {
+      "$ref": "#/definitions/postRenderer"
+    },
     "chart": {
       "$ref": "#/definitions/chart"
     },
@@ -68,6 +71,9 @@
           "skip": {
             "$ref": "#/definitions/skip"
           },
+          "postRenderer": {
+            "$ref": "#/definitions/postRenderer"
+          },
           "template": {
             "type": "string",
             "description": "The template file(s) which render the manifest to be tested, default to the list of template file defined in templates of suite file, unless template is defined in the assertion(s).",
@@ -1112,12 +1118,32 @@
       "properties": {
         "reason": {
           "type": "string",
-          "description": "The `suite` or 'test' to skip.",
-          "markdownDescription": "**reason** (string)\n\nThe `suite` or 'test' to skip."
+          "description": "The reason to skip the `suite` or 'test'.",
+          "markdownDescription": "**reason** (string)\n\nThe reason to skip the `suite` or 'test'."
         }
       },
       "additionalProperties": false
     },
+    "postRenderer": {
+      "type": "object",
+      "description": "A helm 'post-renderer' to apply after chart rendering but before validation.",
+      "markdownDescription":"**postRenderer** (object) _optional_\n\nA helm [post-renderer](https://helm.sh/docs/topics/advanced/#post-rendering) to apply after chart rendering but before validation.",
+      "properties": {
+        "cmd": {
+          "type": "string",
+          "description": "The full path to the command to invoke, or just its name if it's on `$PATH`.",
+          "markdownDescription": "**cmd** (string)\n\nThe full path to the command to invoke, or just its name if it's on '$PATH'."
+        },
+        "args": {
+          "type": "array",
+          "description": "A list of command-line arguments to pass to the 'cmd'.",
+          "markdownDescription": "**args** (array<string>)\n\nA list of command-line arguments to pass to the `cmd`.",
+          "items": {
+            "type": "string"
+          }
+        }
+      }
+    },
     "kubernetesProvider": {
       "type": "object",
       "description": "Define Kubernetes resources to fake.",



Test patch:
diff --git a/internal/common/utilities_ymlescape_test.go b/internal/common/utilities_ymlescape_test.go
index ff425ef..9610608 100644
--- a/internal/common/utilities_ymlescape_test.go
+++ b/internal/common/utilities_ymlescape_test.go
@@ -3,8 +3,8 @@ package common_test
 import (
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	. "github.com/helm-unittest/helm-unittest/internal/common"
+	"github.com/stretchr/testify/assert"
 )
 
 const (
diff --git a/pkg/unittest/.snapshots/TestV3RunnerOkWithPostRenderer b/pkg/unittest/.snapshots/TestV3RunnerOkWithPostRenderer
new file mode 100644
index 0000000..f797cc5
--- /dev/null
+++ b/pkg/unittest/.snapshots/TestV3RunnerOkWithPostRenderer
@@ -0,0 +1,15 @@
+
+### Chart [ with-post-renderer ] ../../test/data/v3/with-post-renderer
+
+
+ PASS  	../../test/data/v3/with-post-renderer/tests/basic_no_postrender_test.yaml
+ PASS  	../../test/data/v3/with-post-renderer/tests/basic_postrender_test.yaml
+
+
+Charts:      1 passed, 1 total
+Test Suites: 2 passed, 2 total
+Tests:       4 passed, 4 total
+Snapshot:    2 passed, 2 total
+Time:        XX.XXXms
+
+
diff --git a/pkg/unittest/assertion.go b/pkg/unittest/assertion.go
index 95bc3c1..7219a19 100644
--- a/pkg/unittest/assertion.go
+++ b/pkg/unittest/assertion.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"reflect"
 	"sort"
+	"strings"
 
 	"github.com/helm-unittest/helm-unittest/internal/common"
 	"github.com/helm-unittest/helm-unittest/pkg/unittest/results"
@@ -34,6 +35,7 @@ func (a *Assertion) Assert(
 	renderError error,
 	result *results.AssertionResult,
 	failfast bool,
+	didPostRender bool,
 ) *results.AssertionResult {
 	result.AssertType = a.AssertType
 	result.Not = a.Not
@@ -41,7 +43,30 @@ func (a *Assertion) Assert(
 	// Ensure assertion is succeeding or failing based on templates to test.
 	assertionPassed := false
 	failInfo := make([]string, 0)
-	selectedDocsByTemplate, indexError := a.selectDocumentsForAssertion(a.getDocumentsByDefaultTemplates(templatesResult))
+
+	var templates = templatesResult
+
+	// If we PostRendered, there's no guarantee the post-renderer will preserve our file mapping.  If it doesn't, the
+	// parser just puts the whole manifest in one "manifest.yaml" so handle that case:
+	if didPostRender && len(templatesResult) == 1 {
+		var key string
+		for k := range templatesResult {
+			key = k
+		}
+
+		// account for pathname prefix
+		if strings.HasSuffix(key, "manifest.yaml") {
+			a.Template = key
+			templates = make(map[string][]common.K8sManifest)
+			templates[key] = templatesResult[key]
+		} else {
+			templates = a.getDocumentsByDefaultTemplates(templatesResult)
+		}
+	} else {
+		templates = a.getDocumentsByDefaultTemplates(templatesResult)
+	}
+
+	selectedDocsByTemplate, indexError := a.selectDocumentsForAssertion(templates)
 	selectedTemplates := a.getKeys(selectedDocsByTemplate)
 
 	// Sort templates to ensure a consistent output
@@ -72,6 +97,7 @@ func (a *Assertion) Assert(
 			rendered, ok := templatesResult[template]
 			var validatePassed bool
 			var singleFailInfo []string
+
 			if !ok && a.requireRenderSuccess {
 				noFile := []string{"Error:", a.noFileErrMessage(template)}
 				failInfo = append(failInfo, noFile...)
diff --git a/pkg/unittest/assertion_test.go b/pkg/unittest/assertion_test.go
index 148607f..f2314e1 100644
--- a/pkg/unittest/assertion_test.go
+++ b/pkg/unittest/assertion_test.go
@@ -14,7 +14,9 @@ func validateSucceededTestAssertions(
 	t *testing.T,
 	assertionsYAML string,
 	assertionCount int,
-	renderedMap map[string][]common.K8sManifest) {
+	renderedMap map[string][]common.K8sManifest,
+	didPostRender bool,
+) {
 
 	assertions := make([]Assertion, assertionCount)
 	common.YmlUnmarshalTestHelper(assertionsYAML, &assertions, t)
@@ -22,7 +24,7 @@ func validateSucceededTestAssertions(
 	a := assert.New(t)
 
 	for idx, assertion := range assertions {
-		result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: idx}, false)
+		result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: idx}, false, didPostRender)
 		a.Equal(&results.AssertionResult{
 			Index:      idx,
 			FailInfo:   []string{},
@@ -289,7 +291,7 @@ x:
     path: c
     count: 1
 `
-	validateSucceededTestAssertions(t, assertionsYAML, 15, renderedMap)
+	validateSucceededTestAssertions(t, assertionsYAML, 15, renderedMap, false)
 }
 
 func TestAssertionRawAssertWhenOk(t *testing.T) {
@@ -317,7 +319,7 @@ func TestAssertionRawAssertWhenOk(t *testing.T) {
 - template: t.yaml
   matchSnapshot: {}
 `
-	validateSucceededTestAssertions(t, assertionsYAML, 5, renderedMap)
+	validateSucceededTestAssertions(t, assertionsYAML, 5, renderedMap, false)
 }
 
 func TestAssertionAssertWhenTemplateNotExisted(t *testing.T) {
@@ -334,7 +336,7 @@ equal:
 
 	a := assert.New(t)
 
-	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false)
+	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false, false)
 	a.Equal(&results.AssertionResult{
 		Index:      0,
 		FailInfo:   []string{"Error:", "\ttemplate \"not-existed.yaml\" not exists or not selected in test suite"},
@@ -345,6 +347,150 @@ equal:
 	}, result)
 }
 
+func TestAssertionAssertWhenPostRendererRetainsFileSplitter(t *testing.T) {
+	manifestDoc := `
+kind: Fake
+apiVersion: v123
+a: b
+c: [d]
+e:
+  f: g
+x:
+`
+	manifest := common.TrustedUnmarshalYAML(manifestDoc)
+	renderedMap := map[string][]common.K8sManifest{
+		"t.yaml": {manifest},
+	}
+	assertionsYAML := `
+- template: t.yaml
+  equal:
+    path:  a
+    value: b
+- template: t.yaml
+  notEqual:
+    path:  a
+    value: c
+- template: t.yaml
+  matchRegex:
+    path:    a
+    pattern: b
+- template: t.yaml
+  notMatchRegex:
+    path:    a
+    pattern: c
+- template: t.yaml
+  contains:
+    path:    c
+    content: d
+- template: t.yaml
+  notContains:
+    path:    c
+    content: e
+- template: t.yaml
+  exists:
+    path: x
+- template: t.yaml
+  notExists:
+    path: g
+- template: t.yaml
+  isNotNullOrEmpty:
+    path: c
+- template: t.yaml
+  isKind:
+    of: Fake
+- template: t.yaml
+  isAPIVersion:
+    of: v123
+- template: t.yaml
+  hasDocuments:
+    count: 1
+- template: t.yaml
+  matchSnapshot: {}
+- template: t.yaml
+  isSubset:
+    path: e
+    content:
+      f: g
+- template: t.yaml
+  lengthEqual:
+    path: c
+    count: 1
+`
+	validateSucceededTestAssertions(t, assertionsYAML, 15, renderedMap, true)
+}
+
+func TestAssertionAssertWhenPostRendererDoesNotRetainFileSplitter(t *testing.T) {
+	manifestDoc := `
+kind: Fake
+apiVersion: v123
+a: b
+c: [d]
+e:
+  f: g
+x:
+`
+	manifest := common.TrustedUnmarshalYAML(manifestDoc)
+	renderedMap := map[string][]common.K8sManifest{
+		"manifest.yaml": {manifest},
+	}
+	assertionsYAML := `
+- template: t.yaml
+  equal:
+    path:  a
+    value: b
+- template: t.yaml
+  notEqual:
+    path:  a
+    value: c
+- template: t.yaml
+  matchRegex:
+    path:    a
+    pattern: b
+- template: t.yaml
+  notMatchRegex:
+    path:    a
+    pattern: c
+- template: t.yaml
+  contains:
+    path:    c
+    content: d
+- template: t.yaml
+  notContains:
+    path:    c
+    content: e
+- template: t.yaml
+  exists:
+    path: x
+- template: t.yaml
+  notExists:
+    path: g
+- template: t.yaml
+  isNotNullOrEmpty:
+    path: c
+- template: t.yaml
+  isKind:
+    of: Fake
+- template: t.yaml
+  isAPIVersion:
+    of: v123
+- template: t.yaml
+  hasDocuments:
+    count: 1
+- template: t.yaml
+  matchSnapshot: {}
+- template: t.yaml
+  isSubset:
+    path: e
+    content:
+      f: g
+- template: t.yaml
+  lengthEqual:
+    path: c
+    count: 1
+`
+	validateSucceededTestAssertions(t, assertionsYAML, 15, renderedMap, true)
+}
+
 func TestAssertionAssertWhenTemplateNotSpecifiedAndNoDefault(t *testing.T) {
 	manifest := common.K8sManifest{}
 	renderedMap := map[string][]common.K8sManifest{
@@ -355,7 +501,7 @@ func TestAssertionAssertWhenTemplateNotSpecifiedAndNoDefault(t *testing.T) {
 	common.YmlUnmarshalTestHelper(assertionYAML, &assertion, t)
 
 	a := assert.New(t)
-	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false)
+	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false, false)
 	a.Equal(&results.AssertionResult{
 		Index:      0,
 		FailInfo:   []string{"Error:", "\tassertion.template must be given if testsuite.templates is empty"},
@@ -381,7 +527,7 @@ equal:
 
 	a := assert.New(t)
 
-	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false)
+	result := assertion.Assert(renderedMap, fakeSnapshotComparer(true), true, nil, &results.AssertionResult{Index: 0}, false, false)
 	a.Equal(&results.AssertionResult{
 		Index:      0,
 		FailInfo:   []string{"Error:", "document index 1 is out of rage"},
diff --git a/pkg/unittest/test_job.go b/pkg/unittest/test_job.go
index 0650e3c..2246bd0 100644
--- a/pkg/unittest/test_job.go
+++ b/pkg/unittest/test_job.go
@@ -1,13 +1,16 @@
 package unittest
 
 import (
+	"bytes"
 	"cmp"
 	"errors"
 	"fmt"
+	"helm.sh/helm/v3/pkg/postrender"
 	"io"
 	"os"
 	"path/filepath"
 	"regexp"
+	"sort"
 	"strings"
 	"time"
 
@@ -35,8 +38,14 @@ const LOG_TEST_JOB = "test-job"
 //	--- m: Multi-line mode. ^ and $ match the start and end of each line.
 //	--- s: Dot-all mode. . matches any character, including newline.
 //	--- U: Ungreedy mode. Makes quantifiers lazy by default.
-//
 const regexPattern string = "(?msU)^(?:.+: |.+ \\()(?:(.+):\\d+:\\d+).+(?:.+>)*: (.+)$"
+const fileKeyPrefix = "#### file:"
+const yamlFileSeparator = "---\n" + fileKeyPrefix
+
+type PostRendererConfig struct {
+	Cmd      string   `yaml:"cmd"`
+	ArgSlice []string `yaml:"args"`
+}
 
 var regexErrorPattern = regexp.MustCompile(regexPattern)
 
@@ -192,6 +201,8 @@ type TestJob struct {
 		Reason string `yaml:"reason"`
 	} `yaml:"skip"`
 	KubernetesProvider KubernetesFakeClientProvider `yaml:"kubernetesProvider"`
+	PostRendererConfig PostRendererConfig           `yaml:"postRenderer"`
+
 	// global set values
 	globalSet map[string]interface{}
 	// route indicate which chart in the dependency hierarchy
@@ -214,6 +225,7 @@ func (t *TestJob) RunV3(
 	failfast bool,
 	renderPath string,
 	result *results.TestJobResult,
+	suitePostRendererConfig PostRendererConfig,
 ) *results.TestJobResult {
 	startTestRun := time.Now()
 	log.WithField(LOG_TEST_JOB, "run-v3").Debug("job name ", t.Name)
@@ -237,7 +249,13 @@ func (t *TestJob) RunV3(
 		// Continue to enable matching error via failedTemplate assert
 	}
 
-	manifestsOfFiles, err := t.parseManifestsFromOutputOfFiles(targetChart.Name(), outputOfFiles)
+	postRenderedManifestsOfFiles, didPostRender, err := t.postRender(suitePostRendererConfig, outputOfFiles)
+	if err != nil {
+		result.ExecError = err
+		return result
+	}
+
+	manifestsOfFiles, err := t.parseManifestsFromOutputOfFiles(targetChart.Name(), postRenderedManifestsOfFiles)
 	if err != nil {
 		result.ExecError = err
 		return result
@@ -258,6 +276,7 @@ func (t *TestJob) RunV3(
 		renderSucceed,
 		renderError,
 		failfast,
+		didPostRender,
 	)
 
 	result.Duration = time.Since(startTestRun)
@@ -279,12 +298,12 @@ func (t *TestJob) getUserValues() (string, error) {
 			valueFilePath = filepath.Join(filepath.Dir(t.definitionFile), specifiedPath)
 		}
 
-		bytes, err := os.ReadFile(valueFilePath)
+		byteArray, err := os.ReadFile(valueFilePath)
 		if err != nil {
 			return "", err
 		}
 
-		if err := common.YmlUnmarshal(string(bytes), &value); err != nil {
+		if err := common.YmlUnmarshal(string(byteArray), &value); err != nil {
 			return "", fmt.Errorf("failed to parse %s: %s", specifiedPath, err)
 		}
 
@@ -365,6 +384,102 @@ func (t *TestJob) renderV3Chart(targetChart *v3chart.Chart, userValues []byte) (
 	return outputOfFiles, renderSucceed, nil
 }
 
+// merge the map into a single file, post-render it, and split it out again
+func MergeAndPostRender(renderedManifestsMap map[string]string, postRenderer postrender.PostRenderer) (*bytes.Buffer, error) {
+	var renderedManifests bytes.Buffer
+
+	// stable iteration order
+	orderedManifests := make([]string, 0, len(renderedManifestsMap))
+	for k := range renderedManifestsMap {
+		orderedManifests = append(orderedManifests, k)
+	}
+	sort.Strings(orderedManifests)
+
+	for _, key := range orderedManifests {
+		manifest := renderedManifestsMap[key]
+		renderedManifests.WriteString(yamlFileSeparator + " " + key + "\n")
+		renderedManifests.WriteString(strings.TrimSpace(manifest) + "\n")
+	}
+	var modifiedManifests *bytes.Buffer
+
+	modifiedManifests, err := postRenderer.Run(&renderedManifests)
+	if err != nil {
+		return nil, fmt.Errorf("post-render failed: %w", err)
+	}
+	renderedManifests = *modifiedManifests
+
+	return &renderedManifests, nil
+}
+
+func SplitManifests(renderedManifests *bytes.Buffer) map[string]string {
+	postRenderedManifestsString := renderedManifests.String()
+
+	postRenderedManifestsMap := make(map[string]string)
+	re := regexp.MustCompile(fileKeyPrefix + ` (.*)`)
+
+	fileBlocks := common.SplitBefore(postRenderedManifestsString, yamlFileSeparator)
+
+	var foundMatch = false
+	for _, block := range fileBlocks {
+
+		if block == "" {
+			continue
+		}
+
+		match := re.FindStringSubmatch(block)
+
+		if match == nil || len(match) < 2 {
+			continue
+		}
+
+		key := match[1]
+		manifest := strings.TrimPrefix(block, fmt.Sprintf("---\n%s\n", match[0]))
+		postRenderedManifestsMap[key] = manifest
+
+		foundMatch = true
+	}
+
+	if !foundMatch {
+		// if we can't do our silly comment identification (like if the post processor doesn't retain the comments)
+		// we do our best by reading the whole file as one object.  should be referenceable by DocumentIndex, etc.
+		// not our job to keep the order consistent, though.  the post-renderer should.
+		postRenderedManifestsMap["manifest.yaml"] = postRenderedManifestsString
+
+		// TODO:  what if the post renderer contains *some* net new manifests but not all?  the new manifests would get
+		// gobbled into whichever file is currently open on stdin.  this'll be an edge case we will need to be aware of
+		// but it should at least be consistent enough to assert on if our post-renderer returns consistent values.
+		// I'm not sure we can do much better with the information at hand.
+	}
+
+	return postRenderedManifestsMap
+}
+
+func (t *TestJob) postRender(suitePostRendererConfig PostRendererConfig, renderedManifestsMap map[string]string) (map[string]string, bool, error) {
+
+	var cfg PostRendererConfig
+	// use job-level post-renderer if it exists; else try suite; else return what we were passed as input
+	if t.PostRendererConfig.Cmd != "" {
+		cfg = t.PostRendererConfig
+	} else if suitePostRendererConfig.Cmd != "" {
+		cfg = suitePostRendererConfig
+	} else {
+		return renderedManifestsMap, false, nil
+	}
+
+	postRenderer, err := postrender.NewExec(cfg.Cmd, cfg.ArgSlice...)
+	if err != nil {
+		return nil, true, err
+	}
+
+	renderedManifests, err := MergeAndPostRender(renderedManifestsMap, postRenderer)
+	if err != nil {
+		return nil, true, err
+	}
+
+	postRenderedManifestsMap := SplitManifests(renderedManifests)
+	return postRenderedManifestsMap, true, nil
+}
+
 // When rendering failed, due to fail or required,
 // make sure to translate the error to outputOfFiles.
 func (t *TestJob) translateErrorToOutputFiles(err error, outputOfFiles map[string]string) (map[string]string, bool, error) {
@@ -468,6 +583,7 @@ func (t *TestJob) runAssertions(
 	manifestsOfFiles map[string][]common.K8sManifest,
 	snapshotComparer validators.SnapshotComparer,
 	renderSucceed bool, renderError error, failfast bool,
+	didPostRender bool,
 ) (bool, []*results.AssertionResult) {
 	testPass := false
 	assertsResult := make([]*results.AssertionResult, 0)
@@ -483,6 +599,7 @@ func (t *TestJob) runAssertions(
 			renderError,
 			&results.AssertionResult{Index: idx},
 			failfast,
+			didPostRender,
 		)
 
 		assertsResult = append(assertsResult, result)
diff --git a/pkg/unittest/test_job_test.go b/pkg/unittest/test_job_test.go
index 146527c..b92a8f7 100644
--- a/pkg/unittest/test_job_test.go
+++ b/pkg/unittest/test_job_test.go
@@ -1,8 +1,10 @@
 package unittest_test
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
+	"github.com/stretchr/testify/mock"
 	"os"
 	"path"
 
@@ -83,7 +85,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -114,7 +116,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, renderPath, &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, renderPath, &results.TestJobResult{}, PostRendererConfig{})
 	defer os.RemoveAll(renderPath)
 
 	a := assert.New(t)
@@ -144,7 +146,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -173,7 +175,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -205,7 +207,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -230,7 +232,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -259,7 +261,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, false, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, false, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -288,7 +290,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 	// Write Buffer
 
 	a := assert.New(t)
@@ -315,7 +317,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -347,7 +349,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(fmt.Sprintf(manifest, file), &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
 
@@ -374,7 +376,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -396,7 +398,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -420,7 +422,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -446,7 +448,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
 
@@ -472,7 +474,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	a.False(testResult.Passed)
@@ -494,7 +496,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	a.False(testResult.Passed)
@@ -516,7 +518,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{}, PostRendererConfig{})
 	assert.False(t, testResult.Passed)
 	assert.Equal(t, "7", tj.Capabilities.MinorVersion)
 }
@@ -543,7 +545,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
 
@@ -566,7 +568,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -588,7 +590,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -613,7 +615,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
 
@@ -638,7 +640,7 @@ asserts:
 	var tj TestJob
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 	cupaloy.SnapshotT(t, makeTestJobResultSnapshotable(testResult))
@@ -666,7 +668,7 @@ asserts:
 	a := assert.New(t)
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a.Nil(testResult.ExecError)
 	a.True(testResult.Passed)
@@ -688,7 +690,7 @@ asserts:
 	var tj TestJob
 	unmarshalJobTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	assert.Nil(t, testResult.ExecError)
 	assert.True(t, testResult.Passed)
@@ -809,7 +811,7 @@ asserts:
 	assert := assert.New(t)
 	common.YmlUnmarshalTestHelper(manifest, &tj, t)
 
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	assert.NoError(testResult.ExecError)
 	assert.True(testResult.Passed, testResult.AssertsResult)
@@ -829,7 +831,7 @@ asserts:
 `
 	var tj TestJob
 	common.YmlUnmarshal(manifest, &tj)
-	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	a := assert.New(t)
 
@@ -878,7 +880,7 @@ ingress:
 	var tj TestJob
 	unmarshalJobTestHelper(fmt.Sprintf(manifest, file), &tj, t)
 
-	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	assert.Nil(t, testResult.ExecError)
 	assert.True(t, testResult.Passed)
@@ -923,7 +925,7 @@ ingress:
 	var tj TestJob
 	unmarshalJobTestHelper(fmt.Sprintf(manifest, file), &tj, t)
 
-	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{})
+	testResult := tj.RunV3(c, nil, true, "", &results.TestJobResult{}, PostRendererConfig{})
 
 	assert.Nil(t, testResult.ExecError)
 	assert.True(t, testResult.Passed)
@@ -959,7 +961,7 @@ func TestV3RunJob_TplFunction_Fail_WithoutAssertion(t *testing.T) {
 	for _, test := range tests {
 		tj := TestJob{}
 		c.Templates = []*v3chart.File{test.template}
-		testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+		testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 		a.Error(testResult.ExecError)
 		a.False(testResult.Passed)
 		a.EqualError(testResult.ExecError, test.error.Error())
@@ -1005,7 +1007,7 @@ asserts:
 
 	for _, test := range tests {
 		c.Templates = []*v3chart.File{test.template}
-		testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{})
+		testResult := tj.RunV3(c, &snapshot.Cache{}, true, "", &results.TestJobResult{}, PostRendererConfig{})
 		assert.Equal(t, test.expected, testResult.Passed)
 		if test.error != nil {
 			assert.NotNil(t, testResult.ExecError)
@@ -1017,3 +1019,137 @@ asserts:
 		}
 	}
 }
+
+const fileKeyPrefix = "#### file:"
+
+func Test_SplitManifests(t *testing.T) {
+	tests := []struct {
+		name              string
+		input             string
+		expectedManifests map[string]string
+	}{
+		{
+			name:  "Single Manifest",
+			input: "---\n" + fileKeyPrefix + " test-key\nmanifest1\n", // Literal separator
+			expectedManifests: map[string]string{
+				"test-key": "manifest1\n",
+			},
+		},
+		{
+			name:  "Multiple Manifests",
+			input: "---\n" + fileKeyPrefix + " test-key1\nmanifest1\n" + "---\n" + fileKeyPrefix + " test-key2\nmanifest2\n",
+			expectedManifests: map[string]string{
+				"test-key1": "manifest1\n",
+				"test-key2": "manifest2\n",
+			},
+		},
+		{
+			name:  "Multi-document Manifest",
+			input: "---\n" + fileKeyPrefix + " test-key\nmanifest1\n---\nmanifest2\n",
+			expectedManifests: map[string]string{
+				"test-key": "manifest1\n---\nmanifest2\n",
+			},
+		},
+		{
+			// TODO: should we treat the post-renderer handing us an empty file as "a map of one empty file?"
+			// or should we return an empty map?
+			name:  "Empty Input",
+			input: "",
+			expectedManifests: map[string]string{
+				"manifest.yaml": "",
+			},
+		},
+		{
+			name:  "Manifest with no newline",
+			input: "---\n" + fileKeyPrefix + " test-key\nmanifest1",
+			expectedManifests: map[string]string{
+				"test-key": "manifest1",
+			},
+		},
+		{
+			name:  "Manifest with multiple newlines",
+			input: "---\n" + fileKeyPrefix + " test-key\nmanifest1\n\n",
+			expectedManifests: map[string]string{
+				"test-key": "manifest1\n\n",
+			},
+		},
+		{
+			name:  "Manifest with net-new content",
+			input: "---\nmanifest1\n",
+			expectedManifests: map[string]string{
+				"manifest.yaml": "---\nmanifest1\n",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			inputBuffer := bytes.NewBufferString(test.input)
+			actualManifests := SplitManifests(inputBuffer)
+			assert.Equal(t, test.expectedManifests, actualManifests)
+		})
+	}
+}
+
+func Test_MergeAndPostRender(t *testing.T) {
+	tests := []struct {
+		name           string
+		inputManifests map[string]string
+		expectedOutput string
+		postRenderer   *mockPostRenderer // Use the mock type
+		expectedInput  string            // Add expected input for postRenderer
+	}{
+		{
+			name: "With Post-render newlines",
+			inputManifests: map[string]string{
+				"test-key1": "manifest1\n",
+				"test-key2": "manifest2\n",
+			},
+			expectedOutput: "---\n" + fileKeyPrefix + " test-key1\nmanifest1-modified\n\n---\n" + fileKeyPrefix + " test-key2\nmanifest2-modified\n\n",
+			postRenderer:   &mockPostRenderer{},
+			expectedInput:  "---\n" + fileKeyPrefix + " test-key1\nmanifest1\n---\n" + fileKeyPrefix + " test-key2\nmanifest2\n", // Input *before* post-rendering
+		},
+		{
+			name: "With Post-render no newlines",
+			inputManifests: map[string]string{
+				"test-key1": "manifest1\n",
+				"test-key2": "manifest2\n",
+			},
+			expectedOutput: "---\n" + fileKeyPrefix + " test-key1\nmanifest1-modified\n---\n" + fileKeyPrefix + " test-key2\nmanifest2-modified",
+			postRenderer:   &mockPostRenderer{},
+			expectedInput:  "---\n" + fileKeyPrefix + " test-key1\nmanifest1\n---\n" + fileKeyPrefix + " test-key2\nmanifest2\n", // Input *before* post-rendering
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			// Set up the mock expectation *before* calling MergeAndPostRender
+			expectedBuffer := bytes.NewBufferString(test.expectedInput)
+			test.postRenderer.On("Run", mock.Anything).Return(bytes.NewBufferString(test.expectedOutput), nil).Run(func(args mock.Arguments) {
+				arg := args.Get(0).(*bytes.Buffer)
+				assert.Equal(t, expectedBuffer.String(), arg.String())
+			})
+
+			output, err := MergeAndPostRender(test.inputManifests, test.postRenderer)
+			assert.NoError(t, err)
+			assert.Equal(t, test.expectedOutput, output.String())
+
+			test.postRenderer.AssertExpectations(t) // Verify the mock was called as expected
+
+		})
+	}
+}
+
+type mockPostRenderer struct {
+	mock.Mock
+	output string
+	err    error
+}
+
+func (m *mockPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
+	args := m.Called(renderedManifests)
+	if args.Get(0) == nil {
+		return nil, args.Error(1)
+	}
+	return args.Get(0).(*bytes.Buffer), args.Error(1)
+}
diff --git a/pkg/unittest/test_runner_test.go b/pkg/unittest/test_runner_test.go
index 0088ca4..7cffc94 100644
--- a/pkg/unittest/test_runner_test.go
+++ b/pkg/unittest/test_runner_test.go
@@ -267,6 +267,17 @@ func TestV3RunnerOkWithFakeK8sClient(t *testing.T) {
 	cupaloy.SnapshotT(t, makeOutputSnapshotable(buffer.String())...)
 }
 
+func TestV3RunnerOkWithPostRenderer(t *testing.T) {
+	buffer := new(bytes.Buffer)
+	runner := TestRunner{
+		Printer:   printer.NewPrinter(buffer, nil),
+		TestFiles: []string{testTestFiles},
+	}
+	passed := runner.RunV3([]string{testV3WithPostRendererChart})
+	assert.True(t, passed, buffer.String())
+	cupaloy.SnapshotT(t, makeOutputSnapshotable(buffer.String())...)
+}
+
 func TestV3RunnerOkWithSchemaValidation(t *testing.T) {
 	buffer := new(bytes.Buffer)
 	runner := TestRunner{
diff --git a/pkg/unittest/test_suite.go b/pkg/unittest/test_suite.go
index 16c6779..ad72e8d 100644
--- a/pkg/unittest/test_suite.go
+++ b/pkg/unittest/test_suite.go
@@ -225,7 +225,9 @@ type TestSuite struct {
 		APIVersions  []string `yaml:"apiVersions"`
 	}
 	KubernetesProvider KubernetesFakeClientProvider `yaml:"kubernetesProvider"`
-	Tests              []*TestJob
+	PostRendererConfig PostRendererConfig           `yaml:"postRenderer"`
+
+	Tests []*TestJob
 	// where the test suite file located
 	definitionFile string
 	// route indicate which chart in the dependency hierarchy
@@ -397,7 +399,7 @@ func (s *TestSuite) runV3TestJobs(
 				result.Pass = true
 			}
 		} else {
-			jobResult = testJob.RunV3(chart, cache, failFast, renderPath, &job)
+			jobResult = testJob.RunV3(chart, cache, failFast, renderPath, &job, s.PostRendererConfig)
 			jobResults[idx] = jobResult
 			if idx == 0 {
 				result.Pass = jobResult.Passed
diff --git a/pkg/unittest/test_suite_test.go b/pkg/unittest/test_suite_test.go
index 15f168e..8e71ff6 100644
--- a/pkg/unittest/test_suite_test.go
+++ b/pkg/unittest/test_suite_test.go
@@ -41,6 +41,7 @@ const testV3WithHelmTestsChart string = "../../test/data/v3/with-helm-tests"
 const testV3WitSamenameSubSubChart string = "../../test/data/v3/with-samenamesubsubcharts"
 const testV3WithDocumentSelectorChart string = "../../test/data/v3/with-document-select"
 const testV3WithFakeK8sClientChart string = "../../test/data/v3/with-k8s-fake-client"
+const testV3WithPostRendererChart string = "../../test/data/v3/with-post-renderer"
 
 var tmpdir, _ = os.MkdirTemp("", testSuiteTests)
 
diff --git a/pkg/unittest/testdata/chart-post-renderer/.helmignore b/pkg/unittest/testdata/chart-post-renderer/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/pkg/unittest/testdata/chart-post-renderer/Chart.yaml b/pkg/unittest/testdata/chart-post-renderer/Chart.yaml
new file mode 100644
index 0000000..11e62b0
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v2
+name: with-post-renderer
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
diff --git a/pkg/unittest/testdata/chart-post-renderer/templates/basic.yaml b/pkg/unittest/testdata/chart-post-renderer/templates/basic.yaml
new file mode 100644
index 0000000..a8774b3
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/templates/basic.yaml
@@ -0,0 +1,4 @@
+---
+metadata:
+  annotations:
+    foo: "bar"
diff --git a/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap b/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap
new file mode 100644
index 0000000..d7545d0
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap
@@ -0,0 +1,5 @@
+manifest should match snapshot:
+  1: |
+    metadata:
+      annotations:
+        foo: bar
diff --git a/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap b/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap
new file mode 100644
index 0000000..9918eed
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap
@@ -0,0 +1,6 @@
+manifest should match snapshot:
+  1: |
+    metadata:
+      annotations:
+        appended: new
+        foo: bar
diff --git a/pkg/unittest/testdata/chart-post-renderer/tests/basic_no_postrender_test.yaml b/pkg/unittest/testdata/chart-post-renderer/tests/basic_no_postrender_test.yaml
new file mode 100644
index 0000000..a3cae93
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/tests/basic_no_postrender_test.yaml
@@ -0,0 +1,11 @@
+templates:
+  - templates/basic.yaml
+tests:
+  - it: manifest should match snapshot
+    asserts:
+      - matchSnapshot: {}
+      - notExists:
+          path: metadata.annotations.appended
+      - equal:
+          path: metadata.annotations.foo
+          value: bar
diff --git a/pkg/unittest/testdata/chart-post-renderer/tests/basic_postrender_test.yaml b/pkg/unittest/testdata/chart-post-renderer/tests/basic_postrender_test.yaml
new file mode 100644
index 0000000..764e2ae
--- /dev/null
+++ b/pkg/unittest/testdata/chart-post-renderer/tests/basic_postrender_test.yaml
@@ -0,0 +1,34 @@
+templates:
+  - templates/basic.yaml
+postRenderer:
+  cmd: "yq"
+  args:
+    - "eval"
+    - '.metadata.annotations.appended="new"'
+    - "-"
+tests:
+  - it: manifest should match snapshot
+    asserts:
+      - matchSnapshot: {}
+  - it: manifest should include an extra annotation due to suite yq post-renderer
+    asserts:
+      - equal:
+          path: metadata.annotations.foo
+          value: bar
+      - equal:
+          path: metadata.annotations.appended
+          value: new
+  - it: manifest should overwrite existing annotation due to test-job yq post-renderer
+    postRenderer:
+      cmd: "yq"
+      args:
+        - "eval"
+        - '.metadata.annotations.foo="baz"'
+        - "-"
+    asserts:
+      - equal:
+          path: metadata.annotations.foo
+          value: baz
+      # never added by the suite yq because it was overridden in the job
+      - notExists:
+          path: metadata.annotations.appended
diff --git a/pkg/unittest/testdata/chart-post-renderer/values.yaml b/pkg/unittest/testdata/chart-post-renderer/values.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/pkg/unittest/utils_test.go b/pkg/unittest/utils_test.go
index ae3fc48..e5487a3 100644
--- a/pkg/unittest/utils_test.go
+++ b/pkg/unittest/utils_test.go
@@ -220,3 +220,123 @@ func TestV3RunnerWith_Fixture_Chart_SkipTest(t *testing.T) {
 	assert.Contains(t, buffer.String(), "Test Suites: 2 passed, 1 skipped, 3 total")
 	assert.Contains(t, buffer.String(), "Tests:       2 passed, 4 skipped, 6 total")
 }
+
+func TestV3RunnerWith_Fixture_Chart_PostRenderer(t *testing.T) {
+	buffer := new(bytes.Buffer)
+	runner := TestRunner{
+		Printer:   printer.NewPrinter(buffer, nil),
+		TestFiles: []string{"tests/*_test.yaml"},
+		Strict:    false,
+	}
+	_ = runner.RunV3([]string{"testdata/chart-post-renderer"})
+	assert.Contains(t, buffer.String(), "Test Suites: 2 passed, 2 total")
+	assert.Contains(t, buffer.String(), "Tests:       4 passed, 4 total")
+}
+
+func TestSplitBefore(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		separator string
+		expected  []string
+	}{
+		// This suite was constructed against a test suite for SplitAfter (to check for parity).
+		{
+			name:      "Simple Case",
+			input:     "apple-banana-cherry",
+			separator: "-",
+			expected:  []string{"apple", "-banana", "-cherry"}, // Separator at the beginning
+		},
+		{
+			name:      "No Separator",
+			input:     "apple",
+			separator: "-",
+			expected:  []string{"apple"},
+		},
+		{
+			name:      "Separator at Beginning",
+			input:     "-apple-banana",
+			separator: "-",
+			expected:  []string{"", "-apple", "-banana"}, // Separator at the beginning
+		},
+		{
+			name:      "Separator at End",
+			input:     "apple-banana-",
+			separator: "-",
+			expected:  []string{"apple", "-banana", "-"}, // Separator at the beginning
+		},
+		{
+			name:      "Consecutive Separators",
+			input:     "apple--banana",
+			separator: "-",
+			expected:  []string{"apple", "-", "-banana"}, // Separator at the beginning
+		},
+		{
+			name:      "Empty String",
+			input:     "",
+			separator: "-",
+			expected:  []string{""},
+		},
+		{
+			name:      "Special Characters",
+			input:     "one.two.three",
+			separator: ".",
+			expected:  []string{"one", ".two", ".three"},
+		},
+		{
+			name:      "Long Separator",
+			input:     "prefix-one-suffix-two",
+			separator: "-one-",
+			expected:  []string{"prefix", "-one-suffix-two"},
+		},
+		{
+			name:      "Separator and other special characters",
+			input:     "prefix.one-suffix.two",
+			separator: ".",
+			expected:  []string{"prefix", ".one-suffix", ".two"},
+		},
+		{
+			name:      "Complex Case",
+			input:     "part1-part2-SEP-part3-part4-SEP-part5",
+			separator: "SEP",
+			expected:  []string{"part1-part2-", "SEP-part3-part4-", "SEP-part5"},
+		},
+		{
+			name:      "Complex Case with SEP at beginning and end",
+			input:     "SEP-part1-part2-SEP-part3-part4-SEP",
+			separator: "SEP",
+			expected:  []string{"", "SEP-part1-part2-", "SEP-part3-part4-", "SEP"},
+		},
+		{
+			name:      "Another Complex Case",
+			input:     "before#### file: test1\nmanifest1\n#### file: test2\nmanifest2",
+			separator: "#### file:",
+			expected:  []string{"before", "#### file: test1\nmanifest1\n", "#### file: test2\nmanifest2"},
+		},
+		{
+			name:      "Empty Input with Separator at Beginning",
+			input:     "#### file:",
+			separator: "#### file:",
+			expected:  []string{"", "#### file:"},
+		},
+		{
+			name:      "Input with only Separator",
+			input:     "#### file:",
+			separator: "#### file:",
+			expected:  []string{"", "#### file:"},
+		},
+		{
+			name:      "Input with only Separator repeated",
+			input:     "#### file:#### file:",
+			separator: "#### file:",
+			expected:  []string{"", "#### file:", "#### file:"},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual := common.SplitBefore(test.input, test.separator)
+			assert.Equal(t, test.expected, actual, fmt.Sprintf("Test Case: %s", test.name))
+		})
+	}
+}
diff --git a/test/data/v3/with-post-renderer/.helmignore b/test/data/v3/with-post-renderer/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/test/data/v3/with-post-renderer/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/test/data/v3/with-post-renderer/Chart.yaml b/test/data/v3/with-post-renderer/Chart.yaml
new file mode 100644
index 0000000..11e62b0
--- /dev/null
+++ b/test/data/v3/with-post-renderer/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v2
+name: with-post-renderer
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
diff --git a/test/data/v3/with-post-renderer/templates/basic.yaml b/test/data/v3/with-post-renderer/templates/basic.yaml
new file mode 100644
index 0000000..a8774b3
--- /dev/null
+++ b/test/data/v3/with-post-renderer/templates/basic.yaml
@@ -0,0 +1,4 @@
+---
+metadata:
+  annotations:
+    foo: "bar"
diff --git a/test/data/v3/with-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap b/test/data/v3/with-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap
new file mode 100644
index 0000000..d7545d0
--- /dev/null
+++ b/test/data/v3/with-post-renderer/tests/__snapshot__/basic_no_postrender_test.yaml.snap
@@ -0,0 +1,5 @@
+manifest should match snapshot:
+  1: |
+    metadata:
+      annotations:
+        foo: bar
diff --git a/test/data/v3/with-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap b/test/data/v3/with-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap
new file mode 100644
index 0000000..9918eed
--- /dev/null
+++ b/test/data/v3/with-post-renderer/tests/__snapshot__/basic_postrender_test.yaml.snap
@@ -0,0 +1,6 @@
+manifest should match snapshot:
+  1: |
+    metadata:
+      annotations:
+        appended: new
+        foo: bar
diff --git a/test/data/v3/with-post-renderer/tests/basic_no_postrender_test.yaml b/test/data/v3/with-post-renderer/tests/basic_no_postrender_test.yaml
new file mode 100644
index 0000000..a3cae93
--- /dev/null
+++ b/test/data/v3/with-post-renderer/tests/basic_no_postrender_test.yaml
@@ -0,0 +1,11 @@
+templates:
+  - templates/basic.yaml
+tests:
+  - it: manifest should match snapshot
+    asserts:
+      - matchSnapshot: {}
+      - notExists:
+          path: metadata.annotations.appended
+      - equal:
+          path: metadata.annotations.foo
+          value: bar
diff --git a/test/data/v3/with-post-renderer/tests/basic_postrender_test.yaml b/test/data/v3/with-post-renderer/tests/basic_postrender_test.yaml
new file mode 100644
index 0000000..764e2ae
--- /dev/null
+++ b/test/data/v3/with-post-renderer/tests/basic_postrender_test.yaml
@@ -0,0 +1,34 @@
+templates:
+  - templates/basic.yaml
+postRenderer:
+  cmd: "yq"
+  args:
+    - "eval"
+    - '.metadata.annotations.appended="new"'
+    - "-"
+tests:
+  - it: manifest should match snapshot
+    asserts:
+      - matchSnapshot: {}
+  - it: manifest should include an extra annotation due to suite yq post-renderer
+    asserts:
+      - equal:
+          path: metadata.annotations.foo
+          value: bar
+      - equal:
+          path: metadata.annotations.appended
+          value: new
+  - it: manifest should overwrite existing annotation due to test-job yq post-renderer
+    postRenderer:
+      cmd: "yq"
+      args:
+        - "eval"
+        - '.metadata.annotations.foo="baz"'
+        - "-"
+    asserts:
+      - equal:
+          path: metadata.annotations.foo
+          value: baz
+      # never added by the suite yq because it was overridden in the job
+      - notExists:
+          path: metadata.annotations.appended
diff --git a/test/data/v3/with-post-renderer/values.yaml b/test/data/v3/with-post-renderer/values.yaml
new file mode 100644
index 0000000..e69de29