Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,28 @@ This configuration will use the same naming, but place all binaries in a `bin/`
An `output` configuration must have all three `${TARGET}`, `${GOOS}`, `${GOARCH}`
placeholders present, but the ordering can change.

Windows, as a special case, will always have ".exe" appended to the filename.
Windows, as a special case, will always have ".exe" appended to the filename of a raw binary.

The `TARGET` placeholder expands to the default build target name that `go build` would produce.
The `GOOS` placeholder is expands to the `GOOS` under build.
The `GOARCH` placeholder expands to the `GOARCH` under build.

Only a single `output` directive may be found in a package.

## Output formats

multibuild can produce several types of output.

`//go:multibuild:format=raw,zip,tar.gz`

The list of formats is comma separated, and any of the following are supported:

* `raw` - The default, the raw binary produced by `go build`.
* `zip` - A zip archive of the raw binary.
* `tar.gz` - A tar.gz'd archive of the raw binary.

Only a single `format` directive may be found in a package.

# Differences to `go build`

As multibuild is a wrapper around `go build`, most of the behaviour you will see come from there.
Expand Down
78 changes: 78 additions & 0 deletions cmd/multibuild/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ func main() {
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=raw
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
Expand All @@ -124,6 +125,7 @@ func main() {
expectedConfig: `//go:multibuild:include=*/arm64
//go:multibuild:exclude=android/arm64,darwin/arm64,freebsd/arm64,ios/arm64,netbsd/arm64,openbsd/arm64,windows/arm64,android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=raw
`,
expectedTargets: "linux/arm64\n",
},
Expand All @@ -139,6 +141,75 @@ func main() {
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=bin/${TARGET}-hello-${GOOS}-world-${GOARCH}
//go:multibuild:format=raw
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
{
name: "format=raw",
config: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:format=raw
`,
expectedBinaries: []string{
"${TARGET}-linux-amd64",
"${TARGET}-linux-arm64",
},
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=raw
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
{
name: "format=zip",
config: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:format=zip
`,
expectedBinaries: []string{
"${TARGET}-linux-amd64.zip",
"${TARGET}-linux-arm64.zip",
},
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=zip
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
{
name: "format=tar.gz",
config: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:format=tar.gz
`,
expectedBinaries: []string{
"${TARGET}-linux-amd64.tar.gz",
"${TARGET}-linux-arm64.tar.gz",
},
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=tar.gz
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
{
name: "format=raw,zip,tar.gz",
config: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:format=raw,zip,tar.gz
`,
expectedBinaries: []string{
"${TARGET}-linux-amd64",
"${TARGET}-linux-arm64",
"${TARGET}-linux-amd64.zip",
"${TARGET}-linux-arm64.zip",
"${TARGET}-linux-amd64.tar.gz",
"${TARGET}-linux-arm64.tar.gz",
},
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
//go:multibuild:exclude=android/*,ios/*
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
//go:multibuild:format=raw,zip,tar.gz
`,
expectedTargets: "linux/amd64\nlinux/arm64\n",
},
Expand Down Expand Up @@ -187,7 +258,14 @@ func main() {
if err != nil {
t.Fatalf("failed to multibuild: %v\nOutput:\n%s", err, out)
}
if len(out) != 0 {
t.Fatalf("unexpected output: %s", out)
}

// FIXME: This test has a small oversight. It was written to assert that the expected output is created.
// But ideally it should also be asserting that no *unexpected* output is created.
//
// An example here is that if we request format=zip, we should assert that the 'raw' binaries are removed.
for _, want := range test.expectedBinaries {
want := strings.ReplaceAll(want, "${TARGET}", filepath.Base(testTmp))
if _, err := os.Stat(filepath.Join(testTmp, want)); err != nil {
Expand Down
1 change: 1 addition & 0 deletions cmd/multibuild/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func displayConfigAndExit(opts options) {
fmt.Fprintf(os.Stderr, "//go:multibuild:include=%s\n", strings.Join(mapSlice(opts.Include, func(f filter) string { return string(f) }), ","))
fmt.Fprintf(os.Stderr, "//go:multibuild:exclude=%s\n", strings.Join(mapSlice(opts.Exclude, func(f filter) string { return string(f) }), ","))
fmt.Fprintf(os.Stderr, "//go:multibuild:output=%s\n", opts.Output)
fmt.Fprintf(os.Stderr, "//go:multibuild:format=%s\n", strings.Join(mapSlice(opts.Format, func(f format) string { return string(f) }), ","))
os.Exit(0)
}

Expand Down
109 changes: 103 additions & 6 deletions cmd/multibuild/multibuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
package main

import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
)
Expand Down Expand Up @@ -117,30 +121,123 @@ func doMultibuild(args cliArgs) {
out := formattedOutput
out = strings.ReplaceAll(out, "${GOOS}", goos)
out = strings.ReplaceAll(out, "${GOARCH}", goarch)
outBin := out

if goos == "windows" {
out += ".exe"
outBin += ".exe"
}

buildArgs := []string{"-o", out}
buildArgs := []string{"-o", outBin}
buildArgs = append(buildArgs, args.goBuildArgs...)

wg.Add(1) // acquire for global
go func(goos, goarch string, buildArgs []string) {
go func(out, outBin, goos, goarch string, buildArgs []string) {
if args.verbose {
fmt.Fprintf(os.Stderr, "%s/%s: waiting\n", goos, goarch)
}
sem <- struct{}{} // acquire for job
if args.verbose {
fmt.Fprintf(os.Stderr, "%s/%s: building\n", goos, goarch)
fmt.Fprintf(os.Stderr, "%s/%s: build\n", goos, goarch)
}
runBuild(buildArgs, goos, goarch)
if args.verbose {
fmt.Fprintf(os.Stderr, "%s/%s: done\n", goos, goarch)
fmt.Fprintf(os.Stderr, "%s/%s: archive\n", goos, goarch)
}

for _, format := range opts.Format {
switch format {
case formatRaw:
// already built (obvs)..
case formatZip:
arPath := out + ".zip"
f, err := os.Create(arPath)
defer f.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to create archive %s: %s\n", goos, goarch, arPath, err)
os.Exit(1)
}

zw := zip.NewWriter(f)
defer zw.Close()

w, err := zw.Create(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to create header %s: %s\n", goos, goarch, arPath, err)
os.Exit(1)
}

st, err := os.Stat(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to stat raw %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
bin, err := os.Open(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to open raw %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
defer bin.Close()
sz, err := io.Copy(w, bin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to copy %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
if sz != st.Size() {
fmt.Fprintf(os.Stderr, "%s/%s: size mismatch in copy of %s: (%d vs %d)\n", goos, goarch, outBin, sz, st.Size())
os.Exit(1)
}
case formatTgz:
arPath := out + ".tar.gz"
f, err := os.Create(arPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to create archive %s: %s\n", goos, goarch, arPath, err)
os.Exit(1)
}
defer f.Close()

gz := gzip.NewWriter(f)
defer gz.Close()

tw := tar.NewWriter(gz)
defer tw.Close()

st, err := os.Stat(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to stat raw %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
bin, err := os.Open(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to open raw %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
defer bin.Close()

hdr := &tar.Header{Name: outBin, Mode: 0755, Size: st.Size()}
tw.WriteHeader(hdr)
sz, err := io.Copy(tw, bin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to copy %s: %s\n", goos, goarch, outBin, err)
os.Exit(1)
}
if sz != st.Size() {
fmt.Fprintf(os.Stderr, "%s/%s: size mismatch in copy of %s: (%d vs %d)\n", goos, goarch, outBin, sz, st.Size())
os.Exit(1)
}
}
}

// If the format list specifically excluded raw, remove the binary.
// I don't know why one would want to do this, but nevertheless...
if !slices.Contains(opts.Format, formatRaw) {
err := os.Remove(outBin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s/%s: failed to remove unwanted raw output %s: %s\n", goos, goarch, outBin, err)
}
}
<-sem // release for job
wg.Done() // release for global
}(goos, goarch, buildArgs)
}(out, outBin, goos, goarch, buildArgs)
}

wg.Wait()
Expand Down
59 changes: 58 additions & 1 deletion cmd/multibuild/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,23 @@ type target string
// e.g. ${TARGET}_${GOOS}_${GOARCH}
type outputTemplate string

// raw, tar.gz, ...
type format string

const (
formatRaw format = "raw"
formatZip = "zip"
formatTgz = "tar.gz"
)

// All options for multibuild go here..
type options struct {
// Output format
// Output filename format
Output outputTemplate

// Output formats to produce
Format []format

// Targets to include
Include []filter

Expand Down Expand Up @@ -171,6 +183,30 @@ func validateTemplate(s string) (outputTemplate, error) {
return outputTemplate(s), nil
}

// Validates that the 's' is a list of formats.
func validateFormatString(s string) ([]format, error) {
if s == "" {
return nil, fmt.Errorf("empty string is not a valid format")
}

var allowedFormats = map[format]struct{}{
formatRaw: {},
formatZip: {},
formatTgz: {},
}

var formats []format
formatStrs := strings.SplitSeq(s, ",")
for formatStr := range formatStrs {
format := format(formatStr)
if _, ok := allowedFormats[format]; !ok {
return nil, fmt.Errorf("format %q is not valid", formatStr)
}
formats = append(formats, format)
}
return formats, nil
}

func validateFilterString(s string) ([]filter, error) {
isAlphaNum := func(b byte) bool {
return (b >= 'a' && b <= 'z') ||
Expand Down Expand Up @@ -274,6 +310,19 @@ func scanBuildPath(reader io.Reader, path string) (options, error) {
return options{}, fmt.Errorf("%s:%d: go:multibuild:output=%s is invalid: %s", path, i, rest, err)
}
opts.Output = parsed
} else if strings.HasPrefix(line, "//go:multibuild:format=") {
if dlog {
log.Printf("Found format: %s:%d: %s", path, i, line)
}
rest := strings.TrimPrefix(line, "//go:multibuild:format=")
if len(opts.Format) > 0 {
return options{}, fmt.Errorf("%s:%d: go:multibuild:format was already set to %s, found: %q here", path, i, opts.Format, rest)
}
parsed, err := validateFormatString(rest)
if err != nil {
return options{}, fmt.Errorf("%s:%d: go:multibuild:format=%s is invalid: %s", path, i, rest, err)
}
opts.Format = parsed
} else if strings.HasPrefix(line, "//go:multibuild:include=") {
if dlog {
log.Printf("Found include: %s:%d: %s", path, i, line)
Expand Down Expand Up @@ -321,6 +370,11 @@ func scanBuildDir(sources []string) (options, error) {
} else if len(topts.Output) > 0 {
opts.Output = topts.Output
}
if len(opts.Format) > 0 && len(topts.Format) > 0 {
return options{}, fmt.Errorf("%s: format= already set elsewhere", path)
} else if len(topts.Format) > 0 {
opts.Format = topts.Format
}
opts.Exclude = append(opts.Exclude, topts.Exclude...)
opts.Include = append(opts.Include, topts.Include...)
}
Expand All @@ -329,6 +383,9 @@ func scanBuildDir(sources []string) (options, error) {
if len(opts.Include) == 0 {
opts.Include = []filter{"*/*"}
}
if len(opts.Format) == 0 {
opts.Format = []format{formatRaw}
}

// These require CGO_ENABLED=1, which I don't want to touch right now.
// As I don't have a use for it, let's just disable them.
Expand Down
Loading
Loading