diff --git a/.goreleaser.yml b/.goreleaser.yml index 316fb5e32..1f1f0f286 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -13,6 +13,8 @@ builds: - arm64 checksum: name_template: '{{ .ProjectName }}_checksums.txt' +dockers: + - image: goreleaser/goreleaser archive: name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' replacements: diff --git a/.travis.yml b/.travis.yml index c3fe5822c..8f1935a6c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,11 @@ dist: trusty sudo: required language: go go: 1.8.3 +services: + - docker install: - make setup + - docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" - gem install fpm - sudo apt-get update - sudo apt-get install --yes snapd rpm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..b7dcde4c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM scratch +COPY goreleaser /goreleaser +ENTRYPOINT ["/goreleaser"] + diff --git a/config/config.go b/config/config.go index 57711863c..995657c37 100644 --- a/config/config.go +++ b/config/config.go @@ -159,6 +159,19 @@ type Checksum struct { XXX map[string]interface{} `yaml:",inline"` } +// Docker image config +type Docker struct { + Binary string `yaml:",omitempty"` + Goos string `yaml:",omitempty"` + Goarch string `yaml:",omitempty"` + Goarm string `yaml:",omitempty"` + Image string `yaml:",omitempty"` + Dockerfile string `yaml:",omitempty"` + + // Capture all undefined fields and should be empty after loading + XXX map[string]interface{} `yaml:",inline"` +} + // Project includes all project configuration type Project struct { ProjectName string `yaml:"project_name,omitempty"` @@ -170,6 +183,7 @@ type Project struct { Snapcraft Snapcraft `yaml:",omitempty"` Snapshot Snapshot `yaml:",omitempty"` Checksum Checksum `yaml:",omitempty"` + Dockers []Docker `yaml:",omitempty"` // this is a hack ¯\_(ツ)_/¯ SingleBuild Build `yaml:"build,omitempty"` @@ -231,6 +245,9 @@ func checkOverflows(config Project) error { } overflow.check(config.Snapshot.XXX, "snapshot") overflow.check(config.Checksum.XXX, "checksum") + for i, docker := range config.Dockers { + overflow.check(docker.XXX, fmt.Sprintf("docker[%d]", i)) + } return overflow.err() } diff --git a/config/config_test.go b/config/config_test.go index 81831bea3..224d3e721 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -58,7 +58,7 @@ func TestFileNotFound(t *testing.T) { func TestInvalidFields(t *testing.T) { var assert = assert.New(t) _, err := Load("testdata/invalid_config.yml") - assert.EqualError(err, "unknown fields in the config file: invalid_root, archive.invalid_archive, archive.format_overrides[0].invalid_archive_fmtoverrides, brew.invalid_brew, brew.github.invalid_brew_github, builds[0].invalid_builds, builds[0].hooks.invalid_builds_hooks, builds[0].ignored_builds[0].invalid_builds_ignore, fpm.invalid_fpm, release.invalid_release, release.github.invalid_release_github, build.invalid_build, builds.hooks.invalid_build_hook, builds.ignored_builds[0].invalid_build_ignore, snapshot.invalid_snapshot") + assert.EqualError(err, "unknown fields in the config file: invalid_root, archive.invalid_archive, archive.format_overrides[0].invalid_archive_fmtoverrides, brew.invalid_brew, brew.github.invalid_brew_github, builds[0].invalid_builds, builds[0].hooks.invalid_builds_hooks, builds[0].ignored_builds[0].invalid_builds_ignore, fpm.invalid_fpm, release.invalid_release, release.github.invalid_release_github, build.invalid_build, builds.hooks.invalid_build_hook, builds.ignored_builds[0].invalid_build_ignore, snapshot.invalid_snapshot, docker[0].invalid_docker") } func TestInvalidYaml(t *testing.T) { diff --git a/config/testdata/invalid_config.yml b/config/testdata/invalid_config.yml index c8b0223bb..a8c96ca3c 100644 --- a/config/testdata/invalid_config.yml +++ b/config/testdata/invalid_config.yml @@ -27,4 +27,5 @@ fpm: invalid_fpm: 1 snapshot: invalid_snapshot: 1 - +dockers: + - invalid_docker: 1 diff --git a/goreleaserlib/goreleaser.go b/goreleaserlib/goreleaser.go index c1f1f5e73..614199c75 100644 --- a/goreleaserlib/goreleaser.go +++ b/goreleaserlib/goreleaser.go @@ -16,6 +16,7 @@ import ( "github.com/goreleaser/goreleaser/pipeline/checksums" "github.com/goreleaser/goreleaser/pipeline/cleandist" "github.com/goreleaser/goreleaser/pipeline/defaults" + "github.com/goreleaser/goreleaser/pipeline/docker" "github.com/goreleaser/goreleaser/pipeline/env" "github.com/goreleaser/goreleaser/pipeline/fpm" "github.com/goreleaser/goreleaser/pipeline/git" @@ -35,6 +36,7 @@ var pipes = []pipeline.Pipe{ snapcraft.Pipe{}, // archive via snapcraft (snap) checksums.Pipe{}, // checksums of the files release.Pipe{}, // release to github + docker.Pipe{}, // create and push docker images brew.Pipe{}, // push to brew tap } @@ -96,9 +98,8 @@ func handle(err error) error { if err == nil { return nil } - skip, ok := err.(pipeline.ErrSkip) - if ok { - log.WithField("reason", skip.Error()).Warn("skipped") + if pipeline.IsSkip(err) { + log.WithField("reason", err.Error()).Warn("skipped") return nil } return err diff --git a/pipeline/defaults/defaults.go b/pipeline/defaults/defaults.go index 5f6a8070c..b3715f7c6 100644 --- a/pipeline/defaults/defaults.go +++ b/pipeline/defaults/defaults.go @@ -57,6 +57,21 @@ func (Pipe) Run(ctx *context.Context) error { } ctx.Config.Brew.Install = strings.Join(installs, "\n") } + if len(ctx.Config.Dockers) == 1 { + if ctx.Config.Dockers[0].Goos == "" { + ctx.Config.Dockers[0].Goos = "linux" + } + if ctx.Config.Dockers[0].Goarch == "" { + ctx.Config.Dockers[0].Goarch = "amd64" + } + if ctx.Config.Dockers[0].Binary == "" { + ctx.Config.Dockers[0].Binary = ctx.Config.Builds[0].Binary + } + if ctx.Config.Dockers[0].Dockerfile == "" { + ctx.Config.Dockers[0].Dockerfile = "Dockerfile" + } + } + err := setArchiveDefaults(ctx) log.WithField("config", ctx.Config).Debug("defaults set") return err diff --git a/pipeline/defaults/defaults_test.go b/pipeline/defaults/defaults_test.go index 9d4ff3b49..0377a4ee7 100644 --- a/pipeline/defaults/defaults_test.go +++ b/pipeline/defaults/defaults_test.go @@ -32,6 +32,7 @@ func TestFillBasicData(t *testing.T) { assert.Contains(ctx.Config.Builds[0].Goarch, "amd64") assert.Equal("tar.gz", ctx.Config.Archive.Format) assert.Contains(ctx.Config.Brew.Install, "bin.install \"goreleaser\"") + assert.Empty(ctx.Config.Dockers) assert.NotEmpty( ctx.Config.Archive.NameTemplate, ctx.Config.Builds[0].Ldflags, @@ -65,11 +66,19 @@ func TestFillPartial(t *testing.T) { }, }, }, + Dockers: []config.Docker{ + {Image: "a/b"}, + }, }, } assert.NoError(Pipe{}.Run(ctx)) assert.Len(ctx.Config.Archive.Files, 1) assert.Equal(`bin.install "testreleaser"`, ctx.Config.Brew.Install) + assert.NotEmpty(ctx.Config.Dockers[0].Binary) + assert.NotEmpty(ctx.Config.Dockers[0].Goos) + assert.NotEmpty(ctx.Config.Dockers[0].Goarch) + assert.NotEmpty(ctx.Config.Dockers[0].Dockerfile) + assert.Empty(ctx.Config.Dockers[0].Goarm) } func TestFillSingleBuild(t *testing.T) { diff --git a/pipeline/docker/docker.go b/pipeline/docker/docker.go new file mode 100644 index 000000000..a1343a336 --- /dev/null +++ b/pipeline/docker/docker.go @@ -0,0 +1,108 @@ +// Package docker provides a Pipe that creates and pushes a Docker image +package docker + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/goreleaser/goreleaser/config" + "github.com/goreleaser/goreleaser/context" + "github.com/goreleaser/goreleaser/pipeline" + + "github.com/apex/log" + + "github.com/pkg/errors" +) + +// ErrNoDocker is shown when docker cannot be found in $PATH +var ErrNoDocker = errors.New("docker not present in $PATH") + +// Pipe for docker +type Pipe struct{} + +// Description of the pipe +func (Pipe) Description() string { + return "Creating Docker images" +} + +// Run the pipe +func (Pipe) Run(ctx *context.Context) error { + if len(ctx.Config.Dockers) == 0 || ctx.Config.Dockers[0].Image == "" { + return pipeline.Skip("docker section is not configured") + } + _, err := exec.LookPath("docker") + if err != nil { + return ErrNoDocker + } + for _, docker := range ctx.Config.Dockers { + var imagePlatform = docker.Goos + docker.Goarch + docker.Goarm + for platform, groups := range ctx.Binaries { + if platform != imagePlatform { + continue + } + for folder, binaries := range groups { + for _, binary := range binaries { + if binary.Name != docker.Binary { + continue + } + var err = doRun(ctx, folder, docker, binary) + if err != nil && !pipeline.IsSkip(err) { + return err + } + } + } + } + } + return nil +} + +func doRun(ctx *context.Context, folder string, docker config.Docker, binary context.Binary) error { + var root = filepath.Join(ctx.Config.Dist, folder) + var dockerfile = filepath.Join(root, filepath.Base(docker.Dockerfile)) + var image = fmt.Sprintf("%s:%s", docker.Image, ctx.Git.CurrentTag) + + if err := os.Link(docker.Dockerfile, dockerfile); err != nil { + return errors.Wrap(err, "failed to link dockerfile") + } + if err := dockerBuild(root, dockerfile, image); err != nil { + return err + } + + // TODO: improve this so it can log into to stdout + if !ctx.Publish { + return pipeline.Skip("--skip-publish is set") + } + if ctx.Config.Release.Draft { + return pipeline.Skip("release is marked as draft") + } + if err := dockerPush(image); err != nil { + return err + } + return nil +} + +func dockerBuild(root, dockerfile, image string) error { + log.WithField("image", image).Info("building docker image") + var cmd = exec.Command("docker", "build", "-f", dockerfile, "-t", image, root) + log.WithField("cmd", cmd).Debug("executing") + out, err := cmd.CombinedOutput() + if err != nil { + return errors.Wrapf(err, "failed to build docker image: \n%s", string(out)) + } + log.Debugf("docker build output: \n%s", string(out)) + return nil +} + +func dockerPush(image string) error { + log.WithField("image", image).Info("pushing docker image") + var cmd = exec.Command("docker", "push", image) + log.WithField("cmd", cmd).Debug("executing") + out, err := cmd.CombinedOutput() + if err != nil { + return errors.Wrapf(err, "failed to push docker image: \n%s", string(out)) + } + log.Debugf("docker push output: \n%s", string(out)) + return nil +} diff --git a/pipeline/docker/docker_test.go b/pipeline/docker/docker_test.go new file mode 100644 index 000000000..47aea0cdc --- /dev/null +++ b/pipeline/docker/docker_test.go @@ -0,0 +1,110 @@ +package docker + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/goreleaser/goreleaser/config" + "github.com/goreleaser/goreleaser/context" + "github.com/goreleaser/goreleaser/pipeline" + + "github.com/stretchr/testify/assert" +) + +func TestRunPipe(t *testing.T) { + var assert = assert.New(t) + folder, err := ioutil.TempDir("", "archivetest") + assert.NoError(err) + var dist = filepath.Join(folder, "dist") + assert.NoError(os.Mkdir(dist, 0755)) + assert.NoError(os.Mkdir(filepath.Join(dist, "mybin"), 0755)) + var binPath = filepath.Join(dist, "mybin", "mybin") + _, err = os.Create(binPath) + assert.NoError(err) + // this might fail as the image doesnt exist yet, so lets ignore the error + _ = exec.Command("docker", "rmi", "goreleaser/test_run_pipe:v1.0.0").Run() + var ctx = &context.Context{ + Git: context.GitInfo{ + CurrentTag: "v1.0.0", + }, + Publish: true, + Config: config.Project{ + ProjectName: "mybin", + Dist: dist, + Dockers: []config.Docker{ + { + Image: "goreleaser/test_run_pipe", + Goos: "linux", + Goarch: "amd64", + Dockerfile: "testdata/Dockerfile", + Binary: "mybin", + }, + { + Image: "goreleaser/test_run_pipe_nope", + Goos: "linux", + Goarch: "amd64", + Dockerfile: "testdata/Dockerfile", + Binary: "otherbin", + }, + }, + }, + } + for _, plat := range []string{"linuxamd64", "linux386", "darwinamd64"} { + ctx.AddBinary(plat, "mybin", "mybin", binPath) + } + assert.NoError(Pipe{}.Run(ctx)) + // this might should not fail as the image should have been created when + // the step ran + assert.NoError( + exec.Command("docker", "rmi", "goreleaser/test_run_pipe:v1.0.0").Run(), + ) + // the test_run_pipe_nope image should not have been created, so deleting + // it should fail + assert.Error( + exec.Command("docker", "rmi", "goreleaser/test_run_pipe_nope:v1.0.0").Run(), + ) +} + +func TestDescription(t *testing.T) { + var assert = assert.New(t) + assert.NotEmpty(Pipe{}.Description()) +} + +func TestNoDockers(t *testing.T) { + var assert = assert.New(t) + assert.True(pipeline.IsSkip(Pipe{}.Run(context.New(config.Project{})))) +} + +func TestNoDockerWithoutImageName(t *testing.T) { + var assert = assert.New(t) + assert.True(pipeline.IsSkip(Pipe{}.Run(context.New(config.Project{ + Dockers: []config.Docker{ + { + Goos: "linux", + }, + }, + })))) +} + +func TestDockerNotInPath(t *testing.T) { + var assert = assert.New(t) + var path = os.Getenv("PATH") + defer func() { + assert.NoError(os.Setenv("PATH", path)) + }() + assert.NoError(os.Setenv("PATH", "")) + var ctx = &context.Context{ + Version: "1.0.0", + Config: config.Project{ + Dockers: []config.Docker{ + { + Image: "a/b", + }, + }, + }, + } + assert.EqualError(Pipe{}.Run(ctx), ErrNoDocker.Error()) +} diff --git a/pipeline/docker/testdata/Dockerfile b/pipeline/docker/testdata/Dockerfile new file mode 100644 index 000000000..cf012c074 --- /dev/null +++ b/pipeline/docker/testdata/Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +ADD mybin / diff --git a/pipeline/pipe.go b/pipeline/pipe.go index 48da97c5c..f09b385aa 100644 --- a/pipeline/pipe.go +++ b/pipeline/pipe.go @@ -12,6 +12,12 @@ type Pipe interface { Run(ctx *context.Context) error } +// IsSkip returns true if the error is an ErrSkip +func IsSkip(err error) bool { + _, ok := err.(ErrSkip) + return ok +} + // ErrSkip occurs when a pipe is skipped for some reason type ErrSkip struct { reason string diff --git a/pipeline/pipe_test.go b/pipeline/pipe_test.go index c7a332efe..67be2f174 100644 --- a/pipeline/pipe_test.go +++ b/pipeline/pipe_test.go @@ -1,6 +1,7 @@ package pipeline import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -13,3 +14,9 @@ func TestSkipPipe(t *testing.T) { assert.Error(err) assert.Equal(reason, err.Error()) } + +func TestIsSkip(t *testing.T) { + var assert = assert.New(t) + assert.True(IsSkip(Skip("whatever"))) + assert.False(IsSkip(errors.New("nope"))) +}