Repository: google/ko
Pull request #484 (2021-10-20 19:38:57)

Document setup-ko in README.md


Patch:
diff --git a/README.md b/README.md
index 2a6b87d4..75d9df14 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,17 @@ With Go 1.16+, build and install the latest released version:
 go install github.com/google/ko@latest
 ```
 
+### Setup on GitHub Actions
+
+You can use the [`setup-ko`](https://github.com/imjasonh/setup-ko) action
+to install `ko` and setup auth to [GitHub Container
+Registry](https://github.com/features/packages) in a GitHub Action workflow:
+
+```
+steps:
+- uses: imjasonh/setup-ko@v0.4
+```
+
 ## Authenticate
 
 `ko` depends on the authentication configured in your Docker config (typically
@@ -127,7 +138,7 @@ baseImageOverrides:
 ### Overriding Go build settings
 
 By default, `ko` builds the binary with no additional build flags other than
-`--trimpath` (depending on the Go version). You can replace the default build
+`-trimpath`. You can replace the default build
 arguments by providing build flags and ldflags using a
 [GoReleaser](https://github.com/goreleaser/goreleaser) influenced `builds`
 configuration section in your `.ko.yaml`.
@@ -135,6 +146,7 @@ configuration section in your `.ko.yaml`.
 ```yaml
 builds:
 - id: foo
+  dir: .  # default is .
   main: ./foobar/foo
   env:
   - GOPRIVATE=git.internal.example.com,source.developers.google.com
@@ -146,7 +158,8 @@ builds:
   - -extldflags "-static"
   - -X main.version={{.Env.VERSION}}
 - id: bar
-  main: ./foobar/bar/main.go
+  dir: ./bar
+  main: .  # default is .
   env:
   - GOCACHE=/workspace/.gocache
   ldflags:
@@ -154,15 +167,22 @@ builds:
   - -w
 ```
 
-For the build, `ko` will pick the entry based on the respective import path
-being used. It will be matched against the local path that is configured using
-`dir` and `main`. In the context of `ko`, it is fine just to specify `main`
-with the intended import path.
+If your repository contains multiple modules (multiple `go.mod` files in
+different directories), use the `dir` field to specify the directory where
+`ko` should run `go build`.
+
+`ko` picks the entry from `builds` based on the import path you request. The
+import path is matched against the result of joining `dir` and `main`.
+
+The paths specified in `dir` and `main` are relative to the working directory
+of the `ko` process.
+
+The `ldflags` default value is `[]`.
 
 _Please note:_ Even though the configuration section is similar to the
 [GoReleaser `builds` section](https://goreleaser.com/customization/build/),
 only the `env`, `flags` and `ldflags` fields are currently supported. Also, the
-templating support is currently limited to environment variables only.
+templating support is currently limited to using environment variables only.
 
 ## Naming Images
 
diff --git a/go.mod b/go.mod
index 889ef17c..a009f40d 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/containerd/containerd v1.5.7 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.9.0
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
-	github.com/docker/docker v20.10.9+incompatible
+	github.com/docker/docker v20.10.10+incompatible
 	github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960
 	github.com/evanphx/json-patch/v5 v5.5.0 // indirect
 	github.com/fsnotify/fsnotify v1.5.1
@@ -29,6 +29,6 @@ require (
 	google.golang.org/grpc v1.41.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
-	k8s.io/apimachinery v0.22.2
+	k8s.io/apimachinery v0.22.3
 	sigs.k8s.io/kind v0.11.1
 )
diff --git a/go.sum b/go.sum
index 473768ff..2e283a5f 100644
--- a/go.sum
+++ b/go.sum
@@ -266,8 +266,8 @@ github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible
 github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v20.10.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/docker v20.10.9+incompatible h1:JlsVnETOjM2RLQa0Cc1XCIspUdXW3Zenq9P54uXBm6k=
-github.com/docker/docker v20.10.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.10+incompatible h1:GKkP0T7U4ks6X3lmmHKC2QDprnpRJor2Z5a8m62R9ZM=
+github.com/docker/docker v20.10.10+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ=
 github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
@@ -1329,8 +1329,8 @@ k8s.io/apimachinery v0.20.1/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRp
 k8s.io/apimachinery v0.20.2/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.6/go.mod h1:ejZXtW1Ra6V1O5H8xPBGz+T3+4gfkTCeExAHKU57MAc=
-k8s.io/apimachinery v0.22.2 h1:ejz6y/zNma8clPVfNDLnPbleBo6MpoFy/HBiBqCouVk=
-k8s.io/apimachinery v0.22.2/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0=
+k8s.io/apimachinery v0.22.3 h1:mrvBG5CZnEfwgpVqWcrRKvdsYECTrhAR6cApAgdsflk=
+k8s.io/apimachinery v0.22.3/go.mod h1:O3oNtNadZdeOMxHFVxOreoznohCpy0z6mocxbZr7oJ0=
 k8s.io/apiserver v0.20.1/go.mod h1:ro5QHeQkgMS7ZGpvf4tSMx6bBOgPfE+f52KwvXfScaU=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/apiserver v0.20.6/go.mod h1:QIJXNt6i6JB+0YQRNcS0hdRHJlMhflFmsBDeSgT1r8Q=
diff --git a/main.go b/main.go
index 0505c366..43d70b66 100644
--- a/main.go
+++ b/main.go
@@ -17,8 +17,10 @@ limitations under the License.
 package main
 
 import (
+	"context"
 	"log"
 	"os"
+	"os/signal"
 
 	"github.com/google/go-containerregistry/pkg/logs"
 	"github.com/google/ko/pkg/commands"
@@ -28,7 +30,9 @@ func main() {
 	logs.Warn.SetOutput(os.Stderr)
 	logs.Progress.SetOutput(os.Stderr)
 
-	if err := commands.Root.Execute(); err != nil {
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
+	defer stop()
+	if err := commands.Root.ExecuteContext(ctx); err != nil {
 		log.Fatal("error during command execution:", err)
 	}
 }
diff --git a/pkg/build/config.go b/pkg/build/config.go
index 5e1f4cf7..ae5a9a0c 100644
--- a/pkg/build/config.go
+++ b/pkg/build/config.go
@@ -62,7 +62,7 @@ func (a *FlagArray) UnmarshalYAML(unmarshal func(interface{}) error) error {
 // the original GoReleaser name to match better with the ko naming.
 //
 // TODO: Introduce support for more fields where possible and where it makes
-///      sense for `ko`, for example ModTimestamp, Env, or GoBinary.
+///      sense for `ko`, for example ModTimestamp or GoBinary.
 //
 type Config struct {
 	// ID only serves as an identifier internally
diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go
index cff0a712..bdf4a515 100644
--- a/pkg/build/gobuild.go
+++ b/pkg/build/gobuild.go
@@ -20,7 +20,6 @@ import (
 	"archive/tar"
 	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	gb "go/build"
@@ -55,10 +54,6 @@ type GetBase func(context.Context, string) (name.Reference, Result, error)
 
 type builder func(context.Context, string, string, v1.Platform, Config) (string, error)
 
-type buildContext interface {
-	Import(path string, srcDir string, mode gb.ImportMode) (*gb.Package, error)
-}
-
 type platformMatcher struct {
 	spec      string
 	platforms []v1.Platform
@@ -71,8 +66,6 @@ type gobuild struct {
 	build                builder
 	disableOptimizations bool
 	buildConfigs         map[string]Config
-	mod                  *modules
-	buildContext         buildContext
 	platformMatcher      *platformMatcher
 	dir                  string
 	labels               map[string]string
@@ -88,8 +81,6 @@ type gobuildOpener struct {
 	build                builder
 	disableOptimizations bool
 	buildConfigs         map[string]Config
-	mod                  *modules
-	buildContext         buildContext
 	platform             string
 	labels               map[string]string
 	dir                  string
@@ -110,90 +101,12 @@ func (gbo *gobuildOpener) Open() (Interface, error) {
 		build:                gbo.build,
 		disableOptimizations: gbo.disableOptimizations,
 		buildConfigs:         gbo.buildConfigs,
-		mod:                  gbo.mod,
-		buildContext:         gbo.buildContext,
 		labels:               gbo.labels,
 		dir:                  gbo.dir,
 		platformMatcher:      matcher,
 	}, nil
 }
 
-// https://golang.org/pkg/cmd/go/internal/modinfo/#ModulePublic
-type modules struct {
-	main *modInfo
-	deps map[string]*modInfo
-}
-
-type modInfo struct {
-	Path string
-	Dir  string
-	Main bool
-}
-
-// moduleInfo returns the module path and module root directory for a project
-// using go modules, otherwise returns nil.
-//
-// Related: https://github.com/golang/go/issues/26504
-func moduleInfo(ctx context.Context, dir string) (*modules, error) {
-	modules := modules{
-		deps: make(map[string]*modInfo),
-	}
-
-	// TODO we read all the output as a single byte array - it may
-	// be possible & more efficient to stream it
-	cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-json", "-m", "all")
-	cmd.Dir = dir
-	output, err := cmd.Output()
-	if err != nil {
-		return nil, nil
-	}
-
-	dec := json.NewDecoder(bytes.NewReader(output))
-
-	for {
-		var info modInfo
-		err := dec.Decode(&info)
-		if err == io.EOF {
-			// all done
-			break
-		}
-
-		modules.deps[info.Path] = &info
-
-		if info.Main {
-			modules.main = &info
-		}
-
-		if err != nil {
-			return nil, fmt.Errorf("error reading module data %w", err)
-		}
-	}
-
-	if modules.main == nil {
-		return nil, fmt.Errorf("couldn't find main module")
-	}
-
-	return &modules, nil
-}
-
-// getGoroot shells out to `go env GOROOT` to determine
-// the GOROOT for the installed version of go so that we
-// can set it in our buildContext. By default, the GOROOT
-// of our buildContext is set to the GOROOT at install
-// time for `ko`, which means that we break when certain
-// package managers update go or when using a pre-built
-// `ko` binary that expects a different GOROOT.
-//
-// See https://github.com/google/ko/issues/106
-func getGoroot(ctx context.Context, dir string) (string, error) {
-	cmd := exec.CommandContext(ctx, "go", "env", "GOROOT")
-	// It's probably not necessary to set the command working directory here,
-	// but it helps keep everything consistent.
-	cmd.Dir = dir
-	output, err := cmd.Output()
-	return strings.TrimSpace(string(output)), err
-}
-
 // NewGo returns a build.Interface implementation that:
 //  1. builds go binaries named by importpath,
 //  2. containerizes the binary on a suitable base.
@@ -201,36 +114,9 @@ func getGoroot(ctx context.Context, dir string) (string, error) {
 // The `dir` argument is the working directory for executing the `go` tool.
 // If `dir` is empty, the function uses the current process working directory.
 func NewGo(ctx context.Context, dir string, options ...Option) (Interface, error) {
-	// TODO: We could do moduleInfo() and getGoroot() concurrently.
-	module, err := moduleInfo(ctx, dir)
-	if err != nil {
-		return nil, err
-	}
-
-	goroot, err := getGoroot(ctx, dir)
-	if err != nil {
-		// On error, print the output and set goroot to "" to avoid using it later.
-		log.Printf("Unexpected error running \"go env GOROOT\": %v\n%v", err, goroot)
-		goroot = ""
-	} else if goroot == "" {
-		log.Printf(`Unexpected: $(go env GOROOT) == ""`)
-	}
-
-	// If $(go env GOROOT) successfully returns a non-empty string that differs from
-	// the default build context GOROOT, use $(go env GOROOT) instead.
-	bc := gb.Default
-	bc.Dir = dir
-	if goroot != "" && bc.GOROOT != goroot {
-		bc.GOROOT = goroot
-	}
-
 	gbo := &gobuildOpener{
-		build:        build,
-		mod:          module,
-		buildContext: &bc,
-		// dir is set on both buildContext and on gbo. Not ideal, but the
-		// build.Context interface doesn't expose
-		dir: dir,
+		build: build,
+		dir:   dir,
 	}
 
 	for _, option := range options {
@@ -280,39 +166,19 @@ func (g *gobuild) IsSupportedReference(s string) error {
 	if !ref.IsStrict() {
 		return errors.New("importpath does not start with ko://")
 	}
-	p, err := g.importPackage(ref)
+	pkgs, err := packages.Load(&packages.Config{Dir: g.dir, Mode: packages.NeedName}, ref.Path())
 	if err != nil {
-		return err
+		return fmt.Errorf("error loading package from %s: %w", ref.Path(), err)
 	}
-	if !p.IsCommand() {
+	if len(pkgs) != 1 {
+		return fmt.Errorf("found %d local packages, expected 1", len(pkgs))
+	}
+	if pkgs[0].Name != "main" {
 		return errors.New("importpath is not `package main`")
 	}
 	return nil
 }
 
-// importPackage wraps go/build.Import to handle go modules.
-//
-// Note that we will fall back to GOPATH if the project isn't using go modules.
-func (g *gobuild) importPackage(ref reference) (*gb.Package, error) {
-	if g.mod == nil {
-		return g.buildContext.Import(ref.Path(), gb.Default.GOPATH, gb.ImportComment)
-	}
-
-	// If we're inside a go modules project, try to use the module's directory
-	// as our source root to import:
-	// * any strict reference we get
-	// * paths that match module path prefix (they should be in this project)
-	// * relative paths (they should also be in this project)
-	// * path is a module
-
-	_, isDep := g.mod.deps[ref.Path()]
-	if ref.IsStrict() || strings.HasPrefix(ref.Path(), g.mod.main.Path) || gb.IsLocalImport(ref.Path()) || isDep {
-		return g.buildContext.Import(ref.Path(), g.mod.main.Dir, gb.ImportComment)
-	}
-
-	return nil, fmt.Errorf("unmatched importPackage %q with gomodules", ref.String())
-}
-
 func getGoarm(platform v1.Platform) (string, error) {
 	if !strings.HasPrefix(platform.Variant, "v") {
 		return "", fmt.Errorf("strange arm variant: %v", platform.Variant)
@@ -495,11 +361,17 @@ func tarBinary(name, binary string, creationTime v1.Time, platform *v1.Platform)
 }
 
 func (g *gobuild) kodataPath(ref reference) (string, error) {
-	p, err := g.importPackage(ref)
+	pkgs, err := packages.Load(&packages.Config{Dir: g.dir, Mode: packages.NeedFiles}, ref.Path())
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("error loading package from %s: %w", ref.Path(), err)
+	}
+	if len(pkgs) != 1 {
+		return "", fmt.Errorf("found %d local packages, expected 1", len(pkgs))
 	}
-	return filepath.Join(p.Dir, "kodata"), nil
+	if len(pkgs[0].GoFiles) == 0 {
+		return "", fmt.Errorf("package %s contains no Go files", pkgs[0])
+	}
+	return filepath.Join(filepath.Dir(pkgs[0].GoFiles[0]), "kodata"), nil
 }
 
 // Where kodata lives in the image.
@@ -695,6 +567,10 @@ func (g *gobuild) configForImportPath(ip string) Config {
 		config.Flags = append(config.Flags, "-gcflags", "all=-N -l")
 	}
 
+	if config.ID != "" {
+		log.Printf("Using build config %s for %s", config.ID, ip)
+	}
+
 	return config
 }
 
diff --git a/pkg/build/gobuilds.go b/pkg/build/gobuilds.go
new file mode 100644
index 00000000..283533cd
--- /dev/null
+++ b/pkg/build/gobuilds.go
@@ -0,0 +1,155 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package build
+
+import (
+	"context"
+	"fmt"
+	gb "go/build"
+	"path"
+	"path/filepath"
+	"strings"
+)
+
+type gobuilds struct {
+	// Map of fully qualified import path to go builder with config
+	builders map[string]builderWithConfig
+
+	// Default go builder used if there's no matching build config.
+	defaultBuilder Interface
+
+	// workingDirectory is typically ".", but it may be a different value if ko is embedded as a library.
+	workingDirectory string
+}
+
+// builderWithConfig is not an imaginative name.
+type builderWithConfig struct {
+	builder Interface
+	config  Config
+}
+
+// NewGobuilds returns a build.Interface that can dispatch to builders based on matching the import path to a build config in .ko.yaml.
+func NewGobuilds(ctx context.Context, workingDirectory string, buildConfigs map[string]Config, opts ...Option) (Interface, error) {
+	if workingDirectory == "" {
+		workingDirectory = "."
+	}
+	defaultBuilder, err := NewGo(ctx, workingDirectory, opts...)
+	if err != nil {
+		return nil, fmt.Errorf("could not create default go builder: %w", err)
+	}
+	g := &gobuilds{
+		builders:         map[string]builderWithConfig{},
+		defaultBuilder:   defaultBuilder,
+		workingDirectory: workingDirectory,
+	}
+	for importpath, buildConfig := range buildConfigs {
+		builderDirectory := path.Join(workingDirectory, buildConfig.Dir)
+		builder, err := NewGo(ctx, builderDirectory, opts...)
+		if err != nil {
+			return nil, fmt.Errorf("could not create go builder for config (%q): %w", importpath, err)
+		}
+		g.builders[importpath] = builderWithConfig{
+			builder: builder,
+			config:  buildConfig,
+		}
+	}
+	return g, nil
+}
+
+// QualifyImport implements build.Interface
+func (g *gobuilds) QualifyImport(importpath string) (string, error) {
+	b := g.builder(importpath)
+	if b.config.Dir != "" {
+		var err error
+		importpath, err = relativePath(b.config.Dir, importpath)
+		if err != nil {
+			return "", err
+		}
+	}
+	return b.builder.QualifyImport(importpath)
+}
+
+// IsSupportedReference implements build.Interface
+func (g *gobuilds) IsSupportedReference(importpath string) error {
+	return g.builder(importpath).builder.IsSupportedReference(importpath)
+}
+
+// Build implements build.Interface
+func (g *gobuilds) Build(ctx context.Context, importpath string) (Result, error) {
+	return g.builder(importpath).builder.Build(ctx, importpath)
+}
+
+// builder selects a go builder for the provided import path.
+// The `importpath` argument can be either local (e.g., `./cmd/foo`) or not (e.g., `example.com/app/cmd/foo`).
+func (g *gobuilds) builder(importpath string) builderWithConfig {
+	importpath = strings.TrimPrefix(importpath, StrictScheme)
+	if len(g.builders) == 0 {
+		return builderWithConfig{
+			builder: g.defaultBuilder,
+		}
+	}
+	// first, try to find go builder by fully qualified import path
+	if builderWithConfig, exists := g.builders[importpath]; exists {
+		return builderWithConfig
+	}
+	// second, try to find go builder by local path
+	for _, builderWithConfig := range g.builders {
+		// Match go builder by trying to resolve the local path to a fully qualified import path. If successful, we have a winner.
+		relPath, err := relativePath(builderWithConfig.config.Dir, importpath)
+		if err != nil {
+			// Cannot determine a relative path. Move on and try the next go builder.
+			continue
+		}
+		_, err = builderWithConfig.builder.QualifyImport(relPath)
+		if err != nil {
+			// There's an error turning the local path into a fully qualified import path. Move on and try the next go builder.
+			continue
+		}
+		return builderWithConfig
+	}
+	// fall back to default go builder
+	return builderWithConfig{
+		builder: g.defaultBuilder,
+	}
+}
+
+// relativePath takes as input a local import path, and returns a path relative to the base directory.
+//
+// For example, given the following inputs:
+// - baseDir: "app"
+// - importpath: "./app/cmd/foo
+// The output is: "./cmd/foo"
+//
+// If the input is a not a local import path as determined by go/build.IsLocalImport(), the input is returned unchanged.
+//
+// If the import path is _not_ a subdirectory of baseDir, the result is an error.
+func relativePath(baseDir string, importpath string) (string, error) {
+	// Return input unchanged if the import path is a fully qualified import path
+	if !gb.IsLocalImport(importpath) {
+		return importpath, nil
+	}
+	relPath, err := filepath.Rel(baseDir, importpath)
+	if err != nil {
+		return "", fmt.Errorf("cannot determine relative path of baseDir (%q) and local path (%q): %v", baseDir, importpath, err)
+	}
+	if strings.HasPrefix(relPath, "..") {
+		// TODO Is this assumption correct?
+		return "", fmt.Errorf("import path (%q) must be a subdirectory of build config directory (%q)", importpath, baseDir)
+	}
+	if !strings.HasPrefix(relPath, ".") && relPath != "." {
+		relPath = "./" + relPath // ensure go/build.IsLocalImport() interprets this as a local path
+	}
+	return relPath, nil
+}
diff --git a/pkg/build/options.go b/pkg/build/options.go
index 60c0fca7..443cdd3e 100644
--- a/pkg/build/options.go
+++ b/pkg/build/options.go
@@ -98,20 +98,3 @@ func withBuilder(b builder) Option {
 		return nil
 	}
 }
-
-// withModulePath is a functional option for overriding the module path for
-// the current ko invocation.
-// This is exposed for testing.
-func withModuleInfo(m *modules) Option {
-	return func(gbo *gobuildOpener) error {
-		gbo.mod = m
-		return nil
-	}
-}
-
-func withBuildContext(b buildContext) Option {
-	return func(gbo *gobuildOpener) error {
-		gbo.buildContext = b
-		return nil
-	}
-}
diff --git a/pkg/commands/apply.go b/pkg/commands/apply.go
index 42522c72..de71724f 100644
--- a/pkg/commands/apply.go
+++ b/pkg/commands/apply.go
@@ -85,9 +85,7 @@ func addApply(topLevel *cobra.Command) {
 			if !isKubectlAvailable() {
 				return errors.New("error: kubectl is not available. kubectl must be installed to use ko apply")
 			}
-
-			// Cancel on signals.
-			ctx := createCancellableContext()
+			ctx := cmd.Context()
 
 			bo.InsecureRegistry = po.InsecureRegistry
 			builder, err := makeBuilder(ctx, bo)
diff --git a/pkg/commands/build.go b/pkg/commands/build.go
index 06a2b368..9472d6d2 100644
--- a/pkg/commands/build.go
+++ b/pkg/commands/build.go
@@ -57,8 +57,9 @@ func addBuild(topLevel *cobra.Command) {
   # This always preserves import paths.
   ko build --local github.com/foo/bar/cmd/baz github.com/foo/bar/cmd/blah`,
 		Args: cobra.MinimumNArgs(1),
-		RunE: func(_ *cobra.Command, args []string) error {
-			ctx := createCancellableContext()
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+
 			bo.InsecureRegistry = po.InsecureRegistry
 			builder, err := makeBuilder(ctx, bo)
 			if err != nil {
diff --git a/pkg/commands/config.go b/pkg/commands/config.go
index e5769723..bd351734 100644
--- a/pkg/commands/config.go
+++ b/pkg/commands/config.go
@@ -21,11 +21,8 @@ import (
 	"fmt"
 	"log"
 	"os"
-	"os/signal"
-	"path/filepath"
 	"strconv"
 	"strings"
-	"syscall"
 	"time"
 
 	"github.com/google/go-containerregistry/pkg/authn"
@@ -34,26 +31,13 @@ import (
 	"github.com/google/go-containerregistry/pkg/v1/daemon"
 	"github.com/google/go-containerregistry/pkg/v1/remote"
 	"github.com/google/go-containerregistry/pkg/v1/types"
+
 	"github.com/google/ko/pkg/build"
 	"github.com/google/ko/pkg/commands/options"
 	"github.com/google/ko/pkg/publish"
-	"github.com/spf13/viper"
-	"golang.org/x/tools/go/packages"
-)
-
-const (
-	// configDefaultBaseImage is the default base image if not specified in .ko.yaml.
-	configDefaultBaseImage = "gcr.io/distroless/static:nonroot"
-)
-
-var (
-	defaultBaseImage   string
-	baseImageOverrides map[string]string
-	buildConfigs       map[string]build.Config
 )
 
 // getBaseImage returns a function that determines the base image for a given import path.
-// If the `bo.BaseImage` parameter is non-empty, it overrides base image configuration from `.ko.yaml`.
 func getBaseImage(platform string, bo *options.BuildOptions) build.GetBase {
 	return func(ctx context.Context, s string) (name.Reference, build.Result, error) {
 		s = strings.TrimPrefix(s, build.StrictScheme)
@@ -63,14 +47,11 @@ func getBaseImage(platform string, bo *options.BuildOptions) build.GetBase {
 		//    github.com/GoogleCloudPlatform/foo/cmd/bar
 		// comes through as:
 		//    github.com/googlecloudplatform/foo/cmd/bar
-		baseImage, ok := baseImageOverrides[strings.ToLower(s)]
-		if !ok {
-			baseImage = defaultBaseImage
-		}
-		if bo.BaseImage != "" {
+		baseImage, ok := bo.BaseImageOverrides[strings.ToLower(s)]
+		if !ok || baseImage == "" {
 			baseImage = bo.BaseImage
 		}
-		nameOpts := []name.Option{}
+		var nameOpts []name.Option
 		if bo.InsecureRegistry {
 			nameOpts = append(nameOpts, name.Insecure)
 		}
@@ -159,107 +140,3 @@ func getCreationTime() (*v1.Time, error) {
 func getKoDataCreationTime() (*v1.Time, error) {
 	return getTimeFromEnv("KO_DATA_DATE_EPOCH")
 }
-
-func createCancellableContext() context.Context {
-	signals := make(chan os.Signal)
-	signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
-	ctx, cancel := context.WithCancel(context.Background())
-
-	go func() {
-		<-signals
-		cancel()
-	}()
-
-	return ctx
-}
-
-func createBuildConfigMap(workingDirectory string, configs []build.Config) (map[string]build.Config, error) {
-	buildConfigsByImportPath := make(map[string]build.Config)
-	for i, config := range configs {
-		// Make sure to behave like GoReleaser by defaulting to the current
-		// directory in case the build or main field is not set, check
-		// https://goreleaser.com/customization/build/ for details
-		if config.Dir == "" {
-			config.Dir = "."
-		}
-		if config.Main == "" {
-			config.Main = "."
-		}
-
-		// baseDir is the directory where `go list` will be run to look for package information
-		baseDir := filepath.Join(workingDirectory, config.Dir)
-
-		// To behave like GoReleaser, check whether the configured `main` config value points to a
-		// source file, and if so, just use the directory it is in
-		path := config.Main
-		if fi, err := os.Stat(filepath.Join(baseDir, config.Main)); err == nil && fi.Mode().IsRegular() {
-			path = filepath.Dir(config.Main)
-		}
-
-		// By default, paths configured in the builds section are considered
-		// local import paths, therefore add a "./" equivalent as a prefix to
-		// the constructured import path
-		localImportPath := fmt.Sprint(".", string(filepath.Separator), path)
-
-		pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName, Dir: baseDir}, localImportPath)
-		if err != nil {
-			return nil, fmt.Errorf("'builds': entry #%d does not contain a valid local import path (%s) for directory (%s): %v", i, localImportPath, baseDir, err)
-		}
-
-		if len(pkgs) != 1 {
-			return nil, fmt.Errorf("'builds': entry #%d results in %d local packages, only 1 is expected", i, len(pkgs))
-		}
-		importPath := pkgs[0].PkgPath
-		buildConfigsByImportPath[importPath] = config
-	}
-
-	return buildConfigsByImportPath, nil
-}
-
-// loadConfig reads build configuration from defaults, environment variables, and the `.ko.yaml` config file.
-func loadConfig(workingDirectory string) error {
-	v := viper.New()
-	if workingDirectory == "" {
-		workingDirectory = "."
-	}
-	// If omitted, use this base image.
-	v.SetDefault("defaultBaseImage", configDefaultBaseImage)
-	v.SetConfigName(".ko") // .yaml is implicit
-	v.SetEnvPrefix("KO")
-	v.AutomaticEnv()
-
-	if override := os.Getenv("KO_CONFIG_PATH"); override != "" {
-		v.AddConfigPath(override)
-	}
-
-	v.AddConfigPath(workingDirectory)
-
-	if err := v.ReadInConfig(); err != nil {
-		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
-			return fmt.Errorf("error reading config file: %v", err)
-		}
-	}
-
-	ref := v.GetString("defaultBaseImage")
-	if _, err := name.ParseReference(ref); err != nil {
-		return fmt.Errorf("'defaultBaseImage': error parsing %q as image reference: %v", ref, err)
-	}
-	defaultBaseImage = ref
-
-	baseImageOverrides = make(map[string]string)
-	overrides := v.GetStringMapString("baseImageOverrides")
-	for key, value := range overrides {
-		if _, err := name.ParseReference(value); err != nil {
-			return fmt.Errorf("'baseImageOverrides': error parsing %q as image reference: %v", value, err)
-		}
-		baseImageOverrides[key] = value
-	}
-
-	var builds []build.Config
-	if err := v.UnmarshalKey("builds", &builds); err != nil {
-		return fmt.Errorf("configuration section 'builds' cannot be parsed")
-	}
-	var err error
-	buildConfigs, err = createBuildConfigMap(workingDirectory, builds)
-	return err
-}
diff --git a/pkg/commands/create.go b/pkg/commands/create.go
index d37f3e4f..21874cb1 100644
--- a/pkg/commands/create.go
+++ b/pkg/commands/create.go
@@ -70,9 +70,7 @@ func addCreate(topLevel *cobra.Command) {
 			if !isKubectlAvailable() {
 				return errors.New("error: kubectl is not available. kubectl must be installed to use ko create")
 			}
-
-			// Cancel on signals.
-			ctx := createCancellableContext()
+			ctx := cmd.Context()
 
 			bo.InsecureRegistry = po.InsecureRegistry
 			builder, err := makeBuilder(ctx, bo)
diff --git a/pkg/commands/delete.go b/pkg/commands/delete.go
index 7f996e20..07f7d8eb 100644
--- a/pkg/commands/delete.go
+++ b/pkg/commands/delete.go
@@ -28,27 +28,25 @@ type runCmd func(*cobra.Command, []string) error
 // passthru returns a runCmd that simply passes our CLI arguments
 // through to a binary named command.
 func passthru(command string) runCmd {
-	return func(_ *cobra.Command, _ []string) error {
+	return func(cmd *cobra.Command, _ []string) error {
 		if !isKubectlAvailable() {
 			return errors.New("error: kubectl is not available. kubectl must be installed to use ko delete")
 		}
-
-		// Cancel on signals.
-		ctx := createCancellableContext()
+		ctx := cmd.Context()
 
 		// Start building a command line invocation by passing
 		// through our arguments to command's CLI.
-		cmd := exec.CommandContext(ctx, command, os.Args[1:]...)
+		ecmd := exec.CommandContext(ctx, command, os.Args[1:]...)
 
 		// Pass through our environment
-		cmd.Env = os.Environ()
+		ecmd.Env = os.Environ()
 		// Pass through our stdfoo
-		cmd.Stderr = os.Stderr
-		cmd.Stdout = os.Stdout
-		cmd.Stdin = os.Stdin
+		ecmd.Stderr = os.Stderr
+		ecmd.Stdout = os.Stdout
+		ecmd.Stdin = os.Stdin
 
 		// Run it.
-		return cmd.Run()
+		return ecmd.Run()
 	}
 }
 
diff --git a/pkg/commands/deps.go b/pkg/commands/deps.go
index 4926e898..cc2c4d7c 100644
--- a/pkg/commands/deps.go
+++ b/pkg/commands/deps.go
@@ -43,7 +43,7 @@ If the image was not built using ko, or if it was built without embedding depend
   ko deps docker.io/my-user/my-image:v3`,
 		Args: cobra.ExactArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			ctx := createCancellableContext()
+			ctx := cmd.Context()
 
 			ref, err := name.ParseReference(args[0])
 			if err != nil {
diff --git a/pkg/commands/options/build.go b/pkg/commands/options/build.go
index 5533655f..04199769 100644
--- a/pkg/commands/options/build.go
+++ b/pkg/commands/options/build.go
@@ -17,8 +17,21 @@ limitations under the License.
 package options
 
 import (
-	"github.com/google/ko/pkg/build"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/google/go-containerregistry/pkg/name"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"golang.org/x/tools/go/packages"
+
+	"github.com/google/ko/pkg/build"
+)
+
+const (
+	// configDefaultBaseImage is the default base image if not specified in .ko.yaml.
+	configDefaultBaseImage = "gcr.io/distroless/static:nonroot"
 )
 
 // BuildOptions represents options for the ko builder.
@@ -27,6 +40,9 @@ type BuildOptions struct {
 	// If non-empty, this takes precedence over the value in `.ko.yaml`.
 	BaseImage string
 
+	// BaseImageOverrides stores base image overrides for import paths.
+	BaseImageOverrides map[string]string
+
 	// WorkingDirectory allows for setting the working directory for invocations of the `go` tool.
 	// Empty string means the current working directory.
 	WorkingDirectory string
@@ -41,7 +57,7 @@ type BuildOptions struct {
 
 	InsecureRegistry bool
 
-	// BuildConfigs enables programmatic overriding of build config set in `.ko.yaml`.
+	// BuildConfigs stores the per-image build config from `.ko.yaml`.
 	BuildConfigs map[string]build.Config
 }
 
@@ -55,3 +71,116 @@ func AddBuildOptions(cmd *cobra.Command, bo *BuildOptions) {
 	cmd.Flags().StringSliceVar(&bo.Labels, "image-label", []string{},
 		"Which labels (key=value) to add to the image.")
 }
+
+// LoadConfig reads build configuration from defaults, environment variables, and the `.ko.yaml` config file.
+func (bo *BuildOptions) LoadConfig() error {
+	v := viper.New()
+	if bo.WorkingDirectory == "" {
+		bo.WorkingDirectory = "."
+	}
+	// If omitted, use this base image.
+	v.SetDefault("defaultBaseImage", configDefaultBaseImage)
+	v.SetConfigName(".ko") // .yaml is implicit
+	v.SetEnvPrefix("KO")
+	v.AutomaticEnv()
+
+	if override := os.Getenv("KO_CONFIG_PATH"); override != "" {
+		v.AddConfigPath(override)
+	}
+
+	v.AddConfigPath(bo.WorkingDirectory)
+
+	if err := v.ReadInConfig(); err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
+			return fmt.Errorf("error reading config file: %v", err)
+		}
+	}
+
+	if bo.BaseImage == "" {
+		ref := v.GetString("defaultBaseImage")
+		if _, err := name.ParseReference(ref); err != nil {
+			return fmt.Errorf("'defaultBaseImage': error parsing %q as image reference: %v", ref, err)
+		}
+		bo.BaseImage = ref
+	}
+
+	if len(bo.BaseImageOverrides) == 0 {
+		baseImageOverrides := map[string]string{}
+		overrides := v.GetStringMapString("baseImageOverrides")
+		for key, value := range overrides {
+			if _, err := name.ParseReference(value); err != nil {
+				return fmt.Errorf("'baseImageOverrides': error parsing %q as image reference: %v", value, err)
+			}
+			baseImageOverrides[key] = value
+		}
+		bo.BaseImageOverrides = baseImageOverrides
+	}
+
+	if len(bo.BuildConfigs) == 0 {
+		var builds []build.Config
+		if err := v.UnmarshalKey("builds", &builds); err != nil {
+			return fmt.Errorf("configuration section 'builds' cannot be parsed")
+		}
+		buildConfigs, err := createBuildConfigMap(bo.WorkingDirectory, builds)
+		if err != nil {
+			return fmt.Errorf("could not create build config map: %w", err)
+		}
+		bo.BuildConfigs = buildConfigs
+	}
+
+	return nil
+}
+
+func createBuildConfigMap(workingDirectory string, configs []build.Config) (map[string]build.Config, error) {
+	buildConfigsByImportPath := make(map[string]build.Config)
+	for i, config := range configs {
+		// In case no ID is specified, use the index of the build config in
+		// the ko YAML file as a reference (debug help).
+		if config.ID == "" {
+			config.ID = fmt.Sprintf("#%d", i)
+		}
+
+		// Make sure to behave like GoReleaser by defaulting to the current
+		// directory in case the build or main field is not set, check
+		// https://goreleaser.com/customization/build/ for details
+		if config.Dir == "" {
+			config.Dir = "."
+		}
+		if config.Main == "" {
+			config.Main = "."
+		}
+
+		// baseDir is the directory where `go list` will be run to look for package information
+		baseDir := filepath.Join(workingDirectory, config.Dir)
+
+		// To behave like GoReleaser, check whether the configured `main` config value points to a
+		// source file, and if so, just use the directory it is in
+		path := config.Main
+		if fi, err := os.Stat(filepath.Join(baseDir, config.Main)); err == nil && fi.Mode().IsRegular() {
+			path = filepath.Dir(config.Main)
+		}
+
+		// Verify that the path actually leads to a local file (https://github.com/google/ko/issues/483)
+		if _, err := os.Stat(filepath.Join(baseDir, path)); err != nil {
+			return nil, err
+		}
+
+		// By default, paths configured in the builds section are considered
+		// local import paths, therefore add a "./" equivalent as a prefix to
+		// the constructured import path
+		localImportPath := fmt.Sprint(".", string(filepath.Separator), path)
+
+		pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName, Dir: baseDir}, localImportPath)
+		if err != nil {
+			return nil, fmt.Errorf("'builds': entry #%d does not contain a valid local import path (%s) for directory (%s): %v", i, localImportPath, baseDir, err)
+		}
+
+		if len(pkgs) != 1 {
+			return nil, fmt.Errorf("'builds': entry #%d results in %d local packages, only 1 is expected", i, len(pkgs))
+		}
+		importPath := pkgs[0].PkgPath
+		buildConfigsByImportPath[importPath] = config
+	}
+
+	return buildConfigsByImportPath, nil
+}
diff --git a/pkg/commands/testdata/config/.ko.yaml b/pkg/commands/options/testdata/config/.ko.yaml
similarity index 100%
rename from pkg/commands/testdata/config/.ko.yaml
rename to pkg/commands/options/testdata/config/.ko.yaml
diff --git a/pkg/commands/testdata/paths/.ko.yaml b/pkg/commands/options/testdata/paths/.ko.yaml
similarity index 100%
rename from pkg/commands/testdata/paths/.ko.yaml
rename to pkg/commands/options/testdata/paths/.ko.yaml
diff --git a/pkg/commands/testdata/paths/app/cmd/foo/main.go b/pkg/commands/options/testdata/paths/app/cmd/foo/main.go
similarity index 100%
rename from pkg/commands/testdata/paths/app/cmd/foo/main.go
rename to pkg/commands/options/testdata/paths/app/cmd/foo/main.go
diff --git a/pkg/commands/testdata/paths/app/go.mod b/pkg/commands/options/testdata/paths/app/go.mod
similarity index 100%
rename from pkg/commands/testdata/paths/app/go.mod
rename to pkg/commands/options/testdata/paths/app/go.mod
diff --git a/pkg/commands/resolve.go b/pkg/commands/resolve.go
index 59e183b1..c3de69d3 100644
--- a/pkg/commands/resolve.go
+++ b/pkg/commands/resolve.go
@@ -55,7 +55,8 @@ func addResolve(topLevel *cobra.Command) {
   ko resolve --local -f config/`,
 		Args: cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			ctx := createCancellableContext()
+			ctx := cmd.Context()
+
 			bo.InsecureRegistry = po.InsecureRegistry
 			builder, err := makeBuilder(ctx, bo)
 			if err != nil {
diff --git a/pkg/commands/resolver.go b/pkg/commands/resolver.go
index bac8cd53..84a907be 100644
--- a/pkg/commands/resolver.go
+++ b/pkg/commands/resolver.go
@@ -32,14 +32,15 @@ import (
 
 	"github.com/google/go-containerregistry/pkg/authn"
 	"github.com/google/go-containerregistry/pkg/name"
-	"github.com/google/ko/pkg/build"
-	"github.com/google/ko/pkg/commands/options"
-	"github.com/google/ko/pkg/publish"
-	"github.com/google/ko/pkg/resolve"
 	"github.com/mattmoor/dep-notify/pkg/graph"
 	"golang.org/x/sync/errgroup"
 	"gopkg.in/yaml.v3"
 	"k8s.io/apimachinery/pkg/labels"
+
+	"github.com/google/ko/pkg/build"
+	"github.com/google/ko/pkg/commands/options"
+	"github.com/google/ko/pkg/publish"
+	"github.com/google/ko/pkg/resolve"
 )
 
 // ua returns the ko user agent.
@@ -106,11 +107,8 @@ func gobuildOptions(bo *options.BuildOptions) ([]build.Option, error) {
 		opts = append(opts, build.WithLabel(parts[0], parts[1]))
 	}
 
-	// prefer buildConfigs from BuildOptions
 	if bo.BuildConfigs != nil {
 		opts = append(opts, build.WithConfig(bo.BuildConfigs))
-	} else if len(buildConfigs) > 0 {
-		opts = append(opts, build.WithConfig(buildConfigs))
 	}
 
 	return opts, nil
@@ -122,14 +120,14 @@ func NewBuilder(ctx context.Context, bo *options.BuildOptions) (build.Interface,
 }
 
 func makeBuilder(ctx context.Context, bo *options.BuildOptions) (*build.Caching, error) {
-	if err := loadConfig(bo.WorkingDirectory); err != nil {
+	if err := bo.LoadConfig(); err != nil {
 		return nil, err
 	}
 	opt, err := gobuildOptions(bo)
 	if err != nil {
 		return nil, fmt.Errorf("error setting up builder options: %v", err)
 	}
-	innerBuilder, err := build.NewGo(ctx, bo.WorkingDirectory, opt...)
+	innerBuilder, err := build.NewGobuilds(ctx, bo.WorkingDirectory, bo.BuildConfigs, opt...)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/commands/run.go b/pkg/commands/run.go
index b863cfc4..e72788fd 100644
--- a/pkg/commands/run.go
+++ b/pkg/commands/run.go
@@ -49,7 +49,7 @@ func addRun(topLevel *cobra.Command) {
   # You can also supply args and flags to the command.
   ko run ./cmd/baz -- -v arg1 arg2 --yes`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			ctx := createCancellableContext()
+			ctx := cmd.Context()
 
 			// Args after -- are for kubectl, so only consider importPaths before it.
 			importPaths := args
diff --git a/vendor/modules.txt b/vendor/modules.txt
index cb79bb89..4214b655 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -27,7 +27,7 @@ github.com/docker/distribution/digestset
 github.com/docker/distribution/reference
 github.com/docker/distribution/registry/api/errcode
 github.com/docker/distribution/registry/client/auth/challenge
-# github.com/docker/docker v20.10.9+incompatible
+# github.com/docker/docker v20.10.10+incompatible
 ## explicit
 github.com/docker/docker/api
 github.com/docker/docker/api/types
@@ -278,7 +278,7 @@ gopkg.in/yaml.v2
 # gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
 ## explicit
 gopkg.in/yaml.v3
-# k8s.io/apimachinery v0.22.2
+# k8s.io/apimachinery v0.22.3
 ## explicit
 k8s.io/apimachinery/pkg/labels
 k8s.io/apimachinery/pkg/selection


Test patch:
diff --git a/integration_test.sh b/integration_test.sh
index 7e0c7853..46b4c156 100755
--- a/integration_test.sh
+++ b/integration_test.sh
@@ -3,7 +3,7 @@ set -o errexit
 set -o nounset
 set -o pipefail
 
-ROOT_DIR=$(dirname $0)
+ROOT_DIR=$(dirname "$0")
 
 pushd "$ROOT_DIR"
 
@@ -11,7 +11,8 @@ ROOT_DIR="$(pwd)"
 
 echo "Moving GOPATH into /tmp/ to test modules behavior."
 export ORIGINAL_GOPATH="$GOPATH"
-export GOPATH="$(mktemp -d)"
+GOPATH="$(mktemp -d)"
+export GOPATH
 
 pushd "$GOPATH" || exit 1
 
@@ -91,8 +92,24 @@ fi
 echo "7. On outside the module should fail."
 pushd .. || exit 1
 GO111MODULE=on ./ko/ko build --local github.com/go-training/helloworld && exit 1
+popd || exit 1
 
+echo "8. On outside with build config specifying the test module builds."
+pushd test/build-configs || exit 1
+for app in foo bar ; do
+  # test both local and fully qualified import paths
+  for prefix in example.com . ; do
+    import_path=$prefix/$app/cmd
+    RESULT="$(GO111MODULE=on GOFLAGS="" ../../ko build --local $import_path | grep "$FILTER" | xargs -I% docker run %)"
+    if [[ "$RESULT" != *"$app"* ]]; then
+      echo "Test FAILED for $import_path. Saw $RESULT but expected $app" && exit 1
+    else
+      echo "Test PASSED for $import_path"
+    fi
+  done
+done
 popd || exit 1
+
 popd || exit 1
 popd || exit 1
 
diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go
index a271364a..c439b3ba 100644
--- a/pkg/build/gobuild_test.go
+++ b/pkg/build/gobuild_test.go
@@ -20,7 +20,6 @@ import (
 	"archive/tar"
 	"context"
 	"fmt"
-	gb "go/build"
 	"io"
 	"io/ioutil"
 	"path"
@@ -176,29 +175,8 @@ func TestGoBuildIsSupportedRefWithModules(t *testing.T) {
 		t.Fatalf("random.Image() = %v", err)
 	}
 
-	mods := &modules{
-		main: &modInfo{
-			Path: "github.com/google/ko/test",
-			Dir:  ".",
-		},
-		deps: map[string]*modInfo{
-			"github.com/some/module/cmd": {
-				Path: "github.com/some/module/cmd",
-				Dir:  ".",
-			},
-		},
-	}
-
 	opts := []Option{
 		WithBaseImages(func(context.Context, string) (name.Reference, Result, error) { return baseRef, base, nil }),
-		withModuleInfo(mods),
-		withBuildContext(stubBuildContext{
-			// make all referenced deps commands
-			"github.com/google/ko/test":  &gb.Package{Name: "main"},
-			"github.com/some/module/cmd": &gb.Package{Name: "main"},
-
-			"github.com/google/ko/pkg/build": &gb.Package{Name: "build"},
-		}),
 	}
 
 	ng, err := NewGo(context.Background(), "", opts...)
@@ -208,8 +186,8 @@ func TestGoBuildIsSupportedRefWithModules(t *testing.T) {
 
 	// Supported import paths.
 	for _, importpath := range []string{
-		"ko://github.com/google/ko/test",  // ko can build the test package.
-		"ko://github.com/some/module/cmd", // ko can build commands in dependent modules
+		"ko://github.com/google/ko/test",         // ko can build the test package.
+		"ko://github.com/go-training/helloworld", // ko can build commands in dependent modules
 	} {
 		t.Run(importpath, func(t *testing.T) {
 			if err := ng.IsSupportedReference(importpath); err != nil {
@@ -222,7 +200,7 @@ func TestGoBuildIsSupportedRefWithModules(t *testing.T) {
 	for _, importpath := range []string{
 		"ko://github.com/google/ko/pkg/build",       // not a command.
 		"ko://github.com/google/ko/pkg/nonexistent", // does not exist.
-		"ko://github.com/google/ko",                 // not in this module.
+		"ko://github.com/google/go-github",          // not in this module.
 	} {
 		t.Run(importpath, func(t *testing.T) {
 			if err := ng.IsSupportedReference(importpath); err == nil {
@@ -580,16 +558,6 @@ func validateImage(t *testing.T, img v1.Image, baseLayers int64, creationTime v1
 	})
 }
 
-type stubBuildContext map[string]*gb.Package
-
-func (s stubBuildContext) Import(path string, srcDir string, mode gb.ImportMode) (*gb.Package, error) {
-	p, ok := s[path]
-	if ok {
-		return p, nil
-	}
-	return nil, fmt.Errorf("not found: %s", path)
-}
-
 func TestGoBuild(t *testing.T) {
 	baseLayers := int64(3)
 	base, err := random.Image(1024, baseLayers)
diff --git a/pkg/build/gobuilds_test.go b/pkg/build/gobuilds_test.go
new file mode 100644
index 00000000..c601974a
--- /dev/null
+++ b/pkg/build/gobuilds_test.go
@@ -0,0 +1,190 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package build
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/v1/random"
+)
+
+func Test_gobuilds(t *testing.T) {
+	base, err := random.Image(1024, 1)
+	if err != nil {
+		t.Fatalf("random.Image() = %v", err)
+	}
+	baseRef := name.MustParseReference("all.your/base")
+	opts := []Option{
+		WithBaseImages(func(context.Context, string) (name.Reference, Result, error) { return baseRef, base, nil }),
+		withBuilder(writeTempFile),
+	}
+
+	tests := []struct {
+		description       string
+		workingDirectory  string
+		buildConfigs      map[string]Config
+		opts              []Option
+		nilDefaultBuilder bool // set to true if you want to test build config and don't want the test to fall back to the default builder
+		importpath        string
+	}{
+		{
+			description: "default builder used when no build configs provided",
+			opts:        opts,
+			importpath:  "github.com/google/ko",
+		},
+		{
+			description:      "match build config using fully qualified import path",
+			workingDirectory: "../..",
+			buildConfigs: map[string]Config{
+				"github.com/google/ko/test": {
+					ID:  "build-config-0",
+					Dir: "test",
+				},
+			},
+			nilDefaultBuilder: true,
+			opts:              opts,
+			importpath:        "github.com/google/ko/test",
+		},
+		{
+			description:      "match build config using ko scheme-prefixed fully qualified import path",
+			workingDirectory: "../..",
+			buildConfigs: map[string]Config{
+				"github.com/google/ko/test": {
+					ID:  "build-config-1",
+					Dir: "test",
+				},
+			},
+			nilDefaultBuilder: true,
+			opts:              opts,
+			importpath:        "ko://github.com/google/ko/test",
+		},
+		{
+			description:      "find build config by resolving local import path to fully qualified import path",
+			workingDirectory: "../../test",
+			buildConfigs: map[string]Config{
+				"github.com/google/ko/test": {
+					ID: "build-config-2",
+				},
+			},
+			nilDefaultBuilder: true,
+			opts:              opts,
+			importpath:        ".",
+		},
+		{
+			description:      "find build config by matching local import path to build config directory",
+			workingDirectory: "../..",
+			buildConfigs: map[string]Config{
+				"github.com/google/ko/tes12t": {
+					ID:  "build-config-3",
+					Dir: "test",
+				},
+			},
+			nilDefaultBuilder: true,
+			opts:              opts,
+			importpath:        "./test",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.description, func(t *testing.T) {
+			ctx := context.Background()
+			bi, err := NewGobuilds(ctx, test.workingDirectory, test.buildConfigs, test.opts...)
+			if err != nil {
+				t.Fatalf("NewGobuilds(): unexpected error: %v", err)
+			}
+			gbs := bi.(*gobuilds)
+			if test.nilDefaultBuilder {
+				gbs.defaultBuilder = nil
+			}
+			qualifiedImportpath, err := gbs.QualifyImport(test.importpath)
+			if err != nil {
+				t.Fatalf("gobuilds.QualifyImport(%s): unexpected error: %v", test.importpath, err)
+			}
+			if err = gbs.IsSupportedReference(qualifiedImportpath); err != nil {
+				t.Fatalf("gobuilds.IsSupportedReference(%s): unexpected error: %v", qualifiedImportpath, err)
+			}
+			result, err := gbs.Build(ctx, qualifiedImportpath)
+			if err != nil {
+				t.Fatalf("gobuilds.Build(%s): unexpected error = %v", qualifiedImportpath, err)
+			}
+			if result == nil {
+				t.Fatalf("gobuilds.Build(%s): expected non-nil result", qualifiedImportpath)
+			}
+		})
+	}
+}
+
+func Test_relativePath(t *testing.T) {
+	tests := []struct {
+		description string
+		baseDir     string
+		importpath  string
+		want        string
+		wantErr     bool
+	}{
+		{
+			description: "all empty string",
+		},
+		{
+			description: "all current directory",
+			baseDir:     ".",
+			importpath:  ".",
+			want:        ".",
+		},
+		{
+			description: "fully qualified import path without ko prefix",
+			baseDir:     "also-any-value-because-it-is-ignored",
+			importpath:  "example.com/app/cmd/foo",
+			want:        "example.com/app/cmd/foo",
+		},
+		{
+			description: "fully qualified import path with ko prefix",
+			baseDir:     "also-any-value-because-it-is-ignored",
+			importpath:  "ko://example.com/app/cmd/foo",
+			want:        "ko://example.com/app/cmd/foo",
+		},
+		{
+			description: "importpath is local subdirectory",
+			baseDir:     "foo",
+			importpath:  "./foo/bar",
+			want:        "./bar",
+		},
+		{
+			description: "importpath is same local directory",
+			baseDir:     "foo/bar",
+			importpath:  "./foo/bar",
+			want:        ".",
+		},
+		{
+			description: "importpath is not subdirectory or same local directory",
+			baseDir:     "foo",
+			importpath:  "./bar",
+			wantErr:     true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.description, func(t *testing.T) {
+			got, err := relativePath(test.baseDir, test.importpath)
+			if (err != nil) != test.wantErr {
+				t.Errorf("relativePath() error = %v, wantErr %v", err, test.wantErr)
+				return
+			}
+			if got != test.want {
+				t.Errorf("relativePath() got = %v, want %v", got, test.want)
+			}
+		})
+	}
+}
diff --git a/pkg/commands/config_test.go b/pkg/commands/config_test.go
index c86d5291..b62abfb2 100644
--- a/pkg/commands/config_test.go
+++ b/pkg/commands/config_test.go
@@ -22,7 +22,7 @@ import (
 	"testing"
 
 	"github.com/google/go-containerregistry/pkg/crane"
-	"github.com/google/ko/pkg/build"
+
 	"github.com/google/ko/pkg/commands/options"
 )
 
@@ -58,69 +58,3 @@ func TestOverrideDefaultBaseImageUsingBuildOption(t *testing.T) {
 		t.Errorf("got digest %s, wanted %s", gotDigest, wantDigest)
 	}
 }
-
-// TestDefaultBaseImage is a canary-type test for ensuring that config has been read when creating a builder.
-func TestDefaultBaseImage(t *testing.T) {
-	_, err := NewBuilder(context.Background(), &options.BuildOptions{
-		WorkingDirectory: "testdata/config",
-	})
-	if err != nil {
-		t.Fatal(err)
-	}
-	wantDefaultBaseImage := "gcr.io/distroless/base:nonroot" // matches value in ./testdata/.ko.yaml
-	if defaultBaseImage != wantDefaultBaseImage {
-		t.Fatalf("wanted defaultBaseImage %s, got %s", wantDefaultBaseImage, defaultBaseImage)
-	}
-}
-
-func TestBuildConfigWithWorkingDirectoryAndDirAndMain(t *testing.T) {
-	_, err := NewBuilder(context.Background(), &options.BuildOptions{
-		WorkingDirectory: "testdata/paths",
-	})
-	if err != nil {
-		t.Fatalf("NewBuilder(): %+v", err)
-	}
-
-	if len(buildConfigs) != 1 {
-		t.Fatalf("expected 1 build config, got %d", len(buildConfigs))
-	}
-	expectedImportPath := "example.com/testapp/cmd/foo" // module from app/go.mod + `main` from .ko.yaml
-	if _, exists := buildConfigs[expectedImportPath]; !exists {
-		t.Fatalf("expected build config for import path [%s], got %+v", expectedImportPath, buildConfigs)
-	}
-}
-
-func TestCreateBuildConfigs(t *testing.T) {
-	compare := func(expected string, actual string) {
-		if expected != actual {
-			t.Errorf("test case failed: expected '%#v', but actual value is '%#v'", expected, actual)
-		}
-	}
-
-	buildConfigs := []build.Config{
-		{ID: "defaults"},
-		{ID: "OnlyMain", Main: "test"},
-		{ID: "OnlyMainWithFile", Main: "test/main.go"},
-		{ID: "OnlyDir", Dir: "test"},
-		{ID: "DirAndMain", Dir: "test", Main: "main.go"},
-	}
-
-	for _, b := range buildConfigs {
-		buildConfigMap, err := createBuildConfigMap("../..", []build.Config{b})
-		if err != nil {
-			t.Fatal(err)
-		}
-		for importPath, buildCfg := range buildConfigMap {
-			switch buildCfg.ID {
-			case "defaults":
-				compare("github.com/google/ko", importPath)
-
-			case "OnlyMain", "OnlyMainWithFile", "OnlyDir", "DirAndMain":
-				compare("github.com/google/ko/test", importPath)
-
-			default:
-				t.Fatalf("unknown test case: %s", buildCfg.ID)
-			}
-		}
-	}
-}
diff --git a/pkg/commands/options/build_test.go b/pkg/commands/options/build_test.go
new file mode 100644
index 00000000..a8acfb96
--- /dev/null
+++ b/pkg/commands/options/build_test.go
@@ -0,0 +1,89 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package options
+
+import (
+	"testing"
+
+	"github.com/google/ko/pkg/build"
+)
+
+func TestDefaultBaseImage(t *testing.T) {
+	bo := &BuildOptions{
+		WorkingDirectory: "testdata/config",
+	}
+	err := bo.LoadConfig()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	wantDefaultBaseImage := "gcr.io/distroless/base:nonroot" // matches value in ./testdata/.ko.yaml
+	if bo.BaseImage != wantDefaultBaseImage {
+		t.Fatalf("wanted BaseImage %s, got %s", wantDefaultBaseImage, bo.BaseImage)
+	}
+}
+
+func TestBuildConfigWithWorkingDirectoryAndDirAndMain(t *testing.T) {
+	bo := &BuildOptions{
+		WorkingDirectory: "testdata/paths",
+	}
+	err := bo.LoadConfig()
+	if err != nil {
+		t.Fatalf("NewBuilder(): %v", err)
+	}
+
+	if len(bo.BuildConfigs) != 1 {
+		t.Fatalf("expected 1 build config, got %d", len(bo.BuildConfigs))
+	}
+	expectedImportPath := "example.com/testapp/cmd/foo" // module from app/go.mod + `main` from .ko.yaml
+	if _, exists := bo.BuildConfigs[expectedImportPath]; !exists {
+		t.Fatalf("expected build config for import path [%s], got %+v", expectedImportPath, bo.BuildConfigs)
+	}
+}
+
+func TestCreateBuildConfigs(t *testing.T) {
+	compare := func(expected string, actual string) {
+		if expected != actual {
+			t.Errorf("test case failed: expected '%#v', but actual value is '%#v'", expected, actual)
+		}
+	}
+
+	buildConfigs := []build.Config{
+		{ID: "defaults"},
+		{ID: "OnlyMain", Main: "test"},
+		{ID: "OnlyMainWithFile", Main: "test/main.go"},
+		{ID: "OnlyDir", Dir: "test"},
+		{ID: "DirAndMain", Dir: "test", Main: "main.go"},
+	}
+
+	for _, b := range buildConfigs {
+		buildConfigMap, err := createBuildConfigMap("../../..", []build.Config{b})
+		if err != nil {
+			t.Fatal(err)
+		}
+		for importPath, buildCfg := range buildConfigMap {
+			switch buildCfg.ID {
+			case "defaults":
+				compare("github.com/google/ko", importPath)
+
+			case "OnlyMain", "OnlyMainWithFile", "OnlyDir", "DirAndMain":
+				compare("github.com/google/ko/test", importPath)
+
+			default:
+				t.Fatalf("unknown test case: %s", buildCfg.ID)
+			}
+		}
+	}
+}
diff --git a/test/build-configs/.ko.yaml b/test/build-configs/.ko.yaml
new file mode 100644
index 00000000..c92d9430
--- /dev/null
+++ b/test/build-configs/.ko.yaml
@@ -0,0 +1,21 @@
+# Copyright 2021 Google LLC All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+builds:
+- id: foo-app
+  dir: ./foo
+  main: ./cmd
+- id: bar-app
+  dir: ./bar
+  main: ./cmd
diff --git a/test/build-configs/bar/cmd/main.go b/test/build-configs/bar/cmd/main.go
new file mode 100644
index 00000000..bde5a7f4
--- /dev/null
+++ b/test/build-configs/bar/cmd/main.go
@@ -0,0 +1,21 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("bar")
+}
diff --git a/test/build-configs/bar/go.mod b/test/build-configs/bar/go.mod
new file mode 100644
index 00000000..fac14d9e
--- /dev/null
+++ b/test/build-configs/bar/go.mod
@@ -0,0 +1,17 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+module example.com/bar
+
+go 1.16
diff --git a/test/build-configs/foo/cmd/main.go b/test/build-configs/foo/cmd/main.go
new file mode 100644
index 00000000..18cc4923
--- /dev/null
+++ b/test/build-configs/foo/cmd/main.go
@@ -0,0 +1,21 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("foo")
+}
diff --git a/test/build-configs/foo/go.mod b/test/build-configs/foo/go.mod
new file mode 100644
index 00000000..9a4b1cec
--- /dev/null
+++ b/test/build-configs/foo/go.mod
@@ -0,0 +1,17 @@
+// Copyright 2021 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+module example.com/foo
+
+go 1.16