Repository: buildpacks/lifecycle
Pull request #1301 (2024-02-20 10:04:07)

refactor `phase/generator_test.go`
### Summary

Refactor `phase/generator_test.go` as suggested in https://github.com/buildpacks/lifecycle/pull/1276#discussion_r1489634499.
Related PR #1297

### Context

In #1276 we duplicated some of the test code in `phase/generator_test.go`, basically doing the same tests twice, once for Platform API `0.12` and once for `0.13`. The API versions expect a different folder layout, which requires changes to the test setup and assertions. The reduce the size of `phase/generator_test.go`, we:
- added a loop around the test cases looping over API versions.
- for API version >= 0.13 skip the copy tests
- added some `platformAPI.AtLeast("0.13")` switches where file paths are different

Now we have every tests only once in code, while still being executed twice (even the ones that we previously only had for platform API 0.12).

CC @natalieparellano 



Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f02585c7..92bbbab7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -159,6 +159,14 @@ jobs:
         with:
           name: lifecycle-linux-arm64-sha256
           path: out/lifecycle-v*+linux.arm64.tgz.sha256
+      - uses: actions/upload-artifact@v3
+        with:
+          name: lifecycle-linux-ppc64le
+          path: out/lifecycle-v*+linux.ppc64le.tgz
+      - uses: actions/upload-artifact@v3
+        with:
+          name: lifecycle-linux-ppc64le-sha256
+          path: out/lifecycle-v*+linux.ppc64le.tgz.sha256
       - uses: actions/upload-artifact@v3
         with:
           name: lifecycle-linux-s390x
@@ -214,6 +222,9 @@ jobs:
           LINUX_ARM64_SHA=$(go run ./tools/image/main.go -lifecyclePath ./out/lifecycle-v*+linux.arm64.tgz -tag buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-arm64 -arch arm64 | awk '{print $NF}')
           echo "LINUX_ARM64_SHA: $LINUX_ARM64_SHA"
 
+          LINUX_PPC64LE_SHA=$(go run ./tools/image/main.go -lifecyclePath ./out/lifecycle-v*+linux.ppc64le.tgz -tag buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-ppc64le -arch ppc64le | awk '{print $NF}')
+          echo "LINUX_PPC64LE_SHA: LINUX_PPC64LE_SHA"
+
           LINUX_S390X_SHA=$(go run ./tools/image/main.go -lifecyclePath ./out/lifecycle-v*+linux.s390x.tgz -tag buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-s390x -arch s390x | awk '{print $NF}')
           echo "LINUX_S390X_SHA: $LINUX_S390X_SHA"
 
@@ -223,6 +234,7 @@ jobs:
           docker manifest create buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG} \
               buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-x86-64@${LINUX_AMD64_SHA} \
               buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-arm64@${LINUX_ARM64_SHA} \
+              buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-ppc64le@${LINUX_PPC64LE_SHA} \
               buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-linux-s390x@${LINUX_S390X_SHA} \
               buildpacksio/lifecycle:${LIFECYCLE_IMAGE_TAG}-windows@${WINDOWS_AMD64_SHA}
 
diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml
index d4a898a4..e551eb3f 100644
--- a/.github/workflows/draft-release.yml
+++ b/.github/workflows/draft-release.yml
@@ -24,7 +24,7 @@ jobs:
             exit 1
           fi
           echo "LIFECYCLE_VERSION=$version" >> $GITHUB_ENV
-      - name: Determine download urls for linux-x86-64, linux-arm64, linux-s390x, and windows
+      - name: Determine download urls for linux-x86-64, linux-arm64, linux-ppc64le, linux-s390x, and windows
         id: artifact-urls
         # FIXME: this script should be updated to work with actions/github-script@v6
         uses: actions/github-script@v3
@@ -82,8 +82,8 @@ jobs:
                 if (urlList.length === 0) {
                   throw "no artifacts found"
                 }
-                if (urlList.length != 10) {
-                  throw "there should be exactly 10 artifacts"
+                if (urlList.length != 12) {
+                  throw "there should be exactly 12 artifacts"
                 }
                 return urlList.join(",")
               })
diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml
index 790d404e..9ea6e7c4 100644
--- a/.github/workflows/post-release.yml
+++ b/.github/workflows/post-release.yml
@@ -58,12 +58,14 @@ jobs:
 
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-x86-64@${{ env.LINUX_AMD64_SHA }} ${{ env.LIFECYCLE_VERSION }}-linux-x86-64
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-arm64@${{ env.LINUX_ARM64_SHA }} ${{ env.LIFECYCLE_VERSION }}-linux-arm64
+          crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-ppc64le@${{ env.LINUX_PPC64LE_SHA }} ${{ env.LIFECYCLE_VERSION }}-linux-ppc64le
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-s390x@${{ env.LINUX_S390X_SHA }} ${{ env.LIFECYCLE_VERSION }}-linux-s390x
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-windows@${{ env.WINDOWS_AMD64_SHA }} ${{ env.LIFECYCLE_VERSION }}-windows
 
           docker manifest create buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }} \
             buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }}-linux-x86-64@${{ env.LINUX_AMD64_SHA }} \
             buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }}-linux-arm64@${{ env.LINUX_ARM64_SHA }} \
+            buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }}-linux-ppc64le@${{ env.LINUX_PPC64LE_SHA }} \
             buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }}-linux-s390x@${{ env.LINUX_S390X_SHA }} \
             buildpacksio/lifecycle:${{ env.LIFECYCLE_VERSION }}-windows@${{ env.WINDOWS_AMD64_SHA }}
 
@@ -95,12 +97,14 @@ jobs:
 
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-x86-64@${{ env.LINUX_AMD64_SHA }} latest-linux-x86-64
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-arm64@${{ env.LINUX_ARM64_SHA }} latest-linux-arm64
+          crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-ppc64le@${{ env.LINUX_PPC64LE_SHA }} latest-linux-ppc64le
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-linux-s390x@${{ env.LINUX_S390X_SHA }} latest-linux-s390x
           crane tag buildpacksio/lifecycle:${{ env.LIFECYCLE_IMAGE_TAG }}-windows@${{ env.WINDOWS_AMD64_SHA }} latest-windows
 
           docker manifest create buildpacksio/lifecycle:latest \
             buildpacksio/lifecycle:latest-linux-x86-64@${{ env.LINUX_AMD64_SHA }} \
             buildpacksio/lifecycle:latest-linux-arm64@${{ env.LINUX_ARM64_SHA }} \
+            buildpacksio/lifecycle:latest-linux-ppc64le@${{ env.LINUX_PPC64LE_SHA }} \
             buildpacksio/lifecycle:latest-linux-s390x@${{ env.LINUX_S390X_SHA }} \
             buildpacksio/lifecycle:latest-windows@${{ env.WINDOWS_AMD64_SHA }}
 
diff --git a/Makefile b/Makefile
index 1dc6c010..3e3cff92 100644
--- a/Makefile
+++ b/Makefile
@@ -39,11 +39,12 @@ GOFILES := $(shell $(GOCMD) run tools$/lister$/main.go)
 
 all: test build package
 
-build: build-linux-amd64 build-linux-arm64 build-windows-amd64 build-linux-s390x
+build: build-linux-amd64 build-linux-arm64 build-windows-amd64 build-linux-ppc64le build-linux-s390x
 
 build-linux-amd64: build-linux-amd64-lifecycle build-linux-amd64-symlinks build-linux-amd64-launcher
 build-linux-arm64: build-linux-arm64-lifecycle build-linux-arm64-symlinks build-linux-arm64-launcher
 build-windows-amd64: build-windows-amd64-lifecycle build-windows-amd64-symlinks build-windows-amd64-launcher
+build-linux-ppc64le: build-linux-ppc64le-lifecycle build-linux-ppc64le-symlinks build-linux-ppc64le-launcher
 build-linux-s390x: build-linux-s390x-lifecycle build-linux-s390x-symlinks build-linux-s390x-launcher
 
 build-image-linux-amd64: build-linux-amd64 package-linux-amd64
@@ -61,6 +62,11 @@ build-image-windows-amd64: ARCHIVE_PATH=$(BUILD_DIR)/lifecycle-v$(LIFECYCLE_VERS
 build-image-windows-amd64:
 	$(GOCMD) run ./tools/image/main.go -daemon -lifecyclePath $(ARCHIVE_PATH) -os windows -arch amd64 -tag lifecycle:$(LIFECYCLE_IMAGE_TAG)
 
+build-image-linux-ppc64le: build-linux-ppc64le package-linux-ppc64le
+build-image-linux-ppc64le: ARCHIVE_PATH=$(BUILD_DIR)/lifecycle-v$(LIFECYCLE_VERSION)+linux.ppc64le.tgz
+build-image-linux-ppc64le:
+	$(GOCMD) run ./tools/image/main.go -daemon -lifecyclePath $(ARCHIVE_PATH) -os linux -arch ppc64le -tag lifecycle:$(LIFECYCLE_IMAGE_TAG)
+
 build-image-linux-s390x: build-linux-s390x package-linux-s390x
 build-image-linux-s390x: ARCHIVE_PATH=$(BUILD_DIR)/lifecycle-v$(LIFECYCLE_VERSION)+linux.s390x.tgz
 build-image-linux-s390x:
@@ -70,6 +76,8 @@ build-linux-amd64-lifecycle: $(BUILD_DIR)/linux-amd64/lifecycle/lifecycle
 
 build-linux-arm64-lifecycle: $(BUILD_DIR)/linux-arm64/lifecycle/lifecycle
 
+build-linux-ppc64le-lifecycle: $(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle
+
 build-linux-s390x-lifecycle: $(BUILD_DIR)/linux-s390x/lifecycle/lifecycle
 
 $(BUILD_DIR)/linux-amd64/lifecycle/lifecycle: export GOOS:=linux
@@ -90,6 +98,15 @@ $(BUILD_DIR)/linux-arm64/lifecycle/lifecycle:
 	mkdir -p $(OUT_DIR)
 	$(GOENV) $(GOBUILD) -o $(OUT_DIR)/lifecycle -a ./cmd/lifecycle
 
+$(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle: export GOOS:=linux
+$(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle: export GOARCH:=ppc64le
+$(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle: OUT_DIR?=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
+$(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle: $(GOFILES)
+$(BUILD_DIR)/linux-ppc64le/lifecycle/lifecycle:
+	@echo "> Building lifecycle/lifecycle for $(GOOS)/$(GOARCH)..."
+	mkdir -p $(OUT_DIR)
+	$(GOENV) $(GOBUILD) -o $(OUT_DIR)/lifecycle -a ./cmd/lifecycle
+
 $(BUILD_DIR)/linux-s390x/lifecycle/lifecycle: export GOOS:=linux
 $(BUILD_DIR)/linux-s390x/lifecycle/lifecycle: export GOARCH:=s390x
 $(BUILD_DIR)/linux-s390x/lifecycle/lifecycle: OUT_DIR?=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
@@ -123,6 +140,18 @@ $(BUILD_DIR)/linux-arm64/lifecycle/launcher:
 	$(GOENV) $(GOBUILD) -o $(OUT_DIR)/launcher -a ./cmd/launcher
 	test $$(du -m $(OUT_DIR)/launcher|cut -f 1) -le 3
 
+build-linux-ppc64le-launcher: $(BUILD_DIR)/linux-ppc64le/lifecycle/launcher
+
+$(BUILD_DIR)/linux-ppc64le/lifecycle/launcher: export GOOS:=linux
+$(BUILD_DIR)/linux-ppc64le/lifecycle/launcher: export GOARCH:=ppc64le
+$(BUILD_DIR)/linux-ppc64le/lifecycle/launcher: OUT_DIR?=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
+$(BUILD_DIR)/linux-ppc64le/lifecycle/launcher: $(GOFILES)
+$(BUILD_DIR)/linux-ppc64le/lifecycle/launcher:
+	@echo "> Building lifecycle/launcher for $(GOOS)/$(GOARCH)..."
+	mkdir -p $(OUT_DIR)
+	$(GOENV) $(GOBUILD) -o $(OUT_DIR)/launcher -a ./cmd/launcher
+	test $$(du -m $(OUT_DIR)/launcher|cut -f 1) -le 3
+
 build-linux-s390x-launcher: $(BUILD_DIR)/linux-s390x/lifecycle/launcher
 
 $(BUILD_DIR)/linux-s390x/lifecycle/launcher: export GOOS:=linux
@@ -163,6 +192,20 @@ build-linux-arm64-symlinks:
 	ln -sf lifecycle $(OUT_DIR)/creator
 	ln -sf lifecycle $(OUT_DIR)/extender
 
+build-linux-ppc64le-symlinks: export GOOS:=linux
+build-linux-ppc64le-symlinks: export GOARCH:=ppc64le
+build-linux-ppc64le-symlinks: OUT_DIR?=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
+build-linux-ppc64le-symlinks:
+	@echo "> Creating phase symlinks for $(GOOS)/$(GOARCH)..."
+	ln -sf lifecycle $(OUT_DIR)/detector
+	ln -sf lifecycle $(OUT_DIR)/analyzer
+	ln -sf lifecycle $(OUT_DIR)/restorer
+	ln -sf lifecycle $(OUT_DIR)/builder
+	ln -sf lifecycle $(OUT_DIR)/exporter
+	ln -sf lifecycle $(OUT_DIR)/rebaser
+	ln -sf lifecycle $(OUT_DIR)/creator
+	ln -sf lifecycle $(OUT_DIR)/extender
+
 build-linux-s390x-symlinks: export GOOS:=linux
 build-linux-s390x-symlinks: export GOARCH:=s390x
 build-linux-s390x-symlinks: OUT_DIR?=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
@@ -254,7 +297,7 @@ build-darwin-amd64-launcher:
 	$(eval OUT_DIR := $(BUILD_DIR)/$(TARGET)/lifecycle)
 	$(call build_launcher)
 
-generate-sbom: run-syft-windows run-syft-linux-amd64 run-syft-linux-arm64 run-syft-linux-s390x
+generate-sbom: run-syft-windows run-syft-linux-amd64 run-syft-linux-arm64 run-syft-linux-ppc64le run-syft-linux-s390x
 
 run-syft-windows: install-syft
 run-syft-windows: export GOOS:=windows
@@ -280,6 +323,14 @@ run-syft-linux-arm64:
 	syft $(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle -o json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.syft.json -o spdx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.spdx.json -o cyclonedx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.cdx.json
 	syft $(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher -o json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.syft.json -o spdx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.spdx.json -o cyclonedx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.cdx.json
 
+run-syft-linux-ppc64le: install-syft
+run-syft-linux-ppc64le: export GOOS:=linux
+run-syft-linux-ppc64le: export GOARCH:=ppc64le
+run-syft-linux-ppc64le:
+	@echo "> Running syft..."
+	syft $(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle -o json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.syft.json -o spdx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.spdx.json -o cyclonedx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/lifecycle.sbom.cdx.json
+	syft $(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher -o json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.syft.json -o spdx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.spdx.json -o cyclonedx-json=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle/launcher.sbom.cdx.json
+
 run-syft-linux-s390x: install-syft
 run-syft-linux-s390x: export GOOS:=linux
 run-syft-linux-s390x: export GOARCH:=s390x
@@ -349,7 +400,7 @@ clean:
 	@echo "> Cleaning workspace..."
 	rm -rf $(BUILD_DIR)
 
-package:  generate-sbom package-linux-amd64 package-linux-arm64 package-windows-amd64 package-linux-s390x
+package:  generate-sbom package-linux-amd64 package-linux-arm64 package-windows-amd64 package-linux-ppc64le package-linux-s390x
 
 package-linux-amd64: GOOS:=linux
 package-linux-amd64: GOARCH:=amd64
@@ -369,6 +420,15 @@ package-linux-arm64:
 	@echo "> Packaging lifecycle for $(GOOS)/$(GOARCH)..."
 	$(GOCMD) run $(PACKAGER) --inputDir $(INPUT_DIR) -archivePath $(ARCHIVE_PATH) -descriptorPath $(LIFECYCLE_DESCRIPTOR_PATH) -version $(LIFECYCLE_VERSION)
 
+package-linux-ppc64le: GOOS:=linux
+package-linux-ppc64le: GOARCH:=ppc64le
+package-linux-ppc64le: INPUT_DIR:=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
+package-linux-ppc64le: ARCHIVE_PATH=$(BUILD_DIR)/lifecycle-v$(LIFECYCLE_VERSION)+$(GOOS).ppc64le.tgz
+package-linux-ppc64le: PACKAGER=./tools/packager/main.go
+package-linux-ppc64le:
+	@echo "> Packaging lifecycle for $(GOOS)/$(GOARCH)..."
+	$(GOCMD) run $(PACKAGER) --inputDir $(INPUT_DIR) -archivePath $(ARCHIVE_PATH) -descriptorPath $(LIFECYCLE_DESCRIPTOR_PATH) -version $(LIFECYCLE_VERSION)
+
 package-linux-s390x: GOOS:=linux
 package-linux-s390x: GOARCH:=s390x
 package-linux-s390x: INPUT_DIR:=$(BUILD_DIR)/$(GOOS)-$(GOARCH)/lifecycle
diff --git a/cmd/lifecycle/cli/flags.go b/cmd/lifecycle/cli/flags.go
index 49ba2a69..4a667e6b 100644
--- a/cmd/lifecycle/cli/flags.go
+++ b/cmd/lifecycle/cli/flags.go
@@ -181,7 +181,7 @@ func FlagInsecureRegistries(insecureRegistries *str.Slice) {
 // deprecated
 
 func DeprecatedFlagRunImage(deprecatedRunImage *string) {
-	flagSet.StringVar(deprecatedRunImage, "image", "", "reference to run image")
+	flagSet.StringVar(deprecatedRunImage, "image", "", "[deprecated] reference to run image")
 }
 
 // helpers
diff --git a/cmd/lifecycle/rebaser.go b/cmd/lifecycle/rebaser.go
index e40944a5..0ce81f45 100644
--- a/cmd/lifecycle/rebaser.go
+++ b/cmd/lifecycle/rebaser.go
@@ -191,21 +191,13 @@ func (r *rebaseCmd) setAppImage() error {
 	}
 
 	if r.RunImageRef == "" {
-		if r.PlatformAPI.AtLeast("0.12") {
-			r.RunImageRef = md.RunImage.Reference
-			if r.RunImageRef != "" {
-				return nil
-			}
-		}
-
-		// for backwards compatibility, we need to fallback to the stack metadata
-		// fail if there is no run image metadata available from either location
-		if md.Stack == nil || md.Stack.RunImage.Image == "" {
+		runImage, err := platform.GetRunImageFromMetadata(*r.LifecycleInputs, md)
+		if err != nil {
 			return cmd.FailErrCode(errors.New("-run-image is required when there is no run image metadata available"), cmd.CodeForInvalidArgs, "parse arguments")
 		}
 
-		// for older platforms, we find the best mirror for the run image as this point
-		r.RunImageRef, err = platform.BestRunImageMirrorFor(registry, md.Stack.RunImage, r.LifecycleInputs.AccessChecker())
+		// we find the best mirror for the run image as this point
+		r.RunImageRef, err = platform.BestRunImageMirrorFor(registry, runImage, r.LifecycleInputs.AccessChecker())
 		if err != nil {
 			return err
 		}
diff --git a/phase/extender.go b/phase/extender.go
index 73b9e11e..e12fbba1 100644
--- a/phase/extender.go
+++ b/phase/extender.go
@@ -217,7 +217,7 @@ func (e *Extender) saveSparse(image v1.Image, origTopLayerHash string, logger lo
 		switch {
 		case needsCopying:
 			currentLayer := currentLayer // allow use in closure
-			logger.Debugf("Copying top layer with digest: %s", currentHash)
+			logger.Debugf("Copying layer with digest: %s", currentHash)
 			group.Go(func() error {
 				return copyLayer(currentLayer, toPath)
 			})
diff --git a/platform/run_image.go b/platform/run_image.go
index c80e6b28..edba84b8 100644
--- a/platform/run_image.go
+++ b/platform/run_image.go
@@ -112,3 +112,17 @@ func GetRunImageForExport(inputs LifecycleInputs) (files.RunImageForExport, erro
 	}
 	return runMD.Images[0], nil
 }
+
+// GetRunImageFromMetadata extracts the run image from the image metadata
+func GetRunImageFromMetadata(inputs LifecycleInputs, md files.LayersMetadata) (files.RunImageForExport, error) {
+	switch {
+	case inputs.PlatformAPI.AtLeast("0.12") && md.RunImage.RunImageForExport.Image != "":
+		return md.RunImage.RunImageForExport, nil
+	case md.Stack != nil && md.Stack.RunImage.Image != "":
+		// for backwards compatibility, we need to fallback to the stack metadata
+		// fail if there is no run image metadata available from either location
+		return md.Stack.RunImage, nil
+	default:
+		return files.RunImageForExport{}, errors.New("no run image metadata available")
+	}
+}


Test patch:
diff --git a/phase/extender_test.go b/phase/extender_test.go
index 567c0ae4..7dc8eec2 100644
--- a/phase/extender_test.go
+++ b/phase/extender_test.go
@@ -196,623 +196,331 @@ func testExtender(t *testing.T, when spec.G, it spec.S) {
 			mockCtrl.Finish()
 		})
 
-		when("using the platform API 0.12", func() {
-			it.Before(func() {
-				extender.PlatformAPI = api.MustParse("0.12")
-			})
+		// prepares fixtures and expected data for extensions
+		// On older platforms, the extender looks for Dockerfiles in <generated>/<kind>/<extension-id>/Dockerfile.
+		// On newer platforms, it looks for Dockerfiles in <generated>/<extension-id>/kind.Dockerfile, and context directories are allowed.
+		prepareDockerfile := func(id, kind, contextDir string) extend.Dockerfile {
+			basePath := filepath.Join(generatedDir, kind, id)
+			dockerfilePath := filepath.Join(basePath, "Dockerfile")
+
+			if extender.PlatformAPI.AtLeast("0.13") {
+				basePath = filepath.Join(generatedDir, id)
+				dockerfilePath = filepath.Join(basePath, fmt.Sprintf("%s.Dockerfile", kind))
+
+				switch contextDir {
+				case "specific":
+					h.Mkdir(t, filepath.Join(basePath, fmt.Sprintf("context.%s", kind)))
+				case "shared":
+					h.Mkdir(t, filepath.Join(basePath, "context"))
+				case "app":
+					// nothing to do
+				default:
+					t.Fail()
+				}
+			} else {
+				h.AssertEq(t, contextDir, "app")
+			}
 
-			when("build base image", func() {
-				it("applies the Dockerfile with the expected args and opts", func() {
-					expectedDockerfileA := extend.Dockerfile{
-						Path: filepath.Join(generatedDir, "build", "A", "Dockerfile"),
-						Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
-					}
-					h.Mkdir(t, filepath.Join(generatedDir, "build", "A"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "build", "A", "Dockerfile"))
-					buf := new(bytes.Buffer)
-					data := extend.Config{Build: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "build", "A", "extend-config.toml"))
-
-					expectedDockerfileB := extend.Dockerfile{
-						Path: filepath.Join(generatedDir, "build", "B", "Dockerfile"),
-						Args: []extend.Arg{{Name: "argB", Value: "valueB"}},
-					}
-					h.Mkdir(t, filepath.Join(generatedDir, "build", "B"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "build", "B", "Dockerfile"))
-					buf = new(bytes.Buffer)
-					data = extend.Config{Build: extend.BuildConfig{Args: expectedDockerfileB.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "build", "B", "extend-config.toml"))
-
-					fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-					someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-
-					// first dockerfile
-
-					firstConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "0:5678",
-					}}
-					someFakeImage.ConfigFileReturnsOnCall(0, firstConfig, nil)
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						gomock.Any(), // we mutate the provided image so we can't expect the fake image
-						extend.Options{
-							BuildContext: "some-app-dir",
-							IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-							CacheTTL:     7 * (24 * time.Hour),
-						},
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "0")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-							return someFakeImage, nil
-						})
-					secondConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "2345:6789",
-						Env:  []string{"SOME_VAR=some-val"},
-					}}
-					someFakeImage.ConfigFileReturnsOnCall(1, secondConfig, nil)
-
-					// second dockerfile
-
-					someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						someFakeImage,
-						extend.Options{
-							BuildContext: "some-app-dir",
-							IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-							CacheTTL:     7 * (24 * time.Hour),
-						},
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "2345")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "6789")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
-
-							return someFakeImage, nil
-						})
-					someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
+			expectedDockerfile := extend.Dockerfile{
+				Path: dockerfilePath,
+				Args: []extend.Arg{{Name: fmt.Sprintf("arg%s", id), Value: fmt.Sprintf("value%s", id)}},
+			}
+			h.Mkdir(t, basePath)
+			h.Mkfile(t, "some dockerfile content", dockerfilePath)
+
+			buf := new(bytes.Buffer)
+			var data extend.Config
+			switch kind {
+			case "run":
+				data = extend.Config{Run: extend.BuildConfig{Args: expectedDockerfile.Args}}
+			case "build":
+				data = extend.Config{Build: extend.BuildConfig{Args: expectedDockerfile.Args}}
+			default:
+				t.Fail()
+			}
+			h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
+			h.Mkfile(t, buf.String(), filepath.Join(basePath, "extend-config.toml"))
+			return expectedDockerfile
+		}
 
-					fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
+		for _, platformAPI := range []*api.Version{
+			api.MustParse("0.12"),
+			api.MustParse("0.13"),
+		} {
+			platformAPI := platformAPI
 
-					h.AssertNil(t, extender.Extend("build", logger))
-					h.AssertEq(t, os.Getenv("SOME_VAR"), "some-val")
-					h.AssertNil(t, os.Unsetenv("SOME_VAR"))
+			when(fmt.Sprintf("using the platform API %s", platformAPI), func() {
+				it.Before(func() {
+					extender.PlatformAPI = platformAPI
 				})
-			})
 
-			when("run base image", func() {
-				type testCase struct {
-					firstDockerfileRebasable  bool
-					secondDockerfileRebasable bool
-					expectedImageSHA          string
-				}
-				var (
-					rebasableSHA    = "sha256:2407b98f3bcee33d24f47fc3d7beaf0928b3fa799a8a84aa9f9f239fcded6e62"
-					notRebasableSHA = "sha256:cc3b90b798b76e7d41de01d4f02f2917694ef0b09d0d923314c8d6c31c4ae8e9"
-				)
-				for _, tc := range []testCase{
-					{
-						firstDockerfileRebasable:  true,
-						secondDockerfileRebasable: true,
-						expectedImageSHA:          rebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  true,
-						secondDockerfileRebasable: false,
-						expectedImageSHA:          notRebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  false,
-						secondDockerfileRebasable: true,
-						expectedImageSHA:          notRebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  false,
-						secondDockerfileRebasable: false,
-						expectedImageSHA:          notRebasableSHA,
-					},
-				} {
-					tc := tc
-					when := when
-					desc := func(b bool) string {
-						if b {
-							return "rebasable"
+				when("build base image", func() {
+					it("applies the Dockerfile with the expected args and opts", func() {
+						var expectedDockerfileA, expectedDockerfileB extend.Dockerfile
+						var expectedBuildContextA, expectedBuildContextB string
+
+						if platformAPI.AtLeast("0.13") {
+							expectedDockerfileA = prepareDockerfile("A", "build", "specific")
+							expectedBuildContextA = filepath.Join(generatedDir, "A", "context.build")
+							expectedDockerfileB = prepareDockerfile("B", "build", "shared")
+							expectedBuildContextB = filepath.Join(generatedDir, "B", "context")
+						} else {
+							expectedDockerfileA = prepareDockerfile("A", "build", "app")
+							expectedBuildContextA = "some-app-dir"
+							expectedDockerfileB = prepareDockerfile("B", "build", "app")
+							expectedBuildContextB = "some-app-dir"
 						}
-						return "not rebasable"
-					}
-					when(fmt.Sprintf("first Dockerfile is %s, second Dockerfile is %s", desc(tc.firstDockerfileRebasable), desc(tc.secondDockerfileRebasable)), func() {
-						it("applies the Dockerfile with the expected args and opts", func() {
-							expectedDockerfileA := extend.Dockerfile{
-								Path: filepath.Join(generatedDir, "run", "A", "Dockerfile"),
-								Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
-							}
-							h.Mkdir(t, filepath.Join(generatedDir, "run", "A"))
-							h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "run", "A", "Dockerfile"))
-							buf := new(bytes.Buffer)
-							data := extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-							h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-							h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "run", "A", "extend-config.toml"))
-
-							expectedDockerfileB := extend.Dockerfile{
-								Path: filepath.Join(generatedDir, "run", "B", "Dockerfile"),
-								Args: []extend.Arg{{Name: "argB", Value: "valueB"}},
-							}
-							h.Mkdir(t, filepath.Join(generatedDir, "run", "B"))
-							h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "run", "B", "Dockerfile"))
-							buf = new(bytes.Buffer)
-							data = extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileB.Args}}
-							h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-							h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "run", "B", "extend-config.toml"))
-
-							fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-							someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-							someFakeImage.ConfigFileReturnsOnCall(0, &v1.ConfigFile{Config: v1.Config{
-								User: "1234:5678",
-							}}, nil)
-
-							// first dockerfile
-
-							fakeDockerfileApplier.EXPECT().Apply(
-								gomock.Any(),
-								gomock.Any(), // we mutate the provided image so we can't expect the fake image
-								extend.Options{
-									BuildContext: "some-app-dir",
-									IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-									CacheTTL:     7 * (24 * time.Hour),
-								},
-								logger,
-							).DoAndReturn(
-								func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-									h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-									h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
-									h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-									_, err := uuid.Parse(dockerfile.Args[0].Value)
-									h.AssertNil(t, err)
-									h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-									h.AssertEq(t, dockerfile.Args[1].Value, "1234")
-									h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-									h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-									h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-									return someFakeImage, nil
-								})
-							firstConfig := &v1.ConfigFile{Config: v1.Config{
-								User:   "1234:5678",
-								Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.firstDockerfileRebasable)},
-							}}
-							someFakeImage.ConfigFileReturnsOnCall(1, firstConfig, nil)
-
-							// second dockerfile
-
-							fakeDockerfileApplier.EXPECT().Apply(
-								gomock.Any(),
-								someFakeImage,
-								extend.Options{
-									BuildContext: "some-app-dir",
-									IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-									CacheTTL:     7 * (24 * time.Hour),
-								},
-								logger,
-							).DoAndReturn(
-								func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-									h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
-									h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
-									h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-									_, err := uuid.Parse(dockerfile.Args[0].Value)
-									h.AssertNil(t, err)
-									h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-									h.AssertEq(t, dockerfile.Args[1].Value, "1234")
-									h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-									h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-									h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
-
-									return someFakeImage, nil
-								})
-							secondConfig := &v1.ConfigFile{Config: v1.Config{
-								User:   "1234:5678",
-								Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.secondDockerfileRebasable)},
-							}}
-							someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
-
-							// set label
-
-							someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
-							someFakeImage.ConfigFileReturnsOnCall(4, secondConfig, nil)
-
-							// save selective
-
-							imageHash := v1.Hash{Algorithm: "sha256", Hex: "some-image-hex"}
-							someFakeImage.DigestReturns(imageHash, nil)
-							someFakeImage.ConfigNameReturns(v1.Hash{Algorithm: "sha256", Hex: "some-config-hex"}, nil)
-
-							fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
-
-							h.AssertNil(t, extender.Extend("run", logger))
-							outputImagePath := filepath.Join(extendedDir, "run", tc.expectedImageSHA)
-							h.AssertPathExists(t, outputImagePath)
-							fis, err := os.ReadDir(outputImagePath)
-							h.AssertNil(t, err)
-							h.AssertEq(t, len(fis), 3)
-						})
-					})
-				}
 
-				it("errors if the last extension leaves the user as root", func() {
-					expectedDockerfileA := extend.Dockerfile{
-						Path: filepath.Join(generatedDir, "run", "A", "Dockerfile"),
-						Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
-					}
-					h.Mkdir(t, filepath.Join(generatedDir, "run", "A"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "run", "A", "Dockerfile"))
-					buf := new(bytes.Buffer)
-					data := extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "run", "A", "extend-config.toml"))
-
-					fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-					firstConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "0:5678",
-					}}
-					someFakeImage.ConfigFileReturns(firstConfig, nil)
-					someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						gomock.Any(),
-						gomock.Any(),
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "0")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-							return someFakeImage, nil
-						})
-
-					err := extender.Extend("run", logger)
-					h.AssertError(t, err, "extending run image: the final user ID is 0 (root); please add another extension that resets the user to non-root")
+						fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
+						someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
+
+						// first dockerfile
+
+						firstConfig := &v1.ConfigFile{Config: v1.Config{
+							User: "0:5678",
+						}}
+						someFakeImage.ConfigFileReturnsOnCall(0, firstConfig, nil)
+						fakeDockerfileApplier.EXPECT().Apply(
+							gomock.Any(),
+							gomock.Any(), // we mutate the provided image so we can't expect the fake image
+							extend.Options{
+								BuildContext: expectedBuildContextA,
+								IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
+								CacheTTL:     7 * (24 * time.Hour),
+							},
+							logger,
+						).DoAndReturn(
+							func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
+								h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
+								h.AssertEq(t, len(dockerfile.Args), 4)
+								h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
+								_, err := uuid.Parse(dockerfile.Args[0].Value)
+								h.AssertNil(t, err)
+								h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
+								h.AssertEq(t, dockerfile.Args[1].Value, "0")
+								h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
+								h.AssertEq(t, dockerfile.Args[2].Value, "5678")
+								h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
+
+								return someFakeImage, nil
+							})
+						secondConfig := &v1.ConfigFile{Config: v1.Config{
+							User: "2345:6789",
+							Env:  []string{"SOME_VAR=some-val"},
+						}}
+						someFakeImage.ConfigFileReturnsOnCall(1, secondConfig, nil)
+
+						// second dockerfile
+
+						someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
+						fakeDockerfileApplier.EXPECT().Apply(
+							gomock.Any(),
+							someFakeImage,
+							extend.Options{
+								BuildContext: expectedBuildContextB,
+								IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
+								CacheTTL:     7 * (24 * time.Hour),
+							},
+							logger,
+						).DoAndReturn(
+							func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
+								h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
+								h.AssertEq(t, len(dockerfile.Args), 4)
+								h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
+								_, err := uuid.Parse(dockerfile.Args[0].Value)
+								h.AssertNil(t, err)
+								h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
+								h.AssertEq(t, dockerfile.Args[1].Value, "2345")
+								h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
+								h.AssertEq(t, dockerfile.Args[2].Value, "6789")
+								h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
+
+								return someFakeImage, nil
+							})
+						someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
+
+						fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
+
+						h.AssertNil(t, extender.Extend("build", logger))
+						h.AssertEq(t, os.Getenv("SOME_VAR"), "some-val")
+						h.AssertNil(t, os.Unsetenv("SOME_VAR"))
+					})
 				})
-			})
-		})
-
-		when("using platform API 0.13", func() {
-			it.Before(func() {
-				extender.PlatformAPI = api.MustParse("0.13")
-			})
 
-			when("build base image", func() {
-				it("applies the Dockerfile with the expected args and opts", func() {
-					expectedDockerfileA := extend.Dockerfile{
-						Path: filepath.Join(generatedDir, "A", "build.Dockerfile"),
-						Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
-					}
-					h.Mkdir(t, filepath.Join(generatedDir, "A"))
-					h.Mkdir(t, filepath.Join(generatedDir, "A", "context.build"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "A", "build.Dockerfile"))
-					buf := new(bytes.Buffer)
-					data := extend.Config{Build: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "A", "extend-config.toml"))
-
-					expectedDockerfileB := extend.Dockerfile{
-						Path:       filepath.Join(generatedDir, "B", "build.Dockerfile"),
-						Args:       []extend.Arg{{Name: "argB", Value: "valueB"}},
-						ContextDir: "dirB",
+				when("run base image", func() {
+					type testCase struct {
+						firstDockerfileRebasable  bool
+						secondDockerfileRebasable bool
+						expectedImageSHA          string
 					}
-					h.Mkdir(t, filepath.Join(generatedDir, "B"))
-					h.Mkdir(t, filepath.Join(generatedDir, "B", "context"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "B", "build.Dockerfile"))
-					buf = new(bytes.Buffer)
-					data = extend.Config{Build: extend.BuildConfig{Args: expectedDockerfileB.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "B", "extend-config.toml"))
-
-					fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-					someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-
-					// first dockerfile
-
-					firstConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "0:5678",
-					}}
-					someFakeImage.ConfigFileReturnsOnCall(0, firstConfig, nil)
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						gomock.Any(), // we mutate the provided image so we can't expect the fake image
-						extend.Options{
-							BuildContext: filepath.Join(generatedDir, "A", "context.build"),
-							IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-							CacheTTL:     7 * (24 * time.Hour),
+					var (
+						rebasableSHA    = "sha256:2407b98f3bcee33d24f47fc3d7beaf0928b3fa799a8a84aa9f9f239fcded6e62"
+						notRebasableSHA = "sha256:cc3b90b798b76e7d41de01d4f02f2917694ef0b09d0d923314c8d6c31c4ae8e9"
+					)
+					for _, tc := range []testCase{
+						{
+							firstDockerfileRebasable:  true,
+							secondDockerfileRebasable: true,
+							expectedImageSHA:          rebasableSHA,
 						},
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "0")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-							return someFakeImage, nil
-						})
-					secondConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "2345:6789",
-						Env:  []string{"SOME_VAR=some-val"},
-					}}
-					someFakeImage.ConfigFileReturnsOnCall(1, secondConfig, nil)
-
-					// second dockerfile
-
-					someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						someFakeImage,
-						extend.Options{
-							BuildContext: filepath.Join(generatedDir, "B", "context"),
-							IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-							CacheTTL:     7 * (24 * time.Hour),
+						{
+							firstDockerfileRebasable:  true,
+							secondDockerfileRebasable: false,
+							expectedImageSHA:          notRebasableSHA,
 						},
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "2345")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "6789")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
-
-							return someFakeImage, nil
-						})
-					someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
-
-					fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
-
-					h.AssertNil(t, extender.Extend("build", logger))
-					h.AssertEq(t, os.Getenv("SOME_VAR"), "some-val")
-					h.AssertNil(t, os.Unsetenv("SOME_VAR"))
-				})
-			})
-
-			when("run base image", func() {
-				type testCase struct {
-					firstDockerfileRebasable  bool
-					secondDockerfileRebasable bool
-					expectedImageSHA          string
-				}
-				var (
-					rebasableSHA    = "sha256:2407b98f3bcee33d24f47fc3d7beaf0928b3fa799a8a84aa9f9f239fcded6e62"
-					notRebasableSHA = "sha256:cc3b90b798b76e7d41de01d4f02f2917694ef0b09d0d923314c8d6c31c4ae8e9"
-				)
-				for _, tc := range []testCase{
-					{
-						firstDockerfileRebasable:  true,
-						secondDockerfileRebasable: true,
-						expectedImageSHA:          rebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  true,
-						secondDockerfileRebasable: false,
-						expectedImageSHA:          notRebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  false,
-						secondDockerfileRebasable: true,
-						expectedImageSHA:          notRebasableSHA,
-					},
-					{
-						firstDockerfileRebasable:  false,
-						secondDockerfileRebasable: false,
-						expectedImageSHA:          notRebasableSHA,
-					},
-				} {
-					tc := tc
-					when := when
-					desc := func(b bool) string {
-						if b {
-							return "rebasable"
-						}
-						return "not rebasable"
-					}
-					when(fmt.Sprintf("first Dockerfile is %s, second Dockerfile is %s", desc(tc.firstDockerfileRebasable), desc(tc.secondDockerfileRebasable)), func() {
-						it("applies the Dockerfile with the expected args and opts", func() {
-							expectedDockerfileA := extend.Dockerfile{
-								Path: filepath.Join(generatedDir, "A", "run.Dockerfile"),
-								Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
-							}
-							h.Mkdir(t, filepath.Join(generatedDir, "A"))
-							h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "A", "run.Dockerfile"))
-							buf := new(bytes.Buffer)
-							data := extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-							h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-							h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "A", "extend-config.toml"))
-
-							expectedDockerfileB := extend.Dockerfile{
-								Path: filepath.Join(generatedDir, "B", "run.Dockerfile"),
-								Args: []extend.Arg{{Name: "argB", Value: "valueB"}},
+						{
+							firstDockerfileRebasable:  false,
+							secondDockerfileRebasable: true,
+							expectedImageSHA:          notRebasableSHA,
+						},
+						{
+							firstDockerfileRebasable:  false,
+							secondDockerfileRebasable: false,
+							expectedImageSHA:          notRebasableSHA,
+						},
+					} {
+						tc := tc
+						when := when
+						desc := func(b bool) string {
+							if b {
+								return "rebasable"
 							}
-							h.Mkdir(t, filepath.Join(generatedDir, "B"))
-							h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "B", "run.Dockerfile"))
-							buf = new(bytes.Buffer)
-							data = extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileB.Args}}
-							h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-							h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "B", "extend-config.toml"))
-
-							fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-							someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-							someFakeImage.ConfigFileReturnsOnCall(0, &v1.ConfigFile{Config: v1.Config{
-								User: "1234:5678",
-							}}, nil)
-
-							// first dockerfile
-
-							fakeDockerfileApplier.EXPECT().Apply(
-								gomock.Any(),
-								gomock.Any(), // we mutate the provided image so we can't expect the fake image
-								extend.Options{
-									BuildContext: "some-app-dir",
-									IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-									CacheTTL:     7 * (24 * time.Hour),
-								},
-								logger,
-							).DoAndReturn(
-								func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-									h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-									h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
-									h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-									_, err := uuid.Parse(dockerfile.Args[0].Value)
-									h.AssertNil(t, err)
-									h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-									h.AssertEq(t, dockerfile.Args[1].Value, "1234")
-									h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-									h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-									h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-									return someFakeImage, nil
-								})
-							firstConfig := &v1.ConfigFile{Config: v1.Config{
-								User:   "1234:5678",
-								Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.firstDockerfileRebasable)},
-							}}
-							someFakeImage.ConfigFileReturnsOnCall(1, firstConfig, nil)
-
-							// second dockerfile
-
-							fakeDockerfileApplier.EXPECT().Apply(
-								gomock.Any(),
-								someFakeImage,
-								extend.Options{
-									BuildContext: "some-app-dir",
-									IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
-									CacheTTL:     7 * (24 * time.Hour),
-								},
-								logger,
-							).DoAndReturn(
-								func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-									h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
-									h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
-									h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-									_, err := uuid.Parse(dockerfile.Args[0].Value)
-									h.AssertNil(t, err)
-									h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-									h.AssertEq(t, dockerfile.Args[1].Value, "1234")
-									h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-									h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-									h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
-
-									return someFakeImage, nil
-								})
-							secondConfig := &v1.ConfigFile{Config: v1.Config{
-								User:   "1234:5678",
-								Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.secondDockerfileRebasable)},
-							}}
-							someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
-
-							// set label
-
-							someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
-							someFakeImage.ConfigFileReturnsOnCall(4, secondConfig, nil)
-
-							// save without base layers
-
-							imageHash := v1.Hash{Algorithm: "sha256", Hex: "some-image-hex"}
-							someFakeImage.DigestReturns(imageHash, nil)
-							someFakeImage.ConfigNameReturns(v1.Hash{Algorithm: "sha256", Hex: "some-config-hex"}, nil)
-
-							fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
-
-							h.AssertNil(t, extender.Extend("run", logger))
-							outputImagePath := filepath.Join(extendedDir, "run", tc.expectedImageSHA)
-							h.AssertPathExists(t, outputImagePath)
-							fis, err := os.ReadDir(outputImagePath)
-							h.AssertNil(t, err)
-							h.AssertEq(t, len(fis), 3)
+							return "not rebasable"
+						}
+						when(fmt.Sprintf("first Dockerfile is %s, second Dockerfile is %s", desc(tc.firstDockerfileRebasable), desc(tc.secondDockerfileRebasable)), func() {
+							it("applies the Dockerfile with the expected args and opts", func() {
+								expectedDockerfileA := prepareDockerfile("A", "run", "app")
+								expectedDockerfileB := prepareDockerfile("B", "run", "app")
+
+								fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
+								someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
+								someFakeImage.ConfigFileReturnsOnCall(0, &v1.ConfigFile{Config: v1.Config{
+									User: "1234:5678",
+								}}, nil)
+
+								// first dockerfile
+
+								fakeDockerfileApplier.EXPECT().Apply(
+									gomock.Any(),
+									gomock.Any(), // we mutate the provided image so we can't expect the fake image
+									extend.Options{
+										BuildContext: "some-app-dir",
+										IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
+										CacheTTL:     7 * (24 * time.Hour),
+									},
+									logger,
+								).DoAndReturn(
+									func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
+										h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
+										h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
+										h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
+										_, err := uuid.Parse(dockerfile.Args[0].Value)
+										h.AssertNil(t, err)
+										h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
+										h.AssertEq(t, dockerfile.Args[1].Value, "1234")
+										h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
+										h.AssertEq(t, dockerfile.Args[2].Value, "5678")
+										h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
+
+										return someFakeImage, nil
+									})
+								firstConfig := &v1.ConfigFile{Config: v1.Config{
+									User:   "1234:5678",
+									Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.firstDockerfileRebasable)},
+								}}
+								someFakeImage.ConfigFileReturnsOnCall(1, firstConfig, nil)
+
+								// second dockerfile
+
+								fakeDockerfileApplier.EXPECT().Apply(
+									gomock.Any(),
+									someFakeImage,
+									extend.Options{
+										BuildContext: "some-app-dir",
+										IgnorePaths:  []string{"some-app-dir", "some-layers-dir", "some-platform-dir"},
+										CacheTTL:     7 * (24 * time.Hour),
+									},
+									logger,
+								).DoAndReturn(
+									func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
+										h.AssertEq(t, dockerfile.Path, expectedDockerfileB.Path)
+										h.AssertEq(t, len(dockerfile.Args), 4) // build_id, user_id, group_id
+										h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
+										_, err := uuid.Parse(dockerfile.Args[0].Value)
+										h.AssertNil(t, err)
+										h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
+										h.AssertEq(t, dockerfile.Args[1].Value, "1234")
+										h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
+										h.AssertEq(t, dockerfile.Args[2].Value, "5678")
+										h.AssertEq(t, dockerfile.Args[3], expectedDockerfileB.Args[0])
+
+										return someFakeImage, nil
+									})
+								secondConfig := &v1.ConfigFile{Config: v1.Config{
+									User:   "1234:5678",
+									Labels: map[string]string{phase.RebasableLabel: fmt.Sprintf("%t", tc.secondDockerfileRebasable)},
+								}}
+								someFakeImage.ConfigFileReturnsOnCall(2, secondConfig, nil)
+
+								// set label
+
+								someFakeImage.ConfigFileReturnsOnCall(3, secondConfig, nil)
+								someFakeImage.ConfigFileReturnsOnCall(4, secondConfig, nil)
+
+								// save without base layers
+
+								imageHash := v1.Hash{Algorithm: "sha256", Hex: "some-image-hex"}
+								someFakeImage.DigestReturns(imageHash, nil)
+								someFakeImage.ConfigNameReturns(v1.Hash{Algorithm: "sha256", Hex: "some-config-hex"}, nil)
+
+								fakeDockerfileApplier.EXPECT().Cleanup().Return(nil)
+
+								h.AssertNil(t, extender.Extend("run", logger))
+								outputImagePath := filepath.Join(extendedDir, "run", tc.expectedImageSHA)
+								h.AssertPathExists(t, outputImagePath)
+								fis, err := os.ReadDir(outputImagePath)
+								h.AssertNil(t, err)
+								h.AssertEq(t, len(fis), 3)
+							})
 						})
-					})
-				}
-
-				it("errors if the last extension leaves the user as root", func() {
-					expectedDockerfileA := extend.Dockerfile{
-						Path: filepath.Join(generatedDir, "A", "run.Dockerfile"),
-						Args: []extend.Arg{{Name: "argA", Value: "valueA"}},
 					}
-					h.Mkdir(t, filepath.Join(generatedDir, "A"))
-					h.Mkfile(t, "some dockerfile content", filepath.Join(generatedDir, "A", "run.Dockerfile"))
-					buf := new(bytes.Buffer)
-					data := extend.Config{Run: extend.BuildConfig{Args: expectedDockerfileA.Args}}
-					h.AssertNil(t, toml.NewEncoder(buf).Encode(data))
-					h.Mkfile(t, buf.String(), filepath.Join(generatedDir, "A", "extend-config.toml"))
-
-					fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
-					firstConfig := &v1.ConfigFile{Config: v1.Config{
-						User: "0:5678",
-					}}
-					someFakeImage.ConfigFileReturns(firstConfig, nil)
-					someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
-
-					fakeDockerfileApplier.EXPECT().Apply(
-						gomock.Any(),
-						gomock.Any(),
-						gomock.Any(),
-						logger,
-					).DoAndReturn(
-						func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
-							h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
-							h.AssertEq(t, len(dockerfile.Args), 4)
-							h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
-							_, err := uuid.Parse(dockerfile.Args[0].Value)
-							h.AssertNil(t, err)
-							h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
-							h.AssertEq(t, dockerfile.Args[1].Value, "0")
-							h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
-							h.AssertEq(t, dockerfile.Args[2].Value, "5678")
-							h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
-
-							return someFakeImage, nil
-						})
 
-					err := extender.Extend("run", logger)
-					h.AssertError(t, err, "extending run image: the final user ID is 0 (root); please add another extension that resets the user to non-root")
+					it("errors if the last extension leaves the user as root", func() {
+						expectedDockerfileA := prepareDockerfile("A", "run", "app")
+
+						fakeDockerfileApplier.EXPECT().ImageFor(extender.ImageRef).Return(someFakeImage, nil)
+						firstConfig := &v1.ConfigFile{Config: v1.Config{
+							User: "0:5678",
+						}}
+						someFakeImage.ConfigFileReturns(firstConfig, nil)
+						someFakeImage.ManifestReturns(&v1.Manifest{Layers: []v1.Descriptor{}}, nil)
+
+						fakeDockerfileApplier.EXPECT().Apply(
+							gomock.Any(),
+							gomock.Any(),
+							gomock.Any(),
+							logger,
+						).DoAndReturn(
+							func(dockerfile extend.Dockerfile, _ v1.Image, _ extend.Options, _ llog.Logger) (v1.Image, error) {
+								h.AssertEq(t, dockerfile.Path, expectedDockerfileA.Path)
+								h.AssertEq(t, len(dockerfile.Args), 4)
+								h.AssertEq(t, dockerfile.Args[0].Name, "build_id")
+								_, err := uuid.Parse(dockerfile.Args[0].Value)
+								h.AssertNil(t, err)
+								h.AssertEq(t, dockerfile.Args[1].Name, "user_id")
+								h.AssertEq(t, dockerfile.Args[1].Value, "0")
+								h.AssertEq(t, dockerfile.Args[2].Name, "group_id")
+								h.AssertEq(t, dockerfile.Args[2].Value, "5678")
+								h.AssertEq(t, dockerfile.Args[3], expectedDockerfileA.Args[0])
+
+								return someFakeImage, nil
+							})
+
+						err := extender.Extend("run", logger)
+						h.AssertError(t, err, "extending run image: the final user ID is 0 (root); please add another extension that resets the user to non-root")
+					})
 				})
 			})
-		})
+		}
 	})
 }
diff --git a/phase/generator_test.go b/phase/generator_test.go
index c014878e..607ff6b6 100644
--- a/phase/generator_test.go
+++ b/phase/generator_test.go
@@ -2,6 +2,7 @@ package phase_test
 
 import (
 	"bytes"
+	"fmt"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -132,6 +133,9 @@ func testGenerator(t *testing.T, when spec.G, it spec.S) {
 	// when we set up the table tests.
 	var tmpErr error
 	tmpDir, tmpErr = os.MkdirTemp("", "lifecycle")
+	generatedDir = filepath.Join(tmpDir, "output")
+	appDir = filepath.Join(generatedDir, "app")
+	platformDir = filepath.Join(tmpDir, "platform")
 
 	it.Before(func() {
 		h.AssertNil(t, tmpErr)
@@ -140,9 +144,6 @@ func testGenerator(t *testing.T, when spec.G, it spec.S) {
 		dirStore = testmock.NewMockDirStore(mockCtrl)
 		executor = testmock.NewMockGenerateExecutor(mockCtrl)
 
-		generatedDir = filepath.Join(tmpDir, "output")
-		appDir = filepath.Join(generatedDir, "app")
-		platformDir = filepath.Join(tmpDir, "platform")
 		h.Mkdir(t, generatedDir, appDir, filepath.Join(platformDir, "env"))
 		stdout, stderr = &bytes.Buffer{}, &bytes.Buffer{}
 
@@ -174,697 +175,495 @@ func testGenerator(t *testing.T, when spec.G, it spec.S) {
 		extB := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "ext/B", Version: "v1"}}}
 		extC := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "C", Version: "v1"}}}
 
-		when("using the platform API 0.12", func() {
-			it.Before(func() {
-				generator.PlatformAPI = api.MustParse("0.12")
-			})
-
-			it("provides a subset of the build plan to each extension", func() {
-				generator.Plan = files.Plan{
-					Entries: []files.BuildPlanEntry{
-						{
-							Providers: []buildpack.GroupElement{
-								{ID: "A", Version: "v1"}, // not provided to any extension because Extension is false
-							},
-							Requires: []buildpack.Require{
-								{Name: "buildpack-dep", Version: "v1"},
-							},
-						},
-						{
-							Providers: []buildpack.GroupElement{
-								{ID: "A", Version: "v1", Extension: true},
-								{ID: "ext/B", Version: "v2", Extension: true},
-							},
-							Requires: []buildpack.Require{
-								{Name: "some-dep", Version: "v1"}, // not provided to B because it is met
-							},
-						},
-						{
-							Providers: []buildpack.GroupElement{
-								{ID: "A", Version: "v1", Extension: true},
-								{ID: "ext/B", Version: "v2", Extension: true},
-							},
-							Requires: []buildpack.Require{
-								{Name: "some-unmet-dep", Version: "v2"}, // provided to ext/B because it is unmet
-							},
-						},
-						{
-							Providers: []buildpack.GroupElement{
-								{ID: "ext/B", Version: "v2", Extension: true},
-							},
-							Requires: []buildpack.Require{
-								{Name: "other-dep", Version: "v4"}, // only provided to ext/B
-							},
-						},
-					},
-				}
-				expectedInputs := buildpack.GenerateInputs{
-					AppDir:      appDir,
-					PlatformDir: platformDir,
-					// OutputDir is ephemeral directory
-				}
-
-				dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-				expectedAInputs := expectedInputs
-				expectedAInputs.Plan = buildpack.Plan{Entries: []buildpack.Require{
-					{Name: "some-dep", Version: "v1"},
-					{Name: "some-unmet-dep", Version: "v2"},
-				}}
-				executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).DoAndReturn(
-					func(_ buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ llog.Logger) (buildpack.GenerateOutputs, error) {
-						h.AssertEq(t, inputs.Plan, expectedAInputs.Plan)
-						h.AssertEq(t, inputs.AppDir, expectedAInputs.AppDir)
-						h.AssertEq(t, inputs.PlatformDir, expectedAInputs.PlatformDir)
-						h.AssertEq(t, inputs.BuildConfigDir, generator.BuildConfigDir)
-						return buildpack.GenerateOutputs{MetRequires: []string{"some-dep"}}, nil
-					})
-
-				dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
-				expectedBInputs := expectedInputs
-				expectedBInputs.Plan = buildpack.Plan{Entries: []buildpack.Require{
-					{Name: "some-unmet-dep", Version: "v2"},
-					{Name: "other-dep", Version: "v4"},
-				}}
-				executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Do(
-					func(_ buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ llog.Logger) (buildpack.GenerateOutputs, error) {
-						h.AssertEq(t, inputs.Plan, expectedBInputs.Plan)
-						h.AssertEq(t, inputs.AppDir, expectedBInputs.AppDir)
-						h.AssertEq(t, inputs.PlatformDir, expectedBInputs.PlatformDir)
-						h.AssertEq(t, inputs.BuildConfigDir, generator.BuildConfigDir)
-						return buildpack.GenerateOutputs{}, nil
-					})
-
-				_, err := generator.Generate()
-				h.AssertNil(t, err)
-			})
-
-			it("passes through CNB_TARGET environment variables", func() {
-				generator.AnalyzedMD = files.Analyzed{
-					RunImage: &files.RunImage{
-						TargetMetadata: &files.TargetMetadata{
-							OS:   "linux",
-							Arch: "amd64",
-						},
-					},
-				}
-				// mock generate for extensions  - these are tested elsewhere, so we just need to return anything...
-				dirStore.EXPECT().LookupExt(gomock.Any(), gomock.Any()).Return(&extA, nil)
-				dirStore.EXPECT().LookupExt(gomock.Any(), gomock.Any()).Return(&extB, nil)
-				// extension A has a build.Dockerfile and an extend-config.toml
-				h.Mkdir(t, filepath.Join(tmpDir, "A"))
-				buildDockerfilePathA := filepath.Join(tmpDir, "A", "build.Dockerfile")
-				h.Mkfile(t, "some-build.Dockerfile-content-A", buildDockerfilePathA)
-				extendConfigPathA := filepath.Join(tmpDir, "A", "extend-config.toml")
-				h.Mkfile(t, "some-extend-config.toml-content-A", extendConfigPathA)
-				executor.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
-					func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_ARCH=amd64")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_ARCH_VARIANT=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_DISTRO_NAME=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_DISTRO_VERSION=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_OS=linux")
-						return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
-							Kind: "build", Path: buildDockerfilePathA}}}, nil
-					})
-				executor.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
-					func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_ARCH=amd64")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_ARCH_VARIANT=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_DISTRO_NAME=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_DISTRO_VERSION=")
-						h.AssertContains(t, inputs.Env.List(), "CNB_TARGET_OS=linux")
-						return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
-							Kind: "build", Path: buildDockerfilePathA}}}, nil
-					})
-				_, err := generator.Generate()
-				h.AssertNil(t, err)
-			})
-
-			it("copies Dockerfiles and extend-config.toml files to the correct locations", func() {
-				// mock generate for extension A
-				dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-				// extension A has a build.Dockerfile and an extend-config.toml
-				h.Mkdir(t, filepath.Join(tmpDir, "A"))
-				buildDockerfilePathA := filepath.Join(tmpDir, "A", "build.Dockerfile")
-				h.Mkfile(t, "some-build.Dockerfile-content-A", buildDockerfilePathA)
-				extendConfigPathA := filepath.Join(tmpDir, "A", "extend-config.toml")
-				h.Mkfile(t, "some-extend-config.toml-content-A", extendConfigPathA)
-				executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-					Dockerfiles: []buildpack.DockerfileInfo{
-						{
-							ExtensionID: "A",
-							Kind:        "build",
-							Path:        buildDockerfilePathA,
-						},
-					},
-				}, nil)
-
-				// mock generate for extension B
-				dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
-				// extension B has a run.Dockerfile
-				h.Mkdir(t, filepath.Join(tmpDir, "B"))
-				runDockerfilePathB := filepath.Join(tmpDir, "B", "run.Dockerfile")
-				h.Mkfile(t, "some-run.Dockerfile-content-B", runDockerfilePathB)
-				executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-					Dockerfiles: []buildpack.DockerfileInfo{
-						{
-							ExtensionID: "B",
-							Kind:        "run",
-							Path:        runDockerfilePathB,
-						},
-					},
-				}, nil)
-
-				// mock generate for extension C
-				dirStore.EXPECT().LookupExt("C", "v1").Return(&extC, nil)
-				// extension C has a build.Dockerfile, run.Dockerfile, and extend-config.toml
-				h.Mkdir(t, filepath.Join(tmpDir, "C"))
-				buildDockerfilePathC := filepath.Join(tmpDir, "C", "build.Dockerfile")
-				h.Mkfile(t, "some-build.Dockerfile-content-C", buildDockerfilePathC)
-				runDockerfilePathC := filepath.Join(tmpDir, "C", "run.Dockerfile")
-				h.Mkfile(t, "some-run.Dockerfile-content-C", runDockerfilePathC)
-				extendConfigPathC := filepath.Join(tmpDir, "C", "extend-config.toml")
-				h.Mkfile(t, "some-extend-config.toml-content-C", extendConfigPathC)
-				executor.EXPECT().Generate(extC, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-					Dockerfiles: []buildpack.DockerfileInfo{
-						{
-							ExtensionID: "C",
-							Kind:        "build",
-							Path:        buildDockerfilePathC,
-						},
-						{
-							ExtensionID: "C",
-							Kind:        "run",
-							Path:        runDockerfilePathC,
-						},
-					},
-				}, nil)
-
-				// add extension C to the group
-				generator.Extensions = append(generator.Extensions, buildpack.GroupElement{ID: "C", Version: "v1", API: api.Buildpack.Latest().String()})
-				// do generate
-				_, err := generator.Generate()
-				h.AssertNil(t, err)
-
-				t.Log("copies Dockerfiles")
-				contents := h.MustReadFile(t, filepath.Join(generatedDir, "build", "A", "Dockerfile"))
-				h.AssertEq(t, string(contents), "some-build.Dockerfile-content-A")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "B", "Dockerfile"))
-				h.AssertEq(t, string(contents), "some-run.Dockerfile-content-B")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "C", "Dockerfile"))
-				h.AssertEq(t, string(contents), "some-build.Dockerfile-content-C")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "C", "Dockerfile"))
-				h.AssertEq(t, string(contents), "some-run.Dockerfile-content-C")
-
-				t.Log("copies extend-config.toml files if they exist")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "A", "extend-config.toml"))
-				h.AssertEq(t, string(contents), "some-extend-config.toml-content-A")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "C", "extend-config.toml"))
-				h.AssertEq(t, string(contents), "some-extend-config.toml-content-C")
-				contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "C", "extend-config.toml"))
-				h.AssertEq(t, string(contents), "some-extend-config.toml-content-C")
-
-				t.Log("does not pollute the output directory")
-				h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "A", "run.Dockerfile"))
-				h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "B", "build.Dockerfile"))
-				h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "C", "run.Dockerfile"))
-				h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "C", "build.Dockerfile"))
-			})
-
-			when("returning run image metadata", func() {
-				var (
-					runDockerfilePathA = filepath.Join(tmpDir, "run.Dockerfile.A")
-					runDockerfilePathB = filepath.Join(tmpDir, "run.Dockerfile.B")
-				)
+		for _, platformAPI := range []*api.Version{
+			api.MustParse("0.12"),
+			api.MustParse("0.13"),
+		} {
+			platformAPI := platformAPI
 
+			when(fmt.Sprintf("using the platform API %s", platformAPI), func() {
 				it.Before(func() {
-					h.Mkfile(t, "some-dockerfile-content-A", runDockerfilePathA)
-					h.Mkfile(t, "some-dockerfile-content-B", runDockerfilePathB)
-
-					generator.AnalyzedMD = files.Analyzed{
-						RunImage: &files.RunImage{
-							Reference: "some-existing-run-image@sha256:s0m3d1g3st",
-							Image:     "some-existing-run-image",
-						},
-					}
+					generator.PlatformAPI = platformAPI
 				})
 
-				type testCase struct {
-					before                    func()
-					descCondition             string
-					descResult                string
-					aDockerfiles              []buildpack.DockerfileInfo
-					bDockerfiles              []buildpack.DockerfileInfo
-					expectedRunImageImage     string
-					expectedRunImageReference string
-					expectedRunImageExtend    bool
-					expectedErr               string
-					assertAfter               func()
-				}
-				for _, tc := range []testCase{
-					{
-						descCondition: "all run.Dockerfiles declare `FROM ${base_image}`",
-						descResult:    "returns the original run image in the result",
-						aDockerfiles: []buildpack.DockerfileInfo{{
-							ExtensionID: "A",
-							Kind:        "run",
-							Path:        runDockerfilePathA,
-							WithBase:    "",
-							Extend:      true,
-						}},
-						bDockerfiles: []buildpack.DockerfileInfo{{
-							ExtensionID: "B",
-							Kind:        "run",
-							Path:        runDockerfilePathB,
-							WithBase:    "",
-							Extend:      true,
-						}},
-						expectedRunImageImage:     "some-existing-run-image",
-						expectedRunImageReference: "some-existing-run-image@sha256:s0m3d1g3st",
-						expectedRunImageExtend:    true,
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image and run.Dockerfiles follow",
-						descResult:    "returns the last image referenced in the `FROM` statement of the last run.Dockerfile not to declare `FROM ${base_image}`, and sets extend to true",
-						aDockerfiles: []buildpack.DockerfileInfo{
+				it("provides a subset of the build plan to each extension", func() {
+					generator.Plan = files.Plan{
+						Entries: []files.BuildPlanEntry{
 							{
-								ExtensionID: "A",
-								Kind:        "run",
-								Path:        runDockerfilePathA,
-								WithBase:    "some-new-run-image",
-								Extend:      false,
-							},
-						},
-						bDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "B",
-								Kind:        "run",
-								Path:        runDockerfilePathB,
-								WithBase:    "",
-								Extend:      true,
+								Providers: []buildpack.GroupElement{
+									{ID: "some-buildpack", Version: "v1"}, // not provided to any extension because Extension is false
+								},
+								Requires: []buildpack.Require{
+									{Name: "buildpack-dep", Version: "v1"},
+								},
 							},
-						},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    true,
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image (only) and no run.Dockerfiles follow",
-						descResult:    "returns the referenced base image, and sets extend to false",
-						aDockerfiles: []buildpack.DockerfileInfo{
 							{
-								ExtensionID: "A",
-								Kind:        "run",
-								Path:        runDockerfilePathA,
-								WithBase:    "some-new-run-image",
-								Extend:      true,
+								Providers: []buildpack.GroupElement{
+									{ID: "A", Version: "v1", Extension: true},
+									{ID: "ext/B", Version: "v2", Extension: true},
+								},
+								Requires: []buildpack.Require{
+									{Name: "some-dep", Version: "v1"}, // not provided to B because it is met
+								},
 							},
-						},
-						bDockerfiles: []buildpack.DockerfileInfo{
 							{
-								ExtensionID: "B",
-								Kind:        "run",
-								Path:        runDockerfilePathB,
-								WithBase:    "some-other-base-image",
-								Extend:      false,
+								Providers: []buildpack.GroupElement{
+									{ID: "A", Version: "v1", Extension: true},
+									{ID: "ext/B", Version: "v2", Extension: true},
+								},
+								Requires: []buildpack.Require{
+									{Name: "some-unmet-dep", Version: "v2"}, // provided to ext/B because it is unmet
+								},
 							},
-						},
-						expectedRunImageImage:     "some-other-base-image",
-						expectedRunImageReference: "some-other-base-image",
-						expectedRunImageExtend:    false,
-						assertAfter: func() {
-							t.Log("copies Dockerfiles to the correct locations")
-							t.Log("renames earlier run.Dockerfiles to Dockerfile.ignore in the output directory")
-							aContents := h.MustReadFile(t, filepath.Join(generatedDir, "run", "A", "Dockerfile.ignore"))
-							h.AssertEq(t, string(aContents), `some-dockerfile-content-A`)
-							BContents := h.MustReadFile(t, filepath.Join(generatedDir, "run", "B", "Dockerfile"))
-							h.AssertEq(t, string(BContents), `some-dockerfile-content-B`)
-						},
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image and also extends",
-						descResult:    "returns the referenced base image, and sets extend to true",
-						aDockerfiles: []buildpack.DockerfileInfo{
 							{
-								ExtensionID: "A",
-								Kind:        "run",
-								Path:        runDockerfilePathA,
-								WithBase:    "some-new-run-image",
-								Extend:      true,
-							},
-						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    true,
-					},
-					{
-						before: func() {
-							generator.RunMetadata = files.Run{
-								Images: []files.RunImageForExport{
-									{Image: "some-run-image"},
+								Providers: []buildpack.GroupElement{
+									{ID: "ext/B", Version: "v2", Extension: true},
 								},
-							}
-						},
-						descCondition: "run metadata is provided and contains new run image",
-						descResult:    "succeeds",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								Path:        runDockerfilePathA,
-								WithBase:    "some-new-run-image",
-								Extend:      false,
-							},
-						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    false,
-					},
-					{
-						before: func() {
-							generator.RunMetadata = files.Run{
-								Images: []files.RunImageForExport{
-									{Image: "some-run-image"},
+								Requires: []buildpack.Require{
+									{Name: "other-dep", Version: "v4"}, // only provided to ext/B
 								},
-							}
-						},
-						descCondition: "run metadata is provided and does not contain new run image",
-						descResult:    "succeeds with warning",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								Path:        runDockerfilePathA,
-								WithBase:    "some-other-run-image",
-								Extend:      false,
 							},
 						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-other-run-image",
-						expectedRunImageReference: "some-other-run-image",
-						assertAfter: func() {
-							h.AssertLogEntry(t, logHandler, "new runtime base image 'some-other-run-image' not found in run metadata")
-						},
-					},
-				} {
-					tc := tc
-					when := when
-					when(tc.descCondition, func() {
-						if tc.before != nil {
-							it.Before(tc.before)
-						}
-
-						it(tc.descResult, func() {
-							// mock generate for extension A
-							dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-							executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-								Dockerfiles: tc.aDockerfiles,
-							}, nil)
+					}
+					expectedInputs := buildpack.GenerateInputs{
+						AppDir:      appDir,
+						PlatformDir: platformDir,
+						// OutputDir is ephemeral directory
+					}
 
-							// mock generate for extension B
-							dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
-							executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-								Dockerfiles: tc.bDockerfiles,
-							}, nil)
-
-							// do generate
-							result, err := generator.Generate()
-							if err == nil {
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Image, tc.expectedRunImageImage)
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Reference, tc.expectedRunImageReference)
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Extend, tc.expectedRunImageExtend)
-							} else {
-								t.Log(err)
-								h.AssertError(t, err, tc.expectedErr)
-							}
+					dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
+					expectedAInputs := expectedInputs
+					expectedAInputs.Plan = buildpack.Plan{Entries: []buildpack.Require{
+						{Name: "some-dep", Version: "v1"},
+						{Name: "some-unmet-dep", Version: "v2"},
+					}}
+					executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).DoAndReturn(
+						func(_ buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ llog.Logger) (buildpack.GenerateOutputs, error) {
+							h.AssertEq(t, inputs.Plan, expectedAInputs.Plan)
+							h.AssertEq(t, inputs.AppDir, expectedAInputs.AppDir)
+							h.AssertEq(t, inputs.PlatformDir, expectedAInputs.PlatformDir)
+							h.AssertEq(t, inputs.BuildConfigDir, generator.BuildConfigDir)
+							return buildpack.GenerateOutputs{MetRequires: []string{"some-dep"}}, nil
+						})
 
-							if tc.assertAfter != nil {
-								tc.assertAfter()
-							}
+					dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
+					expectedBInputs := expectedInputs
+					expectedBInputs.Plan = buildpack.Plan{Entries: []buildpack.Require{
+						{Name: "some-unmet-dep", Version: "v2"},
+						{Name: "other-dep", Version: "v4"},
+					}}
+					executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Do(
+						func(_ buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ llog.Logger) (buildpack.GenerateOutputs, error) {
+							h.AssertEq(t, inputs.Plan, expectedBInputs.Plan)
+							h.AssertEq(t, inputs.AppDir, expectedBInputs.AppDir)
+							h.AssertEq(t, inputs.PlatformDir, expectedBInputs.PlatformDir)
+							h.AssertEq(t, inputs.BuildConfigDir, generator.BuildConfigDir)
+							return buildpack.GenerateOutputs{}, nil
 						})
-					})
-				}
-			})
 
-			when("extension generate failed", func() {
-				when("first extension fails", func() {
-					it("errors", func() {
-						extA := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "A", Version: "v1"}}}
-						dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-						executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, errors.New("some error"))
+					_, err := generator.Generate()
+					h.AssertNil(t, err)
+				})
 
-						if _, err := generator.Generate(); err == nil {
-							t.Fatal("Expected error.\n")
-						} else if !strings.Contains(err.Error(), "some error") {
-							t.Fatalf("Incorrect error: %s\n", err)
-						}
-					})
+				it("passes through CNB_TARGET environment variables", func() {
+					generator.AnalyzedMD = files.Analyzed{
+						RunImage: &files.RunImage{
+							TargetMetadata: &files.TargetMetadata{
+								OS:   "linux",
+								Arch: "amd64",
+							},
+						},
+					}
+					// mock generate for extensions  - these are tested elsewhere, so we just need to return anything...
+					dirStore.EXPECT().LookupExt(gomock.Any(), gomock.Any()).Return(&extA, nil)
+					dirStore.EXPECT().LookupExt(gomock.Any(), gomock.Any()).Return(&extB, nil)
+					buildDockerfilePathA := filepath.Join(tmpDir, "dummy.Dockerfile")
+					h.Mkfile(t, "some-build.Dockerfile-content-A", buildDockerfilePathA)
+					executor.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+						func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
+							h.AssertContains(t, inputs.Env.List(),
+								"CNB_TARGET_ARCH=amd64",
+								"CNB_TARGET_ARCH_VARIANT=",
+								"CNB_TARGET_DISTRO_NAME=",
+								"CNB_TARGET_DISTRO_VERSION=",
+								"CNB_TARGET_OS=linux",
+							)
+							return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
+								Kind: "build", Path: buildDockerfilePathA}}}, nil
+						})
+					executor.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+						func(d buildpack.ExtDescriptor, inputs buildpack.GenerateInputs, _ *log.Logger) (buildpack.GenerateOutputs, error) {
+							h.AssertContains(t, inputs.Env.List(),
+								"CNB_TARGET_ARCH=amd64",
+								"CNB_TARGET_ARCH_VARIANT=",
+								"CNB_TARGET_DISTRO_NAME=",
+								"CNB_TARGET_DISTRO_VERSION=",
+								"CNB_TARGET_OS=linux",
+							)
+							return buildpack.GenerateOutputs{Dockerfiles: []buildpack.DockerfileInfo{{ExtensionID: d.Extension.ID,
+								Kind: "build", Path: buildDockerfilePathA}}}, nil
+						})
+					_, err := generator.Generate()
+					h.AssertNil(t, err)
 				})
 
-				when("later extension fails", func() {
-					it("errors", func() {
-						extA := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "A", Version: "v1"}}}
+				if platformAPI.LessThan("0.13") { // Platform API >= 0.13 does no longer require Dockerfile copying
+					it("copies Dockerfiles and extend-config.toml files to the correct locations", func() {
+						// mock generate for extension A
 						dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-						executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, nil)
-						extB := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "ext/B", Version: "v1"}}}
-						dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
-						executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, errors.New("some error"))
+						// extension A has a build.Dockerfile and an extend-config.toml
+						h.Mkdir(t, filepath.Join(tmpDir, "A"))
+						buildDockerfilePathA := filepath.Join(tmpDir, "A", "build.Dockerfile")
+						h.Mkfile(t, "some-build.Dockerfile-content-A", buildDockerfilePathA)
+						extendConfigPathA := filepath.Join(tmpDir, "A", "extend-config.toml")
+						h.Mkfile(t, "some-extend-config.toml-content-A", extendConfigPathA)
+						executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
+							Dockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "build",
+									Path:        buildDockerfilePathA,
+								},
+							},
+						}, nil)
 
-						if _, err := generator.Generate(); err == nil {
-							t.Fatal("Expected error.\n")
-						} else if !strings.Contains(err.Error(), "some error") {
-							t.Fatalf("Incorrect error: %s\n", err)
-						}
+						// mock generate for extension B
+						dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
+						// extension B has a run.Dockerfile
+						h.Mkdir(t, filepath.Join(tmpDir, "B"))
+						runDockerfilePathB := filepath.Join(tmpDir, "B", "run.Dockerfile")
+						h.Mkfile(t, "some-run.Dockerfile-content-B", runDockerfilePathB)
+						executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
+							Dockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "B",
+									Kind:        "run",
+									Path:        runDockerfilePathB,
+								},
+							},
+						}, nil)
+
+						// mock generate for extension C
+						dirStore.EXPECT().LookupExt("C", "v1").Return(&extC, nil)
+						// extension C has a build.Dockerfile, run.Dockerfile, and extend-config.toml
+						h.Mkdir(t, filepath.Join(tmpDir, "C"))
+						buildDockerfilePathC := filepath.Join(tmpDir, "C", "build.Dockerfile")
+						h.Mkfile(t, "some-build.Dockerfile-content-C", buildDockerfilePathC)
+						runDockerfilePathC := filepath.Join(tmpDir, "C", "run.Dockerfile")
+						h.Mkfile(t, "some-run.Dockerfile-content-C", runDockerfilePathC)
+						extendConfigPathC := filepath.Join(tmpDir, "C", "extend-config.toml")
+						h.Mkfile(t, "some-extend-config.toml-content-C", extendConfigPathC)
+						executor.EXPECT().Generate(extC, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
+							Dockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "C",
+									Kind:        "build",
+									Path:        buildDockerfilePathC,
+								},
+								{
+									ExtensionID: "C",
+									Kind:        "run",
+									Path:        runDockerfilePathC,
+								},
+							},
+						}, nil)
+
+						// add extension C to the group
+						generator.Extensions = append(generator.Extensions, buildpack.GroupElement{ID: "C", Version: "v1", API: api.Buildpack.Latest().String()})
+						// do generate
+						_, err := generator.Generate()
+						h.AssertNil(t, err)
+
+						t.Log("copies Dockerfiles")
+
+						contents := h.MustReadFile(t, filepath.Join(generatedDir, "build", "A", "Dockerfile"))
+						h.AssertEq(t, string(contents), "some-build.Dockerfile-content-A")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "B", "Dockerfile"))
+						h.AssertEq(t, string(contents), "some-run.Dockerfile-content-B")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "C", "Dockerfile"))
+						h.AssertEq(t, string(contents), "some-build.Dockerfile-content-C")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "C", "Dockerfile"))
+						h.AssertEq(t, string(contents), "some-run.Dockerfile-content-C")
+
+						t.Log("copies extend-config.toml files if they exist")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "A", "extend-config.toml"))
+						h.AssertEq(t, string(contents), "some-extend-config.toml-content-A")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "build", "C", "extend-config.toml"))
+						h.AssertEq(t, string(contents), "some-extend-config.toml-content-C")
+						contents = h.MustReadFile(t, filepath.Join(generatedDir, "run", "C", "extend-config.toml"))
+						h.AssertEq(t, string(contents), "some-extend-config.toml-content-C")
+
+						t.Log("does not pollute the output directory")
+						h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "A", "run.Dockerfile"))
+						h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "B", "build.Dockerfile"))
+						h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "C", "run.Dockerfile"))
+						h.AssertPathDoesNotExist(t, filepath.Join(generatedDir, "C", "build.Dockerfile"))
 					})
-				})
-			})
-		})
-
-		when("using the platform API 0.13", func() {
-			it.Before(func() {
-				generator.PlatformAPI = api.MustParse("0.13")
-			})
+				}
 
-			when("returning run image metadata", func() {
-				var (
-					runDockerfilePathA string
-					runDockerfilePathB string
-				)
+				when("returning run image metadata", func() {
+					var runDockerfilePathA, runDockerfilePathB string
+					if platformAPI.AtLeast("0.13") {
+						runDockerfilePathA = filepath.Join(generatedDir, "A", "run.Dockerfile.A")
+						runDockerfilePathB = filepath.Join(generatedDir, "B", "run.Dockerfile.B")
+					} else {
+						runDockerfilePathA = filepath.Join(tmpDir, "run.Dockerfile.A")
+						runDockerfilePathB = filepath.Join(tmpDir, "run.Dockerfile.B")
+					}
 
-				it.Before(func() {
-					runDockerfilePathA = filepath.Join(generatedDir, "A", "run.Dockerfile.A")
-					runDockerfilePathB = filepath.Join(generatedDir, "B", "run.Dockerfile.B")
+					it.Before(func() {
+						h.Mkdir(t, filepath.Dir(runDockerfilePathA), filepath.Dir(runDockerfilePathB))
+						h.Mkfile(t, "some-dockerfile-content-A", runDockerfilePathA)
+						h.Mkfile(t, "some-dockerfile-content-B", runDockerfilePathB)
 
-					h.Mkdir(t, filepath.Join(generatedDir, "A"), filepath.Join(generatedDir, "B"))
-					h.Mkfile(t, "some-dockerfile-content-A", runDockerfilePathA)
-					h.Mkfile(t, "some-dockerfile-content-B", runDockerfilePathB)
+						generator.AnalyzedMD = files.Analyzed{
+							RunImage: &files.RunImage{
+								Reference: "some-existing-run-image@sha256:s0m3d1g3st",
+								Image:     "some-existing-run-image",
+							},
+						}
+					})
 
-					generator.AnalyzedMD = files.Analyzed{
-						RunImage: &files.RunImage{
-							Reference: "some-existing-run-image@sha256:s0m3d1g3st",
-							Image:     "some-existing-run-image",
-						},
+					type testCase struct {
+						before                    func()
+						descCondition             string
+						descResult                string
+						aDockerfiles              []buildpack.DockerfileInfo
+						bDockerfiles              []buildpack.DockerfileInfo
+						expectedRunImageImage     string
+						expectedRunImageReference string
+						expectedRunImageExtend    bool
+						expectedErr               string
+						assertAfter               func()
 					}
-				})
-
-				type testCase struct {
-					before                    func()
-					descCondition             string
-					descResult                string
-					aDockerfiles              []buildpack.DockerfileInfo
-					bDockerfiles              []buildpack.DockerfileInfo
-					expectedRunImageImage     string
-					expectedRunImageReference string
-					expectedRunImageExtend    bool
-					expectedErr               string
-					assertAfter               func()
-				}
-				for _, tc := range []testCase{
-					{
-						descCondition: "all run.Dockerfiles declare `FROM ${base_image}`",
-						descResult:    "returns the original run image in the result",
-						aDockerfiles: []buildpack.DockerfileInfo{{
-							ExtensionID: "A",
-							Kind:        "run",
-							WithBase:    "",
-							Extend:      true,
-						}},
-						bDockerfiles: []buildpack.DockerfileInfo{{
-							ExtensionID: "B",
-							Kind:        "run",
-							WithBase:    "",
-							Extend:      true,
-						}},
-						expectedRunImageImage:     "some-existing-run-image",
-						expectedRunImageReference: "some-existing-run-image@sha256:s0m3d1g3st",
-						expectedRunImageExtend:    true,
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image and run.Dockerfiles follow",
-						descResult:    "returns the last image referenced in the `FROM` statement of the last run.Dockerfile not to declare `FROM ${base_image}`, and sets extend to true",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
+					for _, tc := range []testCase{
+						{
+							descCondition: "all run.Dockerfiles declare `FROM ${base_image}`",
+							descResult:    "returns the original run image in the result",
+							aDockerfiles: []buildpack.DockerfileInfo{{
 								ExtensionID: "A",
 								Kind:        "run",
-								WithBase:    "some-new-run-image",
-								Extend:      false,
-							},
-						},
-						bDockerfiles: []buildpack.DockerfileInfo{
-							{
+								Path:        runDockerfilePathA,
+								WithBase:    "",
+								Extend:      true,
+							}},
+							bDockerfiles: []buildpack.DockerfileInfo{{
 								ExtensionID: "B",
 								Kind:        "run",
+								Path:        runDockerfilePathB,
 								WithBase:    "",
 								Extend:      true,
-							},
+							}},
+							expectedRunImageImage:     "some-existing-run-image",
+							expectedRunImageReference: "some-existing-run-image@sha256:s0m3d1g3st",
+							expectedRunImageExtend:    true,
 						},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    true,
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image (only) and no run.Dockerfiles follow",
-						descResult:    "returns the referenced base image, and sets extend to false",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								WithBase:    "some-new-run-image",
-								Extend:      true,
+						{
+							descCondition: "a run.Dockerfile declares a new base image and run.Dockerfiles follow",
+							descResult:    "returns the last image referenced in the `FROM` statement of the last run.Dockerfile not to declare `FROM ${base_image}`, and sets extend to true",
+							aDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "run",
+									Path:        runDockerfilePathA,
+									WithBase:    "some-new-run-image",
+									Extend:      false,
+								},
 							},
-						},
-						bDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "B",
-								Kind:        "run",
-								WithBase:    "some-other-base-image",
-								Extend:      false,
+							bDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "B",
+									Kind:        "run",
+									Path:        runDockerfilePathB,
+									WithBase:    "",
+									Extend:      true,
+								},
 							},
+							expectedRunImageImage:     "some-new-run-image",
+							expectedRunImageReference: "some-new-run-image",
+							expectedRunImageExtend:    true,
 						},
-						expectedRunImageImage:     "some-other-base-image",
-						expectedRunImageReference: "some-other-base-image",
-						expectedRunImageExtend:    false,
-						assertAfter: func() {
-							t.Log("renames earlier run.Dockerfiles to Dockerfile.ignore in the output directory")
-							aContents := h.MustReadFile(t, filepath.Join(generatedDir, "A", "run.Dockerfile.A.ignore"))
-							h.AssertEq(t, string(aContents), `some-dockerfile-content-A`)
-							BContents := h.MustReadFile(t, filepath.Join(generatedDir, "B", "run.Dockerfile.B"))
-							h.AssertEq(t, string(BContents), `some-dockerfile-content-B`)
-						},
-					},
-					{
-						descCondition: "a run.Dockerfile declares a new base image and also extends",
-						descResult:    "returns the referenced base image, and sets extend to true",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								WithBase:    "some-new-run-image",
-								Extend:      true,
+						{
+							descCondition: "a run.Dockerfile declares a new base image (only) and no run.Dockerfiles follow",
+							descResult:    "returns the referenced base image, and sets extend to false",
+							aDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "run",
+									Path:        runDockerfilePathA,
+									WithBase:    "some-new-run-image",
+									Extend:      true,
+								},
 							},
-						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    true,
-					},
-					{
-						before: func() {
-							generator.RunMetadata = files.Run{
-								Images: []files.RunImageForExport{
-									{Image: "some-run-image"},
+							bDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "B",
+									Kind:        "run",
+									Path:        runDockerfilePathB,
+									WithBase:    "some-other-base-image",
+									Extend:      false,
 								},
-							}
-						},
-						descCondition: "run metadata is provided and contains new run image",
-						descResult:    "succeeds",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								WithBase:    "some-new-run-image",
-								Extend:      false,
+							},
+							expectedRunImageImage:     "some-other-base-image",
+							expectedRunImageReference: "some-other-base-image",
+							expectedRunImageExtend:    false,
+							assertAfter: func() {
+								var runDockerfileDestinationPathA, runDockerfileDestinationPathB string
+								if platformAPI.AtLeast("0.13") {
+									runDockerfileDestinationPathA = runDockerfilePathA + ".ignore"
+									runDockerfileDestinationPathB = runDockerfilePathB
+								} else {
+									runDockerfileDestinationPathA = filepath.Join(generatedDir, "run", "A", "Dockerfile.ignore")
+									runDockerfileDestinationPathB = filepath.Join(generatedDir, "run", "B", "Dockerfile")
+									t.Log("copies Dockerfiles to the correct locations")
+								}
+								t.Log("renames earlier run.Dockerfiles to Dockerfile.ignore in the output directory")
+								aContents := h.MustReadFile(t, runDockerfileDestinationPathA)
+								h.AssertEq(t, string(aContents), `some-dockerfile-content-A`)
+								bContents := h.MustReadFile(t, runDockerfileDestinationPathB)
+								h.AssertEq(t, string(bContents), `some-dockerfile-content-B`)
 							},
 						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-new-run-image",
-						expectedRunImageReference: "some-new-run-image",
-						expectedRunImageExtend:    false,
-					},
-					{
-						before: func() {
-							generator.RunMetadata = files.Run{
-								Images: []files.RunImageForExport{
-									{Image: "some-run-image"},
+						{
+							descCondition: "a run.Dockerfile declares a new base image and also extends",
+							descResult:    "returns the referenced base image, and sets extend to true",
+							aDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "run",
+									Path:        runDockerfilePathA,
+									WithBase:    "some-new-run-image",
+									Extend:      true,
 								},
-							}
+							},
+							bDockerfiles:              []buildpack.DockerfileInfo{},
+							expectedRunImageImage:     "some-new-run-image",
+							expectedRunImageReference: "some-new-run-image",
+							expectedRunImageExtend:    true,
 						},
-						descCondition: "run metadata is provided and does not contain new run image",
-						descResult:    "succeeds with warning",
-						aDockerfiles: []buildpack.DockerfileInfo{
-							{
-								ExtensionID: "A",
-								Kind:        "run",
-								WithBase:    "some-other-run-image",
-								Extend:      false,
+						{
+							before: func() {
+								generator.RunMetadata = files.Run{
+									Images: []files.RunImageForExport{
+										{Image: "some-run-image"},
+									},
+								}
 							},
+							descCondition: "run metadata is provided and contains new run image",
+							descResult:    "succeeds",
+							aDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "run",
+									Path:        runDockerfilePathA,
+									WithBase:    "some-new-run-image",
+									Extend:      false,
+								},
+							},
+							bDockerfiles:              []buildpack.DockerfileInfo{},
+							expectedRunImageImage:     "some-new-run-image",
+							expectedRunImageReference: "some-new-run-image",
+							expectedRunImageExtend:    false,
 						},
-						bDockerfiles:              []buildpack.DockerfileInfo{},
-						expectedRunImageImage:     "some-other-run-image",
-						expectedRunImageReference: "some-other-run-image",
-						assertAfter: func() {
-							h.AssertLogEntry(t, logHandler, "new runtime base image 'some-other-run-image' not found in run metadata")
+						{
+							before: func() {
+								generator.RunMetadata = files.Run{
+									Images: []files.RunImageForExport{
+										{Image: "some-run-image"},
+									},
+								}
+							},
+							descCondition: "run metadata is provided and does not contain new run image",
+							descResult:    "succeeds with warning",
+							aDockerfiles: []buildpack.DockerfileInfo{
+								{
+									ExtensionID: "A",
+									Kind:        "run",
+									Path:        runDockerfilePathA,
+									WithBase:    "some-other-run-image",
+									Extend:      false,
+								},
+							},
+							bDockerfiles:              []buildpack.DockerfileInfo{},
+							expectedRunImageImage:     "some-other-run-image",
+							expectedRunImageReference: "some-other-run-image",
+							assertAfter: func() {
+								h.AssertLogEntry(t, logHandler, "new runtime base image 'some-other-run-image' not found in run metadata")
+							},
 						},
-					},
-				} {
-					tc := tc
-					when := when
-					when(tc.descCondition, func() {
-						if tc.before != nil {
-							it.Before(tc.before)
-						}
-
-						it(tc.descResult, func() {
-							for i := range tc.aDockerfiles {
-								tc.aDockerfiles[i].Path = runDockerfilePathA
+					} {
+						tc := tc
+						when := when
+						when(tc.descCondition, func() {
+							if tc.before != nil {
+								it.Before(tc.before)
 							}
 
-							for i := range tc.bDockerfiles {
-								tc.bDockerfiles[i].Path = runDockerfilePathB
-							}
+							it(tc.descResult, func() {
+								// mock generate for extension A
+								dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
+								executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
+									Dockerfiles: tc.aDockerfiles,
+								}, nil)
+
+								// mock generate for extension B
+								dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
+								executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
+									Dockerfiles: tc.bDockerfiles,
+								}, nil)
+
+								// do generate
+								result, err := generator.Generate()
+								if err == nil {
+									h.AssertEq(t, result.AnalyzedMD.RunImage.Image, tc.expectedRunImageImage)
+									h.AssertEq(t, result.AnalyzedMD.RunImage.Reference, tc.expectedRunImageReference)
+									h.AssertEq(t, result.AnalyzedMD.RunImage.Extend, tc.expectedRunImageExtend)
+								} else {
+									t.Log(err)
+									h.AssertError(t, err, tc.expectedErr)
+								}
+
+								if tc.assertAfter != nil {
+									tc.assertAfter()
+								}
+							})
+						})
+					}
+				})
 
-							// mock generate for extension A
+				when("extension generate failed", func() {
+					when("first extension fails", func() {
+						it("errors", func() {
+							extA := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "A", Version: "v1"}}}
 							dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
-							executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-								Dockerfiles: tc.aDockerfiles,
-							}, nil)
+							executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, errors.New("some error"))
 
-							// mock generate for extension B
-							dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
-							executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{
-								Dockerfiles: tc.bDockerfiles,
-							}, nil)
-
-							// do generate
-							result, err := generator.Generate()
-							if err == nil {
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Image, tc.expectedRunImageImage)
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Reference, tc.expectedRunImageReference)
-								h.AssertEq(t, result.AnalyzedMD.RunImage.Extend, tc.expectedRunImageExtend)
-							} else {
-								t.Log(err)
-								h.AssertError(t, err, tc.expectedErr)
+							if _, err := generator.Generate(); err == nil {
+								t.Fatal("Expected error.\n")
+							} else if !strings.Contains(err.Error(), "some error") {
+								t.Fatalf("Incorrect error: %s\n", err)
 							}
+						})
+					})
 
-							if tc.assertAfter != nil {
-								tc.assertAfter()
+					when("later extension fails", func() {
+						it("errors", func() {
+							extA := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "A", Version: "v1"}}}
+							dirStore.EXPECT().LookupExt("A", "v1").Return(&extA, nil)
+							executor.EXPECT().Generate(extA, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, nil)
+							extB := buildpack.ExtDescriptor{Extension: buildpack.ExtInfo{BaseInfo: buildpack.BaseInfo{ID: "ext/B", Version: "v1"}}}
+							dirStore.EXPECT().LookupExt("ext/B", "v2").Return(&extB, nil)
+							executor.EXPECT().Generate(extB, gomock.Any(), gomock.Any()).Return(buildpack.GenerateOutputs{}, errors.New("some error"))
+
+							if _, err := generator.Generate(); err == nil {
+								t.Fatal("Expected error.\n")
+							} else if !strings.Contains(err.Error(), "some error") {
+								t.Fatalf("Incorrect error: %s\n", err)
 							}
 						})
 					})
-				}
+				})
 			})
-		})
+		}
 	})
 }
diff --git a/platform/run_image_test.go b/platform/run_image_test.go
index bc7a6b83..deb9a05e 100644
--- a/platform/run_image_test.go
+++ b/platform/run_image_test.go
@@ -147,6 +147,60 @@ func testRunImage(t *testing.T, when spec.G, it spec.S) {
 		})
 	})
 
+	when(".GetRunImageFromMetadata", func() {
+		var inputs = platform.LifecycleInputs{
+			PlatformAPI: api.Platform.Latest(),
+		}
+
+		var md = files.LayersMetadata{}
+
+		when("run image not set in metadata", func() {
+			it("errors", func() {
+				result, err := platform.GetRunImageFromMetadata(inputs, md)
+				h.AssertNotNil(t, err)
+				h.AssertEq(t, result.Image, "")
+			})
+		})
+
+		when("run image set in runImage metadata", func() {
+			md.RunImage.RunImageForExport.Image = "run-image-in-metadata"
+
+			it("returns the run image from runImage metadata", func() {
+				result, err := platform.GetRunImageFromMetadata(inputs, md)
+				h.AssertNil(t, err)
+				h.AssertEq(t, result, files.RunImageForExport{Image: "run-image-in-metadata"})
+			})
+		})
+
+		when("run image set in stack metadata", func() {
+			md.Stack = &files.Stack{
+				RunImage: files.RunImageForExport{
+					Image: "run-image-in-stack-metadata",
+				},
+			}
+
+			it("returns the run image from stack metadata", func() {
+				result, err := platform.GetRunImageFromMetadata(inputs, md)
+				h.AssertNil(t, err)
+				h.AssertEq(t, result, files.RunImageForExport{Image: "run-image-in-stack-metadata"})
+			})
+		})
+
+		when("platform api < 0.12", func() {
+			inputs.PlatformAPI = api.MustParse("0.11")
+
+			when("run image set in runImage metadata", func() {
+				md.RunImage.RunImageForExport.Image = "run-image-in-metadata"
+
+				it("fails to return at this platform version", func() {
+					result, err := platform.GetRunImageFromMetadata(inputs, md)
+					h.AssertNotNil(t, err)
+					h.AssertEq(t, result.Image, "")
+				})
+			})
+		})
+	})
+
 	when(".EnvVarsFor", func() {
 		it("returns the right thing", func() {
 			tm := files.TargetMetadata{Arch: "pentium", ArchVariant: "mmx", ID: "my-id", OS: "linux", Distro: &files.OSDistro{Name: "nix", Version: "22.11"}}