Repository: getporter/porter
Pull request #1515 (2021-03-22 21:22:54)

feat(build/publish): add/use --dir and --file flags
# What does this change

* Allows users to build and publish with an optional path to the build/bundle context directory (`--dir`) and/or an optional path to the Porter manifest (`--file`, previously only exposed to `porter publish` and [not fully implemented](https://github.com/getporter/porter/issues/1498)).

# What issue does it fix
Closes https://github.com/getporter/porter/issues/1498

# Notes for the reviewer

Some "icky" bits of logic have been added to continue support for auto-building (if changes to the user-supplied manifest are detected) as well as conditionally adding the Dockerfile line that attempts to remove the user-supplied manifest from `BUNDLE_DIR` inside the installer image (now that this manifest -- optionally denoted by `--file` -- may exist separate from the build context dir -- optionally denoted by `--dir` -- we don't want to attempt to remove it when/if it hasn't been copied into `BUNDLE_DIR` in the first place).  See the latter two commits on this PR to take a closer look into these areas.

Also, as mentioned in some code comments, this PR currently uses the following assumptions:

1. If `--file path/to/porter.yaml` is provided only, ~~we assume all build/bundle assets should exist/be created in the directory path that the manifest exists in,  e.g. `path/to` in this example~~, we still assume build context is in the current working dir (".")
2. If `--file path/to/porter.yaml` and `--dir otherpath/to/bundle`, we assume all build/bundle assets should exist/be created in the directory path designated by `--dir`, e.g. `otherpath/to/bundle` in this example
3. If `--dir otherpath/to/bundle` is provided only, we assume that the Porter manifest exists in the current working directory (named `porter.yaml`) and *not* in the provided build context directory.   All build/bundle assets are assumed to exist (and will be created/updated) in `otherpath/to/bundle`.

Thoughts/suggestions/concerns?  For instance, for 3., I could potentially envision that we would want to update our assumption and expect that the Porter manifest indeed be located in the provided build context dir (designated by `--dir`).

# Checklist
- [x] Unit Tests
- [ ] Documentation
- [ ] Schema (porter.yaml)

If this is your first pull request, please add your name to the bottom of our [Contributors][contributors] list. Thank you for making Porter better! 🙇‍♀️

[contributors]: /CONTRIBUTORS.md

vdice: (Example build failure intended to be fixed in https://github.com/getporter/porter/pull/1517)
carolynvs: I just merged the fix and kicked off the build again.
carolynvs: Oh it just failed again with the same error. I guess you will need to rebase.
vdice: 😭 It looks like I hit a _different_ error: https://dev.azure.com/getporter/porter/_build/results?buildId=1098&view=logs&j=80fc20b5-bf9c-52e8-5f6f-1fdb6790c904&s=4304c483-acc4-5f4c-d091-add65b819655&t=b6215415-61fa-5101-eccb-e1abfdceed27&l=134

Trying a rerun before attempting to hunt it down...
carolynvs: Success!

Patch:
diff --git a/cmd/porter/bundle.go b/cmd/porter/bundle.go
index ab56c195..fbae860b 100644
--- a/cmd/porter/bundle.go
+++ b/cmd/porter/bundle.go
@@ -50,6 +50,15 @@ func buildBundleBuildCommand(p *porter.Porter) *cobra.Command {
 		Use:   "build",
 		Short: "Build a bundle",
 		Long:  "Builds the bundle in the current directory by generating a Dockerfile and a CNAB bundle.json, and then building the invocation image.",
+		Example: `  porter build
+  porter build --name newbuns
+  porter build --version 0.1.0
+  porter build --file path/to/porter.yaml
+  porter build --dir path/to/build/context
+`,
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return opts.Validate(p.Context)
+		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return p.Build(opts)
 		},
@@ -60,6 +69,10 @@ func buildBundleBuildCommand(p *porter.Porter) *cobra.Command {
 	f.BoolVarP(&opts.Verbose, "verbose", "v", false, "Enable verbose logging")
 	f.StringVar(&opts.Name, "name", "", "Override the bundle name")
 	f.StringVar(&opts.Version, "version", "", "Override the bundle version")
+	f.StringVarP(&opts.File, "file", "f", "",
+		"Path to the Porter manifest. Defaults to `porter.yaml` in the current directory.")
+	f.StringVarP(&opts.Dir, "dir", "d", "",
+		"Path to the build context directory where all bundle assets are located.")
 
 	return cmd
 }
@@ -72,6 +85,10 @@ func buildBundleLintCommand(p *porter.Porter) *cobra.Command {
 		Long: `Check the bundle for problems and adherence to best practices by running linters for porter and the mixins used in the bundle.
 
 The lint command is run automatically when you build a bundle. The command is available separately so that you can just lint your bundle without also building it.`,
+		Example: `  porter lint
+  porter lint --file path/to/porter.yaml
+  porter lint --output plaintext
+`,
 		PreRunE: func(cmd *cobra.Command, args []string) error {
 			return opts.Validate(p.Context)
 		},
@@ -297,6 +314,7 @@ func buildBundlePublishCommand(p *porter.Porter) *cobra.Command {
 Note: if overrides for registry/tag/reference are provided, this command only re-tags the invocation image and bundle; it does not re-build the bundle.`,
 		Example: `  porter bundle publish
   porter bundle publish --file myapp/porter.yaml
+  porter bundle publish --dir myapp
   porter bundle publish --archive /tmp/mybuns.tgz --reference myrepo/my-buns:0.1.0
   porter bundle publish --tag latest
   porter bundle publish --registry myregistry.com/myorg
@@ -311,6 +329,8 @@ Note: if overrides for registry/tag/reference are provided, this command only re
 
 	f := cmd.Flags()
 	f.StringVarP(&opts.File, "file", "f", "", "Path to the Porter manifest. Defaults to `porter.yaml` in the current directory.")
+	f.StringVarP(&opts.Dir, "dir", "d", "",
+		"Path to the build context directory where all bundle assets are located.")
 	f.StringVarP(&opts.ArchiveFile, "archive", "a", "", "Path to the bundle archive in .tgz format")
 	f.StringVar(&opts.Tag, "tag", "", "Override the Docker tag portion of the bundle reference, e.g. latest, v0.1.1")
 	f.StringVar(&opts.Registry, "registry", "", "Override the registry portion of the bundle reference, e.g. docker.io, myregistry.com/myorg")
diff --git a/docs/content/cli/build.md b/docs/content/cli/build.md
index 4b5a646a..3ab7082b 100644
--- a/docs/content/cli/build.md
+++ b/docs/content/cli/build.md
@@ -15,14 +15,27 @@ Builds the bundle in the current directory by generating a Dockerfile and a CNAB
 porter build [flags]
 ```
 
+### Examples
+
+```
+  porter build
+  porter build --name newbuns
+  porter build --version 0.1.0
+  porter build --file path/to/porter.yaml
+  porter build --dir path/to/build/context
+
+```
+
 ### Options
 
 ```
-  -h, --help             help for build
-      --name string      Override the bundle name
-      --no-lint          Do not run the linter
-  -v, --verbose          Enable verbose logging
-      --version string   Override the bundle version
+  -d, --dir string         Path to the build context directory where all bundle assets are located.
+  -f, --file porter.yaml   Path to the Porter manifest. Defaults to porter.yaml in the current directory.
+  -h, --help               help for build
+      --name string        Override the bundle name
+      --no-lint            Do not run the linter
+  -v, --verbose            Enable verbose logging
+      --version string     Override the bundle version
 ```
 
 ### Options inherited from parent commands
diff --git a/docs/content/cli/bundles_build.md b/docs/content/cli/bundles_build.md
index c6cc6977..ac28a89e 100644
--- a/docs/content/cli/bundles_build.md
+++ b/docs/content/cli/bundles_build.md
@@ -15,14 +15,27 @@ Builds the bundle in the current directory by generating a Dockerfile and a CNAB
 porter bundles build [flags]
 ```
 
+### Examples
+
+```
+  porter build
+  porter build --name newbuns
+  porter build --version 0.1.0
+  porter build --file path/to/porter.yaml
+  porter build --dir path/to/build/context
+
+```
+
 ### Options
 
 ```
-  -h, --help             help for build
-      --name string      Override the bundle name
-      --no-lint          Do not run the linter
-  -v, --verbose          Enable verbose logging
-      --version string   Override the bundle version
+  -d, --dir string         Path to the build context directory where all bundle assets are located.
+  -f, --file porter.yaml   Path to the Porter manifest. Defaults to porter.yaml in the current directory.
+  -h, --help               help for build
+      --name string        Override the bundle name
+      --no-lint            Do not run the linter
+  -v, --verbose            Enable verbose logging
+      --version string     Override the bundle version
 ```
 
 ### Options inherited from parent commands
diff --git a/docs/content/cli/bundles_lint.md b/docs/content/cli/bundles_lint.md
index 176b34fd..534886cf 100644
--- a/docs/content/cli/bundles_lint.md
+++ b/docs/content/cli/bundles_lint.md
@@ -17,6 +17,15 @@ The lint command is run automatically when you build a bundle. The command is av
 porter bundles lint [flags]
 ```
 
+### Examples
+
+```
+  porter lint
+  porter lint --file path/to/porter.yaml
+  porter lint --output plaintext
+
+```
+
 ### Options
 
 ```
diff --git a/docs/content/cli/lint.md b/docs/content/cli/lint.md
index bd110b3e..5d459a62 100644
--- a/docs/content/cli/lint.md
+++ b/docs/content/cli/lint.md
@@ -17,6 +17,15 @@ The lint command is run automatically when you build a bundle. The command is av
 porter lint [flags]
 ```
 
+### Examples
+
+```
+  porter lint
+  porter lint --file path/to/porter.yaml
+  porter lint --output plaintext
+
+```
+
 ### Options
 
 ```
diff --git a/docs/content/cli/publish.md b/docs/content/cli/publish.md
index 1e06122a..c718b988 100644
--- a/docs/content/cli/publish.md
+++ b/docs/content/cli/publish.md
@@ -22,6 +22,7 @@ porter publish [flags]
 ```
   porter publish
   porter publish --file myapp/porter.yaml
+  porter publish --dir myapp
   porter publish --archive /tmp/mybuns.tgz --reference myrepo/my-buns:0.1.0
   porter publish --tag latest
   porter publish --registry myregistry.com/myorg
@@ -32,6 +33,7 @@ porter publish [flags]
 
 ```
   -a, --archive string      Path to the bundle archive in .tgz format
+  -d, --dir string          Path to the build context directory where all bundle assets are located.
   -f, --file porter.yaml    Path to the Porter manifest. Defaults to porter.yaml in the current directory.
   -h, --help                help for publish
       --insecure-registry   Don't require TLS for the registry
diff --git a/pkg/build/dockerfile-generater.go b/pkg/build/dockerfile-generator.go
similarity index 86%
rename from pkg/build/dockerfile-generater.go
rename to pkg/build/dockerfile-generator.go
index 4db435d4..555f30e8 100644
--- a/pkg/build/dockerfile-generater.go
+++ b/pkg/build/dockerfile-generator.go
@@ -23,7 +23,12 @@ type DockerfileGenerator struct {
 	Mixins pkgmgmt.PackageManager
 }
 
-func NewDockerfileGenerator(config *config.Config, m *manifest.Manifest, tmpl *templates.Templates, mp pkgmgmt.PackageManager) *DockerfileGenerator {
+func NewDockerfileGenerator(
+	config *config.Config,
+	m *manifest.Manifest,
+	tmpl *templates.Templates,
+	mp pkgmgmt.PackageManager,
+) *DockerfileGenerator {
 	return &DockerfileGenerator{
 		Config:    config,
 		Manifest:  m,
@@ -45,7 +50,7 @@ func (g *DockerfileGenerator) GenerateDockerFile() error {
 		fmt.Fprintln(g.Out, contents)
 	}
 
-	err = g.FileSystem.WriteFile("Dockerfile", []byte(contents), 0644)
+	err = g.FileSystem.WriteFile(DOCKER_FILE, []byte(contents), 0644)
 	return errors.Wrap(err, "couldn't write the Dockerfile")
 }
 
@@ -143,11 +148,20 @@ func (g *DockerfileGenerator) getBaseDockerfile() ([]string, error) {
 }
 
 func (g *DockerfileGenerator) buildPorterSection() []string {
-	return []string{
-		// Remove the user-provided Porter manifest as the canonical version
-		// will migrate via its location in .cnab
-		`RUN rm $BUNDLE_DIR/porter.yaml`,
+	// The user-provided manifest may be located separate from the build context directory.
+	// Therefore, we only need to add lines if the relative manifest path exists inside of
+	// the current working directory.
+	manifestPath := g.FileSystem.Abs(g.Manifest.ManifestPath)
+	if relManifestPath, err := filepath.Rel(g.Getwd(), manifestPath); err == nil {
+		if !strings.Contains(relManifestPath, "..") {
+			return []string{
+				// Remove the user-provided Porter manifest as the canonical version
+				// will migrate via its location in .cnab
+				fmt.Sprintf(`RUN rm $BUNDLE_DIR/%s`, relManifestPath),
+			}
+		}
 	}
+	return []string{}
 }
 
 func (g *DockerfileGenerator) buildCNABSection() []string {
@@ -185,7 +199,7 @@ func (g *DockerfileGenerator) buildMixinsSection() ([]string, error) {
 
 func (g *DockerfileGenerator) PrepareFilesystem() error {
 	// clean up previously generated files
-	g.FileSystem.Remove("Dockerfile")
+	g.FileSystem.Remove(DOCKER_FILE)
 
 	fmt.Fprintf(g.Out, "Copying porter runtime ===> \n")
 
diff --git a/pkg/cnab/config-adapter/adapter.go b/pkg/cnab/config-adapter/adapter.go
index 90f9f6d3..edb327dd 100644
--- a/pkg/cnab/config-adapter/adapter.go
+++ b/pkg/cnab/config-adapter/adapter.go
@@ -24,7 +24,12 @@ type ManifestConverter struct {
 	Mixins       []mixin.Metadata
 }
 
-func NewManifestConverter(cxt *context.Context, manifest *manifest.Manifest, imageDigests map[string]string, mixins []mixin.Metadata) *ManifestConverter {
+func NewManifestConverter(
+	cxt *context.Context,
+	manifest *manifest.Manifest,
+	imageDigests map[string]string,
+	mixins []mixin.Metadata,
+) *ManifestConverter {
 	return &ManifestConverter{
 		Context:      cxt,
 		Manifest:     manifest,
diff --git a/pkg/cnab/config-adapter/stamp.go b/pkg/cnab/config-adapter/stamp.go
index b34c8ca0..df96afc5 100644
--- a/pkg/cnab/config-adapter/stamp.go
+++ b/pkg/cnab/config-adapter/stamp.go
@@ -68,7 +68,7 @@ func (c *ManifestConverter) GenerateStamp() (Stamp, error) {
 	stamp := Stamp{}
 
 	// Remember the original porter.yaml, base64 encoded to avoid canonical json shenanigans
-	rawManifest, err := manifest.ReadManifestData(c.Context, config.Name)
+	rawManifest, err := manifest.ReadManifestData(c.Context, c.Manifest.ManifestPath)
 	if err != nil {
 		return Stamp{}, err
 	}
@@ -97,13 +97,13 @@ func (c *ManifestConverter) GenerateStamp() (Stamp, error) {
 }
 
 func (c *ManifestConverter) DigestManifest() (string, error) {
-	if exists, _ := c.FileSystem.Exists(config.Name); !exists {
-		return "", errors.Errorf("the specified porter configuration file %s does not exist", config.Name)
+	if exists, _ := c.FileSystem.Exists(c.Manifest.ManifestPath); !exists {
+		return "", errors.Errorf("the specified porter configuration file %s does not exist", c.Manifest.ManifestPath)
 	}
 
-	data, err := c.FileSystem.ReadFile(config.Name)
+	data, err := c.FileSystem.ReadFile(c.Manifest.ManifestPath)
 	if err != nil {
-		return "", errors.Wrapf(err, "could not read manifest at %q", config.Name)
+		return "", errors.Wrapf(err, "could not read manifest at %q", c.Manifest.ManifestPath)
 	}
 
 	v := pkg.Version
diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go
index 3d469be8..b96787f4 100644
--- a/pkg/manifest/manifest.go
+++ b/pkg/manifest/manifest.go
@@ -24,7 +24,7 @@ import (
 const invalidStepErrorFormat = "validation of action \"%s\" failed"
 
 type Manifest struct {
-	// ManifestPath is the location from which the manifest was loaded, such as the path on the filesystem or a url.
+	// ManifestPath is location to the original, user-supplied manifest, such as the path on the filesystem or a url
 	ManifestPath string `yaml:"-"`
 
 	// TemplateVariables are the variables used in the templating, e.g. bundle.parameters.NAME, or bundle.outputs.NAME
diff --git a/pkg/porter/build.go b/pkg/porter/build.go
index cdee0e38..34ce26ee 100644
--- a/pkg/porter/build.go
+++ b/pkg/porter/build.go
@@ -7,6 +7,7 @@ import (
 
 	"get.porter.sh/porter/pkg/build"
 	configadapter "get.porter.sh/porter/pkg/cnab/config-adapter"
+	"get.porter.sh/porter/pkg/context"
 	"get.porter.sh/porter/pkg/manifest"
 	"get.porter.sh/porter/pkg/mixin"
 	"get.porter.sh/porter/pkg/printer"
@@ -15,7 +16,7 @@ import (
 )
 
 type BuildProvider interface {
-	// BuildInvocationImage using the bundle in the current directory
+	// BuildInvocationImage using the bundle in the build context directory
 	BuildInvocationImage(manifest *manifest.Manifest) error
 
 	// TagInvocationImage using the origTag and newTag values supplied
@@ -23,6 +24,7 @@ type BuildProvider interface {
 }
 
 type BuildOptions struct {
+	bundleFileOptions
 	contextOptions
 	metadataOpts
 	NoLint bool
@@ -35,7 +37,7 @@ const semVerRegex string = `([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` +
 	`(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` +
 	`(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?`
 
-func (o BuildOptions) Validate() error {
+func (o *BuildOptions) Validate(cxt *context.Context) error {
 	if o.Version != "" {
 		versionRegex := regexp.MustCompile("^" + semVerRegex + "$")
 		if m := versionRegex.FindStringSubmatch(o.Version); m == nil {
@@ -43,17 +45,14 @@ func (o BuildOptions) Validate() error {
 		}
 	}
 
-	return nil
+	return o.bundleFileOptions.Validate(cxt)
 }
 
 func (p *Porter) Build(opts BuildOptions) error {
 	opts.Apply(p.Context)
 
-	if err := opts.Validate(); err != nil {
-		return err
-	}
-
-	if err := p.generateInternalManifest(opts.metadataOpts); err != nil {
+	// Generate Porter's canonical version of the user-provided manifest
+	if err := p.generateInternalManifest(opts); err != nil {
 		return errors.Wrap(err, "unable to generate manifest")
 	}
 
@@ -61,6 +60,11 @@ func (p *Porter) Build(opts BuildOptions) error {
 		return err
 	}
 
+	// Capture the path to the original, user-provided manifest.
+	// This value will be referenced elsewhere, for insteance by
+	// the digest logic (to dictate auto-rebuild)
+	p.Manifest.ManifestPath = opts.File
+
 	if !opts.NoLint {
 		if err := p.preLint(); err != nil {
 			return err
diff --git a/pkg/porter/cnab.go b/pkg/porter/cnab.go
index 18458c4b..3995f513 100644
--- a/pkg/porter/cnab.go
+++ b/pkg/porter/cnab.go
@@ -35,22 +35,45 @@ type bundleFileOptions struct {
 
 	// ReferenceSet indicates whether a bundle reference is present, to determine whether or not to default bundle files
 	ReferenceSet bool
+
+	// Dir represents the build context directory containing bundle assets
+	Dir string
 }
 
 func (o *bundleFileOptions) Validate(cxt *context.Context) error {
-	err := o.validateBundleFiles(cxt)
+	var err error
+
+	err = o.validateBundleFiles(cxt)
 	if err != nil {
 		return err
 	}
 
-	if !o.ReferenceSet {
-		err = o.defaultBundleFiles(cxt)
+	if o.ReferenceSet {
+		return nil
+	}
+
+	if o.File != "" {
+		o.File = cxt.FileSystem.Abs(o.File)
+	}
+
+	// Resolve the proper build context directory
+	if o.Dir != "" {
+		_, err = cxt.FileSystem.IsDir(o.Dir)
 		if err != nil {
-			return err
+			return errors.Wrapf(err, "%q is not a valid directory", o.Dir)
 		}
+		o.Dir = cxt.FileSystem.Abs(o.Dir)
 	}
 
-	return err
+	err = o.defaultBundleFiles(cxt)
+	if err != nil {
+		return err
+	}
+
+	// Enter the resolved build context directory after all defaults
+	// have been populated
+	cxt.Chdir(o.Dir)
+	return nil
 }
 
 // sharedOptions are common options that apply to multiple CNAB actions.
@@ -131,11 +154,10 @@ func (o *sharedOptions) validateInstallationName(args []string) error {
 // defaultBundleFiles defaults the porter manifest and the bundle.json files.
 func (o *bundleFileOptions) defaultBundleFiles(cxt *context.Context) error {
 	if o.File != "" { // --file
-		bundleDir := filepath.Dir(o.File)
-		o.CNABFile = filepath.Join(bundleDir, build.LOCAL_BUNDLE)
+		o.defaultCNABFile()
 	} else if o.CNABFile != "" { // --cnab-file
 		// Nothing to default
-	} else { // no flags passed (--reference is handled elsewhere)
+	} else {
 		manifestExists, err := cxt.FileSystem.Exists(config.Name)
 		if err != nil {
 			return errors.Wrap(err, "could not check if porter manifest exists in current directory")
@@ -143,13 +165,22 @@ func (o *bundleFileOptions) defaultBundleFiles(cxt *context.Context) error {
 
 		if manifestExists {
 			o.File = config.Name
-			o.CNABFile = build.LOCAL_BUNDLE
+			o.defaultCNABFile()
 		}
 	}
 
 	return nil
 }
 
+func (o *bundleFileOptions) defaultCNABFile() {
+	// Place the bundle.json in o.Dir if set; otherwise place in current directory
+	if o.Dir != "" {
+		o.CNABFile = filepath.Join(o.Dir, build.LOCAL_BUNDLE)
+	} else {
+		o.CNABFile = build.LOCAL_BUNDLE
+	}
+}
+
 func (o *bundleFileOptions) validateBundleFiles(cxt *context.Context) error {
 	if o.File != "" && o.CNABFile != "" {
 		return errors.New("cannot specify both --file and --cnab-file")
diff --git a/pkg/porter/generateManifest.go b/pkg/porter/generateManifest.go
index 67f28070..7e444f17 100644
--- a/pkg/porter/generateManifest.go
+++ b/pkg/porter/generateManifest.go
@@ -2,7 +2,6 @@ package porter
 
 import (
 	"get.porter.sh/porter/pkg/build"
-	"get.porter.sh/porter/pkg/config"
 	"get.porter.sh/porter/pkg/manifest"
 	"github.com/pkg/errors"
 )
@@ -17,7 +16,7 @@ type metadataOpts struct {
 // generateInternalManifest decodes the manifest designated by filepath and applies
 // the provided generateInternalManifestOpts, saving the updated manifest to the path
 // designated by build.LOCAL_MANIFEST
-func (p *Porter) generateInternalManifest(opts metadataOpts) error {
+func (p *Porter) generateInternalManifest(opts BuildOptions) error {
 	// Create the local app dir if it does not already exist
 	err := p.FileSystem.MkdirAll(build.LOCAL_APP, 0755)
 	if err != nil {
@@ -25,7 +24,7 @@ func (p *Porter) generateInternalManifest(opts metadataOpts) error {
 	}
 
 	e := manifest.NewEditor(p.Context)
-	err = e.ReadFile(config.Name)
+	err = e.ReadFile(opts.File)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/porter/publish.go b/pkg/porter/publish.go
index 212fc668..beaf7039 100644
--- a/pkg/porter/publish.go
+++ b/pkg/porter/publish.go
@@ -42,7 +42,7 @@ func (o *PublishOptions) Validate(cxt *portercontext.Context) error {
 			return errors.New("must provide a value for --reference of the form REGISTRY/bundle:tag")
 		}
 	} else {
-		// Proceed with publishing from current directory
+		// Proceed with publishing from the resolved build context directory
 		err := o.bundleFileOptions.Validate(cxt)
 		if err != nil {
 			return err
@@ -100,15 +100,19 @@ func (p *Porter) publishFromFile(opts PublishOptions) error {
 	// hot-swap in Porter's canonical translation (if exists) from
 	// the .cnab/app directory, as there may be dynamic overrides for
 	// the name and version fields to inform invocation image naming.
-	canonicalExists, err := p.FileSystem.Exists(build.LOCAL_MANIFEST)
+	canonicalManifest := filepath.Join(opts.Dir, build.LOCAL_MANIFEST)
+	canonicalExists, err := p.FileSystem.Exists(canonicalManifest)
 	if err != nil {
 		return err
 	}
 	if canonicalExists {
-		err := p.LoadManifestFrom(build.LOCAL_MANIFEST)
+		err := p.LoadManifestFrom(canonicalManifest)
 		if err != nil {
 			return err
 		}
+		// We still want the user-provided manifest path to be tracked,
+		// not Porter's canonical manifest path, for digest matching/auto-rebuilds
+		p.Manifest.ManifestPath = opts.File
 	}
 
 	// Capture original invocation image name as it may be updated below
@@ -150,7 +154,7 @@ func (p *Porter) publishFromFile(opts PublishOptions) error {
 		return errors.Wrapf(err, "unable to push CNAB invocation image %q", p.Manifest.Image)
 	}
 
-	bun, err := p.rewriteBundleWithInvocationImageDigest(digest)
+	bun, err := p.rewriteBundleWithInvocationImageDigest(digest, opts.File)
 	if err != nil {
 		return err
 	}
@@ -344,7 +348,7 @@ func getNewImageNameFromBundleReference(origImg, bundleTag string) (image.Name,
 	return newImgName, nil
 }
 
-func (p *Porter) rewriteBundleWithInvocationImageDigest(digest string) (bundle.Bundle, error) {
+func (p *Porter) rewriteBundleWithInvocationImageDigest(digest string, manifestPath string) (bundle.Bundle, error) {
 	taggedImage, err := p.rewriteImageWithDigest(p.Manifest.Image, digest)
 	if err != nil {
 		return bundle.Bundle{}, errors.Wrap(err, "unable to update invocation image reference")
diff --git a/pkg/porter/stamp.go b/pkg/porter/stamp.go
index 3063b611..7834dba5 100644
--- a/pkg/porter/stamp.go
+++ b/pkg/porter/stamp.go
@@ -22,7 +22,10 @@ func (p *Porter) ensureLocalBundleIsUpToDate(opts bundleFileOptions) error {
 
 	if !upToDate {
 		fmt.Fprintln(p.Out, "Building bundle ===>")
-		return p.Build(BuildOptions{})
+		// opts.File is non-empty, which overrides opts.CNABFile if set
+		// (which may be if a cached bundle is fetched e.g. when running an action)
+		opts.CNABFile = ""
+		return p.Build(BuildOptions{bundleFileOptions: opts})
 	}
 	return nil
 }


Test patch:
diff --git a/pkg/build/dockerfile-generator_test.go b/pkg/build/dockerfile-generator_test.go
index 0fb2eb55..44939f20 100644
--- a/pkg/build/dockerfile-generator_test.go
+++ b/pkg/build/dockerfile-generator_test.go
@@ -8,6 +8,8 @@ import (
 	"get.porter.sh/porter/pkg/manifest"
 	"get.porter.sh/porter/pkg/mixin"
 	"get.porter.sh/porter/pkg/templates"
+	"github.com/carolynvs/aferox"
+	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -50,6 +52,91 @@ func TestPorter_buildDockerfile(t *testing.T) {
 	assert.Equal(t, wantlines, gotlines)
 }
 
+func TestPorter_buildDockerfile_alternateManifestLocation(t *testing.T) {
+	t.Parallel()
+
+	c := config.NewTestConfig(t)
+	tmpl := templates.NewTemplates()
+	configTpl, err := tmpl.GetManifest()
+	require.Nil(t, err)
+
+	manifestPath := "alternate/porter.yaml"
+	c.TestContext.AddTestFileContents(configTpl, manifestPath)
+
+	m, err := manifest.LoadManifestFrom(c.Context, manifestPath)
+	require.NoError(t, err, "could not load manifest")
+
+	// ignore mixins in the unit tests
+	m.Mixins = []manifest.MixinDeclaration{}
+
+	mp := mixin.NewTestMixinProvider()
+	g := NewDockerfileGenerator(c.Config, m, tmpl, mp)
+	gotlines, err := g.buildDockerfile()
+	require.NoError(t, err)
+
+	wantlines := []string{
+		"FROM debian:stretch-slim",
+		"",
+		"ARG BUNDLE_DIR",
+		"",
+		"RUN apt-get update && apt-get install -y ca-certificates",
+		"",
+		"",
+		"COPY . $BUNDLE_DIR",
+		"RUN rm $BUNDLE_DIR/alternate/porter.yaml",
+		"RUN rm -fr $BUNDLE_DIR/.cnab",
+		"COPY .cnab /cnab",
+		"WORKDIR $BUNDLE_DIR",
+		"CMD [\"/cnab/app/run\"]",
+	}
+	assert.Equal(t, wantlines, gotlines)
+}
+
+func TestPorter_buildDockerfile_separateManifestLocation(t *testing.T) {
+	t.Parallel()
+
+	c := config.NewTestConfig(t)
+	tmpl := templates.NewTemplates()
+	configTpl, err := tmpl.GetManifest()
+	require.Nil(t, err)
+
+	// The standard test context uses an in-memory Filesystem with root dir of "/".
+	// For this test, we update the context to be a subdirectory
+	// so that the manifest path is suitably separate.
+	c.FileSystem = aferox.NewAferox("/porter", afero.NewMemMapFs())
+
+	manifestPath := "/tmp/separate/porter.yaml"
+	c.TestContext.AddTestFileContents(configTpl, manifestPath)
+
+	m, err := manifest.LoadManifestFrom(c.Context, manifestPath)
+	require.NoError(t, err, "could not load manifest")
+
+	// ignore mixins in the unit tests
+	m.Mixins = []manifest.MixinDeclaration{}
+
+	mp := mixin.NewTestMixinProvider()
+	g := NewDockerfileGenerator(c.Config, m, tmpl, mp)
+	gotlines, err := g.buildDockerfile()
+	require.NoError(t, err)
+
+	// Verify that the line to remove the manifest from BUNDLE_DIR is *not* added
+	wantlines := []string{
+		"FROM debian:stretch-slim",
+		"",
+		"ARG BUNDLE_DIR",
+		"",
+		"RUN apt-get update && apt-get install -y ca-certificates",
+		"",
+		"",
+		"COPY . $BUNDLE_DIR",
+		"RUN rm -fr $BUNDLE_DIR/.cnab",
+		"COPY .cnab /cnab",
+		"WORKDIR $BUNDLE_DIR",
+		"CMD [\"/cnab/app/run\"]",
+	}
+	assert.Equal(t, wantlines, gotlines)
+}
+
 func TestPorter_buildCustomDockerfile(t *testing.T) {
 	t.Parallel()
 
diff --git a/pkg/porter/build_test.go b/pkg/porter/build_test.go
index a864899c..735c4d39 100644
--- a/pkg/porter/build_test.go
+++ b/pkg/porter/build_test.go
@@ -77,6 +77,9 @@ func TestPorter_LintDuringBuild(t *testing.T) {
 		require.NoError(t, err, "Create failed")
 
 		opts := BuildOptions{NoLint: false}
+		err = opts.Validate(p.Context)
+		require.NoError(t, err)
+
 		err = p.Build(opts)
 		require.Errorf(t, err, "Build should have been aborted with lint errors")
 		assert.Contains(t, err.Error(), "Lint errors were detected")
@@ -91,6 +94,9 @@ func TestPorter_LintDuringBuild(t *testing.T) {
 		require.NoError(t, err, "Create failed")
 
 		opts := BuildOptions{NoLint: true}
+		err = opts.Validate(p.Context)
+		require.NoError(t, err)
+
 		err = p.Build(opts)
 		require.NoError(t, err, "Build failed but should have not run lint")
 	})
@@ -119,6 +125,8 @@ func TestPorter_paramRequired(t *testing.T) {
 }
 
 func TestValidateBuildOpts(t *testing.T) {
+	p := NewTestPorter(t)
+
 	testcases := []struct {
 		name      string
 		opts      BuildOptions
@@ -143,7 +151,7 @@ func TestValidateBuildOpts(t *testing.T) {
 
 	for _, tc := range testcases {
 		t.Run(tc.name, func(t *testing.T) {
-			err := tc.opts.Validate()
+			err := tc.opts.Validate(p.Context)
 			if tc.wantError != "" {
 				require.EqualError(t, err, tc.wantError)
 			} else {
diff --git a/pkg/porter/cnab_test.go b/pkg/porter/cnab_test.go
index 9e3d8d11..fcf0d28e 100644
--- a/pkg/porter/cnab_test.go
+++ b/pkg/porter/cnab_test.go
@@ -1,8 +1,11 @@
 package porter
 
 import (
+	"os"
 	"testing"
 
+	"get.porter.sh/porter/pkg/build"
+	"get.porter.sh/porter/pkg/config"
 	"get.porter.sh/porter/pkg/context"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -33,7 +36,7 @@ func TestSharedOptions_defaultBundleFiles_AltManifest(t *testing.T) {
 	err := opts.defaultBundleFiles(cxt.Context)
 	require.NoError(t, err)
 
-	assert.Equal(t, "mybun/.cnab/bundle.json", opts.CNABFile)
+	assert.Equal(t, ".cnab/bundle.json", opts.CNABFile)
 }
 
 func TestSharedOptions_defaultBundleFiles_CNABFile(t *testing.T) {
@@ -225,3 +228,118 @@ func TestSharedOptions_CombineParameters(t *testing.T) {
 		require.Equal(t, "true", params["porter-debug"], "porter-debug should be set to true when p.Debug is true")
 	})
 }
+
+func Test_bundleFileOptions(t *testing.T) {
+	testcases := []struct {
+		name         string
+		opts         bundleFileOptions
+		setup        func(*context.Context, bundleFileOptions) error
+		wantFile     string
+		wantCNABFile string
+		wantError    string
+	}{
+		{
+			name:         "no opts",
+			opts:         bundleFileOptions{},
+			setup:        func(ctx *context.Context, opts bundleFileOptions) error { return nil },
+			wantFile:     config.Name,
+			wantCNABFile: build.LOCAL_BUNDLE,
+			wantError:    "",
+		}, {
+			name: "reference set",
+			opts: bundleFileOptions{
+				ReferenceSet: true,
+			},
+			setup:        func(ctx *context.Context, opts bundleFileOptions) error { return nil },
+			wantFile:     "",
+			wantCNABFile: "",
+			wantError:    "",
+		}, {
+			name: "invalid dir",
+			opts: bundleFileOptions{
+				Dir: "path/to/bundle",
+			},
+			setup:        func(ctx *context.Context, opts bundleFileOptions) error { return nil },
+			wantFile:     "",
+			wantCNABFile: "",
+			wantError:    `"path/to/bundle" is not a valid directory: open /path/to/bundle: file does not exist`,
+		}, {
+			name: "invalid file",
+			opts: bundleFileOptions{
+				File: "alternate/porter.yaml",
+			},
+			setup:        func(ctx *context.Context, opts bundleFileOptions) error { return nil },
+			wantFile:     "",
+			wantCNABFile: "",
+			wantError:    "unable to access --file alternate/porter.yaml: open /alternate/porter.yaml: file does not exist",
+		}, {
+			name: "valid dir",
+			opts: bundleFileOptions{
+				Dir: "path/to/bundle",
+			},
+			setup: func(ctx *context.Context, opts bundleFileOptions) error {
+				return ctx.FileSystem.MkdirAll(opts.Dir, os.ModePerm)
+			},
+			wantFile:     config.Name,
+			wantCNABFile: "/path/to/bundle/.cnab/bundle.json",
+			wantError:    "",
+		}, {
+			name: "valid file",
+			opts: bundleFileOptions{
+				File: "alternate/porter.yaml",
+			},
+			setup: func(ctx *context.Context, opts bundleFileOptions) error {
+				return ctx.FileSystem.MkdirAll(opts.File, os.ModePerm)
+			},
+			wantFile:     "/alternate/porter.yaml",
+			wantCNABFile: build.LOCAL_BUNDLE,
+			wantError:    "",
+		}, {
+			name: "valid dir and file",
+			opts: bundleFileOptions{
+				Dir:  "path/to/bundle",
+				File: "alternate/porter.yaml",
+			},
+			setup: func(ctx *context.Context, opts bundleFileOptions) error {
+				err := ctx.FileSystem.MkdirAll(opts.File, os.ModePerm)
+				if err != nil {
+					return err
+				}
+				return ctx.FileSystem.MkdirAll(opts.Dir, os.ModePerm)
+			},
+			wantFile:     "/alternate/porter.yaml",
+			wantCNABFile: "/path/to/bundle/.cnab/bundle.json",
+			wantError:    "",
+		}}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			cxt := context.NewTestContext(t)
+
+			// Create default local manifest
+			_, err := cxt.FileSystem.Create(config.Name)
+			require.NoError(t, err)
+
+			err = tc.setup(cxt.Context, tc.opts)
+			require.NoError(t, err)
+
+			err = tc.opts.Validate(cxt.Context)
+			if tc.wantError != "" {
+				require.EqualError(t, err, tc.wantError)
+			} else {
+				require.NoError(t, err)
+
+				require.Equal(t, tc.wantFile, tc.opts.File)
+				require.Equal(t, tc.wantCNABFile, tc.opts.CNABFile)
+
+				// Working Dir assertions
+				wd := cxt.FileSystem.Getwd()
+				if tc.opts.Dir != "" && tc.wantError == "" {
+					require.Equal(t, tc.opts.Dir, wd)
+				} else {
+					require.Equal(t, "/", wd)
+				}
+			}
+		})
+	}
+}
diff --git a/pkg/porter/generateManifest_test.go b/pkg/porter/generateManifest_test.go
index b5ff8e62..7b7c66c7 100644
--- a/pkg/porter/generateManifest_test.go
+++ b/pkg/porter/generateManifest_test.go
@@ -9,35 +9,51 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestgenerateInternalManifest(t *testing.T) {
+func Test_generateInternalManifest(t *testing.T) {
 	testcases := []struct {
 		name         string
-		opts         metadataOpts
+		opts         BuildOptions
 		wantManifest string
 	}{{
 		name:         "no opts",
-		opts:         metadataOpts{},
+		opts:         BuildOptions{},
+		wantManifest: "original.yaml",
+	}, {
+		name: "--file set",
+		opts: BuildOptions{
+			bundleFileOptions: bundleFileOptions{
+				File: "alternate.yaml",
+			},
+		},
 		wantManifest: "original.yaml",
 	}, {
 		name:         "name set",
-		opts:         metadataOpts{Name: "newname"},
+		opts:         BuildOptions{metadataOpts: metadataOpts{Name: "newname"}},
 		wantManifest: "new-name.yaml",
 	}, {
 		name:         "version set",
-		opts:         metadataOpts{Version: "1.0.0"},
+		opts:         BuildOptions{metadataOpts: metadataOpts{Version: "1.0.0"}},
 		wantManifest: "new-version.yaml",
 	}, {
 		name:         "name and value set",
-		opts:         metadataOpts{Name: "newname", Version: "1.0.0"},
+		opts:         BuildOptions{metadataOpts: metadataOpts{Name: "newname", Version: "1.0.0"}},
 		wantManifest: "all-fields.yaml",
 	}}
 
 	p := NewTestPorter(t)
-	p.TestConfig.TestContext.AddTestFile("testdata/generateManifest/original.yaml", config.Name)
 
 	for _, tc := range testcases {
 		t.Run(tc.name, func(t *testing.T) {
-			err := p.generateInternalManifest(tc.opts)
+			manifest := config.Name
+			if tc.opts.File != "" {
+				manifest = tc.opts.File
+			}
+			p.TestConfig.TestContext.AddTestFile("testdata/generateManifest/original.yaml", manifest)
+
+			err := tc.opts.Validate(p.Context)
+			require.NoError(t, err)
+
+			err = p.generateInternalManifest(tc.opts)
 			require.NoError(t, err)
 
 			want := p.TestConfig.TestContext.AddTestFile(
diff --git a/tests/publish_test.go b/tests/publish_test.go
index 380c2bea..0cd640f6 100644
--- a/tests/publish_test.go
+++ b/tests/publish_test.go
@@ -25,6 +25,10 @@ func TestPublish_BuildWithVersionOverride(t *testing.T) {
 	// Build with version override
 	buildOpts := porter.BuildOptions{}
 	buildOpts.Version = "0.0.0"
+
+	err = buildOpts.Validate(p.Context)
+	require.NoError(t, err)
+
 	err = p.Build(buildOpts)
 	require.NoError(t, err)