Repository: goreleaser/goreleaser
Pull request #1414 (2020-03-31 18:11:16)

feat(pipe/build): Add support for flexible build hooks
This is an early prototype of a solution for #1328 to solicit early feedback.

### Usage

```yml
builds:
  -
    env:
      - CGO_ENABLED=0
    targets:
      - darwin_amd64
      - windows_386
      - windows_amd64
    after:
      all:
        hooks:
          - zip ./signable_binaries.zip {{ range .Builds }} {{ .Path }}{{ end }}
          - echo "Signing every eligible binary..."
          - cmd: ./codesign.sh
            env:
              - ZIP_FILE={{ .ProjectName }}_{{ .Version }}_{{ .Target }}.zip
      each:
        default_dir: "{{ .Path }}"
        hooks:
          - upx {{ .Name }}
```

--- 

I would be happy to add some tests, perhaps some more logging and docs if we agree on the rough direction.


codecov-io: # [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=h1) Report
> Merging [#1414](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=desc) into [master](https://codecov.io/gh/goreleaser/goreleaser/commit/375109acd76060104f2562951645de3270cddcef&el=desc) will **decrease** coverage by `1.09%`.
> The diff coverage is `34.66%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/goreleaser/pull/1414/graphs/tree.svg?width=650&height=150&src=pr&token=3bJvv6b7hN)](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1414      +/-   ##
==========================================
- Coverage   83.79%   82.69%   -1.10%     
==========================================
  Files          57       57              
  Lines        3350     3415      +65     
==========================================
+ Hits         2807     2824      +17     
- Misses        462      503      +41     
- Partials       81       88       +7     
```


| [Impacted Files](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [internal/tmpl/tmpl.go](https://codecov.io/gh/goreleaser/goreleaser/pull/1414/diff?src=pr&el=tree#diff-aW50ZXJuYWwvdG1wbC90bXBsLmdv) | `81.81% <0.00%> (-18.19%)` | :arrow_down: |
| [pkg/config/config.go](https://codecov.io/gh/goreleaser/goreleaser/pull/1414/diff?src=pr&el=tree#diff-cGtnL2NvbmZpZy9jb25maWcuZ28=) | `100.00% <ø> (ø)` | |
| [internal/pipe/build/build.go](https://codecov.io/gh/goreleaser/goreleaser/pull/1414/diff?src=pr&el=tree#diff-aW50ZXJuYWwvcGlwZS9idWlsZC9idWlsZC5nbw==) | `73.79% <42.62%> (-21.96%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=footer). Last update [375109a...df81704](https://codecov.io/gh/goreleaser/goreleaser/pull/1414?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

caarlos0: I don't get the all vs each thing 🤔
radeksimko: > I don't get the all vs each thing

Good question - the idea is that you may want to do something after `all` the builds finish (e.g. ZIP all binaries from that "build group" with the same build ID), or - perhaps more commonly (?) - do something after each build within that group.

I'm not sure how common these scenarios are, so I assume we could ship just `each` initially?

I'm open to ideas.
caarlos0: yeah I think `each` is more common...
radeksimko: That is fair - I'm happy to reduce the implementation to just `each` scenario.

Would you also then simplify the configuration - with the risk that adding `all` in the future may require some deprecation? I assume it's low risk and the UX benefit outweighs here?

So we may be able to leverage the existing hierarchy and avoid deprecations:

```yml
builds:
  -
    env:
      - CGO_ENABLED=0
    targets:
      - darwin_amd64
      - windows_386
      - windows_amd64
    hooks:
      post_default_dir: "{{ .Path }}"
      post:
          - upx {{ .Name }}
```

Is there anything else you feel is confusing or needs changing, or would you design it entirely differently? 😃 
caarlos0: maybe we can do something like:

```yaml
builds:
- main: main.go
  hooks:
    pre: ...
    post: ...
  each_hooks:
    # pre: ignore and throw a warning if != ""
    post: ...
```

this way we don't need to change the old hooks and can still add the each behavior...


wdyt? 
radeksimko: Yep, I think we could leverage the existing hierarchy that way. 👍 

The only downside of the current implementation of `hooks` is that it allows just a single command, so we'd probably need to implement custom unmarshaler for `hooks.pre` and `hooks.post` so that we retain the existing functionality of single command given as `string` and a slice of commands, where each can have its own isolated ENV variables and workdir. Certainly possible to implement, as long as we're ok with the UX of `pre` and `post` accepting both array and string.

Relatedly the current implementation is not concerned about workdir and I feel that having a default workdir may be beneficial to config simplicity, instead of repeating it in every command, or calling `cd` repeatedly.

--- 

Isolated environments are IMO better from security perspective, although there isn't really a way of passing through sensitive variables as they'd have to be hard-coded - but I didn't want to bike shed on this as I feel that's a problem that could be discussed separately.
caarlos0: > The only downside of the current implementation of `hooks` is that it allows just a single command, so we'd probably need to implement custom unmarshaler for `hooks.pre` and `hooks.post` so that we retain the existing functionality of single command given as `string` and a slice of commands, where each can have its own isolated ENV variables and workdir. Certainly possible to implement, as long as we're ok with the UX of `pre` and `post` accepting both array and string.

yes, we'll need a custom unmarshaler... we can also add the cwd config... or maybe its really easier to just add a `EachHooks` or something haha

anyway, multiple commands could be run by passing a path to a shell script instead 🤔 

> Isolated environments are IMO better from security perspective, although there isn't really a way of passing through sensitive variables as they'd have to be hard-code

maybe you can use `{{ .Env.NAME }}` for that?
radeksimko: > maybe you can use {{ .Env.NAME }} for that?

True, I'm not sure how/why I missed that in the docs!

> anyway, multiple commands could be run by passing a path to a shell script instead

True, it's just a little more plumbing and it hurts config reusability - so I suppose it depends on what the common scenario is and whether we can optimize for it. Will people more often run just a single command, or will they need to wrap things in shell script for common scenarios?

I personally see multiple commands more commonly around me, but I may have biased view 😄 
caarlos0: > True, it's just a little more plumbing and it hurts config reusability - so I suppose it depends on what the common scenario is and whether we can optimize for it. Will people more often run just a single command, or will they need to wrap things in shell script for common scenarios?
> 
> I personally see multiple commands more commonly around me, but I may have biased view 😄

yeah idk about that either... I usually use 0 commands hehe

but... i would probably put in a script so I can use `if` and etc if needed... maybe for simplicity we can assume that?
radeksimko: I believe I reflected most feedback in the prototype. It now leverages the existing config option and allows a few different levels of complexity:

```yml
builds:
- main: main.go
  hooks:
    pre: ./script.sh {{ .Path }}
    post: ./script.sh {{ .Path }}
```
```yml
builds:
- main: main.go
  hooks:
    pre:
      - ./script.sh {{ .Path }}
      - ./another-script.sh {{ .Name }}
    post:
      - ./script.sh {{ .Target }}
      - ./another-script.sh {{ .Target }}
```
```yml
builds:
- main: main.go
  hooks:
    pre:
      - cmd: ./script.sh {{ .Path }}
        env:
          - VAR={{ .Env.VAR }}
      - ./another-script.sh
```

I decided to keep the commands-as-array option, as I feel that GoReleaser itself has a great benefit of cross-OS reusability of config (as a result of Go cross-compilation abilities) and it would be IMO shame if users were forced to make hooks Linux/Windows-specific or generally deal with that problem of OS differences.

Should that be a blocker though I can step back from that decision, in the interest of getting _something_ in.

--- 

One important thing to note is that this _could_ in theory be perceived as a breaking change by some users as the existing hooks are executed just once before and after, not for each build target.
caarlos0: > One important thing to note is that this _could_ in theory be perceived as a breaking change by some users as the existing hooks are executed just once before and after, not for each build target.

this is a problem IMHO, but to be fair, I don't know if anyone uses that...
caarlos0: I did take a look on the code and it lgtm
radeksimko: @caarlos0 Thank you for the review, I will try to increase the code coverage a bit, add some docs and mark it as ready by the end of the day.

> this is a problem IMHO, but to be fair, I don't know if anyone uses that...

It sounds gross and a bit too magical in my mind 😬 but I will share that idea here anyway:

We could theoretically make the new behaviour opt-in by assuming that templating syntax wasn't supported before, so we'd only run hook for each target when there's valid Go template syntax (`{{}}`) found in the hook. In theory though people may still want the new behaviour (run per target) even _without_ templating syntax - I can't think of such use case, but there may be some?

I don't have strong opinions on this as I haven't used these hooks before myself, so it's not breaking for me. If you feel strongly this should be postponed for 1.0, or you have a better idea on how to introduce this sensibly without breaking stuff, I'd be happy to implement it.
caarlos0: for some reason I can't see the logs on travis 🤔 
caarlos0: it looks good to me, thanks @radeksimko for the awesome work on this PR 🚀 
caarlos0: I can't see the build logs though, will merge and fix it if something breaks :)
radeksimko: @caarlos0 Let me try to rebase it first - that may re-trigger the Travis job?
caarlos0: @radeksimko sorry, already did hehe

anyway, I trying re-trigging it on travis as well.. doesn't work... happened with other jobs too... seems like something on travis 🤔 
caarlos0: (already rebase but feel free to check if I didn't break anything 🙏)
caarlos0: BTW now that I did a commit, I can see the logs 🤣 
radeksimko: I will fix the two typos in a sec

> www/content/build.md:144:11: "explicitely" is a misspelling of "explicitly"
> www/content/build.md:194:40: "overriden" is a misspelling of "overridden"
caarlos0: Thanks!

Patch:
diff --git a/internal/pipe/build/build.go b/internal/pipe/build/build.go
index d1193fd6..18b79151 100644
--- a/internal/pipe/build/build.go
+++ b/internal/pipe/build/build.go
@@ -76,43 +76,84 @@ func buildWithDefaults(ctx *context.Context, build config.Build) config.Build {
 }
 
 func runPipeOnBuild(ctx *context.Context, build config.Build) error {
-	if err := runHook(ctx, build.Env, build.Hooks.Pre); err != nil {
-		return errors.Wrap(err, "pre hook failed")
-	}
 	var g = semerrgroup.New(ctx.Parallelism)
 	for _, target := range build.Targets {
 		target := target
 		build := build
 		g.Go(func() error {
-			return doBuild(ctx, build, target)
+			opts, err := buildOptionsForTarget(ctx, build, target)
+			if err != nil {
+				return err
+			}
+
+			if err := runHook(ctx, *opts, build.Env, build.Hooks.Pre); err != nil {
+				return errors.Wrap(err, "pre hook failed")
+			}
+			if err := doBuild(ctx, build, *opts); err != nil {
+				return err
+			}
+			if err := runHook(ctx, *opts, build.Env, build.Hooks.Post); err != nil {
+				return errors.Wrap(err, "post hook failed")
+			}
+			return nil
 		})
 	}
-	if err := g.Wait(); err != nil {
-		return err
-	}
-	return errors.Wrap(runHook(ctx, build.Env, build.Hooks.Post), "post hook failed")
+
+	return g.Wait()
 }
 
-func runHook(ctx *context.Context, env []string, hook string) error {
-	if hook == "" {
+func runHook(ctx *context.Context, opts builders.Options, buildEnv []string, hooks config.BuildHooks) error {
+	if len(hooks) == 0 {
 		return nil
 	}
-	sh, err := tmpl.New(ctx).WithEnvS(env).Apply(hook)
-	if err != nil {
-		return err
+
+	for _, hook := range hooks {
+		var env []string
+
+		env = append(env, ctx.Env.Strings()...)
+		env = append(env, buildEnv...)
+
+		for _, rawEnv := range hook.Env {
+			e, err := tmpl.New(ctx).WithBuildOptions(opts).Apply(rawEnv)
+			if err != nil {
+				return err
+			}
+			env = append(env, e)
+		}
+
+		dir, err := tmpl.New(ctx).WithBuildOptions(opts).Apply(hook.Dir)
+		if err != nil {
+			return err
+		}
+
+		sh, err := tmpl.New(ctx).WithBuildOptions(opts).
+			WithEnvS(env).
+			Apply(hook.Cmd)
+		if err != nil {
+			return err
+		}
+
+		log.WithField("hook", sh).Info("running hook")
+		cmd := strings.Fields(sh)
+
+		if err := run(ctx, dir, cmd, env); err != nil {
+			return err
+		}
 	}
-	log.WithField("hook", sh).Info("running hook")
-	cmd := strings.Fields(sh)
-	env = append(env, ctx.Env.Strings()...)
-	return run(ctx, cmd, env)
+
+	return nil
 }
 
-func doBuild(ctx *context.Context, build config.Build, target string) error {
+func doBuild(ctx *context.Context, build config.Build, opts builders.Options) error {
+	return builders.For(build.Lang).Build(ctx, build, opts)
+}
+
+func buildOptionsForTarget(ctx *context.Context, build config.Build, target string) (*builders.Options, error) {
 	var ext = extFor(target, build.Flags)
 
 	binary, err := tmpl.New(ctx).Apply(build.Binary)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	build.Binary = binary
@@ -125,15 +166,15 @@ func doBuild(ctx *context.Context, build config.Build, target string) error {
 		),
 	)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	log.WithField("binary", path).Info("building")
-	return builders.For(build.Lang).Build(ctx, build, builders.Options{
+	return &builders.Options{
 		Target: target,
 		Name:   name,
 		Path:   path,
 		Ext:    ext,
-	})
+	}, nil
 }
 
 func extFor(target string, flags config.FlagArray) string {
@@ -154,11 +195,14 @@ func extFor(target string, flags config.FlagArray) string {
 	return ""
 }
 
-func run(ctx *context.Context, command, env []string) error {
+func run(ctx *context.Context, dir string, command, env []string) error {
 	/* #nosec */
 	var cmd = exec.CommandContext(ctx, command[0], command[1:]...)
 	var log = log.WithField("env", env).WithField("cmd", command)
 	cmd.Env = env
+	if dir != "" {
+		cmd.Dir = dir
+	}
 	log.Debug("running")
 	if out, err := cmd.CombinedOutput(); err != nil {
 		log.WithError(err).Debug("failed")
diff --git a/internal/tmpl/tmpl.go b/internal/tmpl/tmpl.go
index e3f6ee19..e8da6260 100644
--- a/internal/tmpl/tmpl.go
+++ b/internal/tmpl/tmpl.go
@@ -3,11 +3,13 @@ package tmpl
 
 import (
 	"bytes"
+	"path/filepath"
 	"strings"
 	"text/template"
 	"time"
 
 	"github.com/goreleaser/goreleaser/internal/artifact"
+	"github.com/goreleaser/goreleaser/pkg/build"
 	"github.com/goreleaser/goreleaser/pkg/context"
 )
 
@@ -42,8 +44,15 @@ const (
 	mips         = "Mips"
 	binary       = "Binary"
 	artifactName = "ArtifactName"
+
 	// gitlab only
 	artifactUploadHash = "ArtifactUploadHash"
+
+	// build keys
+	name   = "Name"
+	ext    = "Ext"
+	path   = "Path"
+	target = "Target"
 )
 
 // New Template
@@ -114,6 +123,19 @@ func (t *Template) WithArtifact(a *artifact.Artifact, replacements map[string]st
 	return t
 }
 
+func (t *Template) WithBuildOptions(opts build.Options) *Template {
+	return t.WithExtraFields(buildOptsToFields(opts))
+}
+
+func buildOptsToFields(opts build.Options) Fields {
+	return Fields{
+		target: opts.Target,
+		ext:    opts.Ext,
+		name:   opts.Name,
+		path:   opts.Path,
+	}
+}
+
 // Apply applies the given string against the Fields stored in the template.
 func (t *Template) Apply(s string) (string, error) {
 	var out bytes.Buffer
@@ -127,6 +149,7 @@ func (t *Template) Apply(s string) (string, error) {
 			"tolower": strings.ToLower,
 			"toupper": strings.ToUpper,
 			"trim":    strings.TrimSpace,
+			"dir":     filepath.Dir,
 		}).
 		Parse(s)
 	if err != nil {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 55fcada0..146e6150 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -151,7 +151,7 @@ type Build struct {
 	Ldflags  StringArray    `yaml:",omitempty"`
 	Flags    FlagArray      `yaml:",omitempty"`
 	Binary   string         `yaml:",omitempty"`
-	Hooks    Hooks          `yaml:",omitempty"`
+	Hooks    HookConfig     `yaml:",omitempty"`
 	Env      []string       `yaml:",omitempty"`
 	Lang     string         `yaml:",omitempty"`
 	Asmflags StringArray    `yaml:",omitempty"`
@@ -159,6 +159,54 @@ type Build struct {
 	Skip     bool           `yaml:",omitempty"`
 }
 
+type HookConfig struct {
+	Pre  BuildHooks `yaml:",omitempty"`
+	Post BuildHooks `yaml:",omitempty"`
+}
+
+type BuildHooks []BuildHook
+
+// UnmarshalYAML is a custom unmarshaler that allows simplified declaration of single command
+func (bhc *BuildHooks) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var singleCmd string
+	err := unmarshal(&singleCmd)
+	if err == nil {
+		*bhc = []BuildHook{{Cmd: singleCmd}}
+		return nil
+	}
+
+	type t BuildHooks
+	var hooks t
+	if err := unmarshal(&hooks); err != nil {
+		return err
+	}
+	*bhc = (BuildHooks)(hooks)
+	return nil
+}
+
+type BuildHook struct {
+	Dir string   `yaml:",omitempty"`
+	Cmd string   `yaml:",omitempty"`
+	Env []string `yaml:",omitempty"`
+}
+
+// UnmarshalYAML is a custom unmarshaler that allows simplified declarations of commands as strings
+func (bh *BuildHook) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var cmd string
+	if err := unmarshal(&cmd); err != nil {
+		type t BuildHook
+		var hook t
+		if err := unmarshal(&hook); err != nil {
+			return err
+		}
+		*bh = (BuildHook)(hook)
+		return nil
+	}
+
+	bh.Cmd = cmd
+	return nil
+}
+
 // FormatOverride is used to specify a custom format for a specific GOOS.
 type FormatOverride struct {
 	Goos   string `yaml:",omitempty"`
diff --git a/www/content/build.md b/www/content/build.md
index 660280b9..79e497b5 100644
--- a/www/content/build.md
+++ b/www/content/build.md
@@ -110,7 +110,7 @@ builds:
     # Default is both hooks empty.
     hooks:
       pre: rice embed-go
-      post: ./script.sh
+      post: ./script.sh {{ .Path }}
 
     # If true, skip the build.
     # Useful for library projects.
@@ -137,6 +137,66 @@ Then you can run:
 GOVERSION=$(go version) goreleaser
 ```
 
+## Build Hooks
+
+Both pre and post hooks run **for each build target**, regardless of whether
+these targets are generated via a matrix of OSes and architectures
+or defined explicitly.
+
+In addition to simple declarations as shown above _multiple_ hooks can be declared
+to help retaining reusability of config between different build environments.
+
+```yml
+builds:
+  -
+    id: "with-hooks"
+    targets:
+     - "darwin_amd64"
+     - "windows_amd64"
+    hooks:
+      pre:
+       - first-script.sh
+       - second-script.sh
+      post:
+       - upx "{{ .Path }}"
+       - codesign -project="{{ .ProjectName }}" "{{ .Path }}"
+```
+
+Each hook can also have its own work directory and environment variables:
+
+```yml
+builds:
+  -
+    id: "with-hooks"
+    targets:
+     - "darwin_amd64"
+     - "windows_amd64"
+    hooks:
+      pre:
+       - cmd: first-script.sh
+         dir: "{{ dir .Dist}}"
+         env:
+          - HOOK_SPECIFIC_VAR={{ .Env.GLOBAL_VAR }}
+       - second-script.sh
+```
+
+All properties of a hook (`cmd`, `dir` and `env`) support [templating](/customization/#Name%20Templates)
+with `post` hooks having binary artifact available (as these run _after_ the build).
+Additionally the following build details are exposed to both `pre` and `post` hooks:
+
+|       Key       |              Description               |
+| :-------------: | :------------------------------------: |
+|      .Name      | Filename of the binary, e.g. `bin.exe` |
+|      .Ext       | Extension, e.g. `.exe`                 |
+|      .Path      | Absolute path to the binary            |
+|     .Target     | Build target, e.g. `darwin_amd64`      |
+
+Environment variables are inherited and overridden in the following order:
+
+ - global (`env`)
+ - build (`builds[].env`)
+ - hook (`builds[].hooks.pre[].env` and `builds[].hooks.post[].env`)
+
 ## Go Modules
 
  If you use Go 1.11+ with go modules or vgo, when GoReleaser runs it may
diff --git a/www/content/templates.md b/www/content/templates.md
index 1f3e86bf..25eada2e 100644
--- a/www/content/templates.md
+++ b/www/content/templates.md
@@ -57,6 +57,7 @@ On all fields, you have these available functions:
 | `tolower "V1.2"`        | makes input string lowercase. See [ToLower](https://golang.org/pkg/strings/#ToLower)                     |
 | `toupper "v1.2"`        | makes input string uppercase. See [ToUpper](https://golang.org/pkg/strings/#ToUpper)                     |
 | `trim " v1.2  "`        | removes all leading and trailing white space. See [TrimSpace](https://golang.org/pkg/strings/#TrimSpace) |
+| `dir .Path`             | returns all but the last element of path, typically the path's directory. See [Dir](https://golang.org/pkg/path/filepath/#Dir)
 
 With all those fields, you may be able to compose the name of your artifacts
 pretty much the way you want:


Test patch:
diff --git a/internal/builders/golang/build_test.go b/internal/builders/golang/build_test.go
index 21376c50..aa76a30b 100644
--- a/internal/builders/golang/build_test.go
+++ b/internal/builders/golang/build_test.go
@@ -400,7 +400,7 @@ func TestRunPipeWithoutMainFunc(t *testing.T) {
 		Builds: []config.Build{
 			{
 				Binary: "no-main",
-				Hooks:  config.Hooks{},
+				Hooks:  config.HookConfig{},
 				Targets: []string{
 					runtimeTarget,
 				},
@@ -448,7 +448,7 @@ func TestRunPipeWithMainFuncNotInMainGoFile(t *testing.T) {
 			{
 				Env:    []string{"GO111MODULE=off"},
 				Binary: "foo",
-				Hooks:  config.Hooks{},
+				Hooks:  config.HookConfig{},
 				Targets: []string{
 					runtimeTarget,
 				},
diff --git a/internal/pipe/build/build_test.go b/internal/pipe/build/build_test.go
index d8579859..0dbf063d 100644
--- a/internal/pipe/build/build_test.go
+++ b/internal/pipe/build/build_test.go
@@ -79,7 +79,9 @@ func TestBuild(t *testing.T) {
 		Version: "1.2.3",
 		Config:  config,
 	}
-	error := doBuild(ctx, ctx.Config.Builds[0], "darwin_amd64")
+	opts, err := buildOptionsForTarget(ctx, ctx.Config.Builds[0], "darwin_amd64")
+	assert.NoError(t, err)
+	error := doBuild(ctx, ctx.Config.Builds[0], *opts)
 	assert.NoError(t, error)
 }
 
@@ -117,9 +119,13 @@ func TestRunFullPipe(t *testing.T) {
 				Binary:  "testing",
 				Flags:   []string{"-v"},
 				Ldflags: []string{"-X main.test=testing"},
-				Hooks: config.Hooks{
-					Pre:  "touch " + pre,
-					Post: "touch " + post,
+				Hooks: config.HookConfig{
+					Pre: []config.BuildHook{
+						{Cmd: "touch " + pre},
+					},
+					Post: []config.BuildHook{
+						{Cmd: "touch " + post},
+					},
 				},
 				Targets: []string{"whatever"},
 			},
@@ -148,9 +154,13 @@ func TestRunFullPipeFail(t *testing.T) {
 				Binary:  "testing",
 				Flags:   []string{"-v"},
 				Ldflags: []string{"-X main.test=testing"},
-				Hooks: config.Hooks{
-					Pre:  "touch " + pre,
-					Post: "touch " + post,
+				Hooks: config.HookConfig{
+					Pre: []config.BuildHook{
+						{Cmd: "touch " + pre},
+					},
+					Post: []config.BuildHook{
+						{Cmd: "touch " + post},
+					},
 				},
 				Targets: []string{"whatever"},
 			},
@@ -166,29 +176,29 @@ func TestRunFullPipeFail(t *testing.T) {
 func TestRunPipeFailingHooks(t *testing.T) {
 	folder, back := testlib.Mktmp(t)
 	defer back()
-	var config = config.Project{
+	var cfg = config.Project{
 		Dist: folder,
 		Builds: []config.Build{
 			{
 				Lang:    "fake",
 				Binary:  "hooks",
-				Hooks:   config.Hooks{},
+				Hooks:   config.HookConfig{},
 				Targets: []string{"whatever"},
 			},
 		},
 	}
 	t.Run("pre-hook", func(t *testing.T) {
-		var ctx = context.New(config)
+		var ctx = context.New(cfg)
 		ctx.Git.CurrentTag = "2.3.4"
-		ctx.Config.Builds[0].Hooks.Pre = "exit 1"
-		ctx.Config.Builds[0].Hooks.Post = "echo post"
+		ctx.Config.Builds[0].Hooks.Pre = []config.BuildHook{{Cmd: "exit 1"}}
+		ctx.Config.Builds[0].Hooks.Post = []config.BuildHook{{Cmd: "echo post"}}
 		assert.EqualError(t, Pipe{}.Run(ctx), `pre hook failed: "": exec: "exit": executable file not found in $PATH`)
 	})
 	t.Run("post-hook", func(t *testing.T) {
-		var ctx = context.New(config)
+		var ctx = context.New(cfg)
 		ctx.Git.CurrentTag = "2.3.4"
-		ctx.Config.Builds[0].Hooks.Pre = "echo pre"
-		ctx.Config.Builds[0].Hooks.Post = "exit 1"
+		ctx.Config.Builds[0].Hooks.Pre = []config.BuildHook{{Cmd: "echo pre"}}
+		ctx.Config.Builds[0].Hooks.Post = []config.BuildHook{{Cmd: "exit 1"}}
 		assert.EqualError(t, Pipe{}.Run(ctx), `post hook failed: "": exec: "exit": executable file not found in $PATH`)
 	})
 }
@@ -395,7 +405,7 @@ func TestTemplate(t *testing.T) {
 	assert.Contains(t, binary, `-X "main.foo=123"`)
 }
 
-func TestHookEnvs(t *testing.T) {
+func TestRunHookEnvs(t *testing.T) {
 	tmp, back := testlib.Mktmp(t)
 	defer back()
 
@@ -406,26 +416,166 @@ func TestHookEnvs(t *testing.T) {
 		},
 	}
 
-	t.Run("valid template", func(t *testing.T) {
+	var opts = api.Options{
+		Name:   "binary-name",
+		Path:   "./binary-name",
+		Target: "darwin_amd64",
+	}
+
+	simpleHook := func(cmd string) config.BuildHooks {
+		return []config.BuildHook{{Cmd: cmd}}
+	}
+
+	t.Run("valid cmd template with ctx env", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+			Env: []string{
+				fmt.Sprintf("CTXFOO=%s/foo", tmp),
+			},
+		}), opts, []string{}, simpleHook("touch {{ .Env.CTXFOO }}"))
+		assert.NoError(t, err)
+		assert.FileExists(t, filepath.Join(tmp, "foo"))
+	})
+
+	t.Run("valid cmd template with build env", func(t *testing.T) {
 		var err = runHook(context.New(config.Project{
 			Builds: []config.Build{
 				build,
 			},
-		}), build.Env, "touch {{ .Env.FOO }}")
+		}), opts, build.Env, simpleHook("touch {{ .Env.FOO }}"))
 		assert.NoError(t, err)
 		assert.FileExists(t, filepath.Join(tmp, "foo"))
 	})
 
-	t.Run("invalid template", func(t *testing.T) {
+	t.Run("valid cmd template with hook env", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+		}), opts, []string{}, []config.BuildHook{{
+			Cmd: "touch {{ .Env.HOOK_ONLY_FOO }}",
+			Env: []string{
+				fmt.Sprintf("HOOK_ONLY_FOO=%s/hook_only", tmp),
+			},
+		}})
+		assert.NoError(t, err)
+		assert.FileExists(t, filepath.Join(tmp, "hook_only"))
+	})
+
+	t.Run("valid cmd template with ctx and build env", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+			Env: []string{
+				fmt.Sprintf("OVER_FOO=%s/ctx_over_build", tmp),
+			},
+		}), opts, []string{
+			fmt.Sprintf("OVER_FOO=%s/build_over_ctx", tmp),
+		}, simpleHook("touch {{ .Env.OVER_FOO }}"))
+		assert.NoError(t, err)
+
+		assert.FileExists(t, filepath.Join(tmp, "build_over_ctx"))
+		assert.NoFileExists(t, filepath.Join(tmp, "ctx_over_build"))
+	})
+
+	t.Run("valid cmd template with ctx and hook env", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+			Env: []string{
+				fmt.Sprintf("CTX_OR_HOOK_FOO=%s/ctx_over_hook", tmp),
+			},
+		}), opts, []string{}, []config.BuildHook{{
+			Cmd: "touch {{ .Env.CTX_OR_HOOK_FOO }}",
+			Env: []string{
+				fmt.Sprintf("CTX_OR_HOOK_FOO=%s/hook_over_ctx", tmp),
+			},
+		}})
+		assert.NoError(t, err)
+		assert.FileExists(t, filepath.Join(tmp, "hook_over_ctx"))
+		assert.NoFileExists(t, filepath.Join(tmp, "ctx_over_hook"))
+	})
+
+	t.Run("valid cmd template with build and hook env", func(t *testing.T) {
 		var err = runHook(context.New(config.Project{
 			Builds: []config.Build{
 				build,
 			},
-		}), build.Env, "touch {{ .Env.FOOss }}")
+		}), opts, []string{
+			fmt.Sprintf("BUILD_OR_HOOK_FOO=%s/build_over_hook", tmp),
+		}, []config.BuildHook{{
+			Cmd: "touch {{ .Env.BUILD_OR_HOOK_FOO }}",
+			Env: []string{
+				fmt.Sprintf("BUILD_OR_HOOK_FOO=%s/hook_over_build", tmp),
+			},
+		}})
+		assert.NoError(t, err)
+		assert.FileExists(t, filepath.Join(tmp, "hook_over_build"))
+		assert.NoFileExists(t, filepath.Join(tmp, "build_over_hook"))
+	})
+
+	t.Run("valid cmd template with ctx, build and hook env", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+			Env: []string{
+				fmt.Sprintf("CTX_OR_BUILD_OR_HOOK_FOO=%s/ctx_wins", tmp),
+			},
+		}), opts, []string{
+			fmt.Sprintf("CTX_OR_BUILD_OR_HOOK_FOO=%s/build_wins", tmp),
+		}, []config.BuildHook{{
+			Cmd: "touch {{ .Env.CTX_OR_BUILD_OR_HOOK_FOO }}",
+			Env: []string{
+				fmt.Sprintf("CTX_OR_BUILD_OR_HOOK_FOO=%s/hook_wins", tmp),
+			},
+		}})
+		assert.NoError(t, err)
+		assert.FileExists(t, filepath.Join(tmp, "hook_wins"))
+		assert.NoFileExists(t, filepath.Join(tmp, "ctx_wins"))
+		assert.NoFileExists(t, filepath.Join(tmp, "build_wins"))
+	})
+
+	t.Run("invalid cmd template", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+		}), opts, build.Env, simpleHook("touch {{ .Env.FOOss }}"))
 		assert.EqualError(t, err, `template: tmpl:1:13: executing "tmpl" at <.Env.FOOss>: map has no entry for key "FOOss"`)
 	})
 
-	t.Run("env inside shell", func(t *testing.T) {
+	t.Run("invalid dir template", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+		}), opts, build.Env, []config.BuildHook{{
+			Cmd: "echo something",
+			Dir: "{{ .Env.INVALID_ENV }}",
+		}})
+		assert.EqualError(t, err, `template: tmpl:1:7: executing "tmpl" at <.Env.INVALID_ENV>: map has no entry for key "INVALID_ENV"`)
+	})
+
+	t.Run("invalid hook env template", func(t *testing.T) {
+		var err = runHook(context.New(config.Project{
+			Builds: []config.Build{
+				build,
+			},
+		}), opts, build.Env, []config.BuildHook{{
+			Cmd: "echo something",
+			Env: []string{
+				"TEST={{ .Env.MISSING_ENV }}",
+			},
+		}})
+		assert.EqualError(t, err, `template: tmpl:1:12: executing "tmpl" at <.Env.MISSING_ENV>: map has no entry for key "MISSING_ENV"`)
+	})
+
+	t.Run("build env inside shell", func(t *testing.T) {
 		var shell = `#!/bin/sh -e
 touch "$BAR"`
 		err := ioutil.WriteFile(filepath.Join(tmp, "test.sh"), []byte(shell), 0750)
@@ -434,8 +584,87 @@ touch "$BAR"`
 			Builds: []config.Build{
 				build,
 			},
-		}), build.Env, "sh test.sh")
+		}), opts, build.Env, simpleHook("sh test.sh"))
 		assert.NoError(t, err)
 		assert.FileExists(t, filepath.Join(tmp, "bar"))
 	})
 }
+
+func TestPipeOnBuild_hooksRunPerTarget(t *testing.T) {
+	tmpDir, back := testlib.Mktmp(t)
+	defer back()
+
+	build := config.Build{
+		Lang:   "fake",
+		Binary: "testing.v{{.Version}}",
+		Targets: []string{
+			"linux_amd64",
+			"darwin_amd64",
+			"windows_amd64",
+		},
+		Hooks: config.HookConfig{
+			Pre: []config.BuildHook{
+				{Cmd: "touch pre-hook-{{.Target}}", Dir: tmpDir},
+			},
+			Post: config.BuildHooks{
+				{Cmd: "touch post-hook-{{.Target}}", Dir: tmpDir},
+			},
+		},
+	}
+	ctx := context.New(config.Project{
+		Builds: []config.Build{
+			build,
+		},
+	})
+	err := runPipeOnBuild(ctx, build)
+	assert.NoError(t, err)
+	assert.FileExists(t, filepath.Join(tmpDir, "pre-hook-linux_amd64"))
+	assert.FileExists(t, filepath.Join(tmpDir, "pre-hook-darwin_amd64"))
+	assert.FileExists(t, filepath.Join(tmpDir, "pre-hook-windows_amd64"))
+	assert.FileExists(t, filepath.Join(tmpDir, "post-hook-linux_amd64"))
+	assert.FileExists(t, filepath.Join(tmpDir, "post-hook-darwin_amd64"))
+	assert.FileExists(t, filepath.Join(tmpDir, "post-hook-windows_amd64"))
+}
+
+func TestPipeOnBuild_invalidBinaryTpl(t *testing.T) {
+	build := config.Build{
+		Lang:   "fake",
+		Binary: "testing.v{{.XYZ}}",
+		Targets: []string{
+			"linux_amd64",
+		},
+	}
+	ctx := context.New(config.Project{
+		Builds: []config.Build{
+			build,
+		},
+	})
+	err := runPipeOnBuild(ctx, build)
+	assert.EqualError(t, err, `template: tmpl:1:11: executing "tmpl" at <.XYZ>: map has no entry for key "XYZ"`)
+}
+
+func TestBuildOptionsForTarget(t *testing.T) {
+	tmpDir, back := testlib.Mktmp(t)
+	defer back()
+
+	build := config.Build{
+		ID:     "testid",
+		Binary: "testbinary",
+		Targets: []string{
+			"linux_amd64",
+			"darwin_amd64",
+			"windows_amd64",
+		},
+	}
+	ctx := context.New(config.Project{
+		Dist:   tmpDir,
+		Builds: []config.Build{build},
+	})
+	opts, err := buildOptionsForTarget(ctx, build, "linux_amd64")
+	assert.NoError(t, err)
+	assert.Equal(t, &api.Options{
+		Name:   "testbinary",
+		Path:   filepath.Join(tmpDir, "testid_linux_amd64", "testbinary"),
+		Target: "linux_amd64",
+	}, opts)
+}
diff --git a/pkg/config/config_build_hook_test.go b/pkg/config/config_build_hook_test.go
new file mode 100644
index 00000000..355e2328
--- /dev/null
+++ b/pkg/config/config_build_hook_test.go
@@ -0,0 +1,55 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	yaml "gopkg.in/yaml.v2"
+)
+
+func TestBuildHook_justString(t *testing.T) {
+	var actual HookConfig
+
+	err := yaml.UnmarshalStrict([]byte(`pre: ./script.sh`), &actual)
+	assert.NoError(t, err)
+	assert.Equal(t, BuildHook{
+		Cmd: "./script.sh",
+		Env: nil,
+	}, actual.Pre[0])
+}
+
+func TestBuildHook_stringCmds(t *testing.T) {
+	var actual HookConfig
+
+	err := yaml.UnmarshalStrict([]byte(`pre:
+ - ./script.sh
+ - second-script.sh
+`), &actual)
+	assert.NoError(t, err)
+
+	assert.Equal(t, BuildHooks{
+		{
+			Cmd: "./script.sh",
+			Env: nil,
+		},
+		{
+			Cmd: "second-script.sh",
+			Env: nil,
+		},
+	}, actual.Pre)
+}
+
+func TestBuildHook_complex(t *testing.T) {
+	var actual HookConfig
+
+	err := yaml.UnmarshalStrict([]byte(`pre:
+ - cmd: ./script.sh
+   env:
+    - TEST=value
+`), &actual)
+	assert.NoError(t, err)
+	assert.Equal(t, BuildHook{
+		Cmd: "./script.sh",
+		Env: []string{"TEST=value"},
+	}, actual.Pre[0])
+}