Skip to content

Commit a7ce8e9

Browse files
committed
feat: Add output formats
1 parent c8d407a commit a7ce8e9

6 files changed

Lines changed: 340 additions & 8 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,28 @@ This configuration will use the same naming, but place all binaries in a `bin/`
8282
An `output` configuration must have all three `${TARGET}`, `${GOOS}`, `${GOARCH}`
8383
placeholders present, but the ordering can change.
8484

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

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

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

93+
## Output formats
94+
95+
multibuild can produce several types of output.
96+
97+
`//go:multibuild:format=raw,zip,tar.gz`
98+
99+
The list of formats is comma separated, and any of the following are supported:
100+
101+
* `raw` - The default, the raw binary produced by `go build`.
102+
* `zip` - A zip archive of the raw binary.
103+
* `tar.gz` - A tar.gz'd archive of the raw binary.
104+
105+
Only a single `format` directive may be found in a package.
106+
93107
# Differences to `go build`
94108

95109
As multibuild is a wrapper around `go build`, most of the behaviour you will see come from there.

cmd/multibuild/integration_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ func main() {
110110
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
111111
//go:multibuild:exclude=android/*,ios/*
112112
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
113+
//go:multibuild:format=raw
113114
`,
114115
expectedTargets: "linux/amd64\nlinux/arm64\n",
115116
},
@@ -124,6 +125,7 @@ func main() {
124125
expectedConfig: `//go:multibuild:include=*/arm64
125126
//go:multibuild:exclude=android/arm64,darwin/arm64,freebsd/arm64,ios/arm64,netbsd/arm64,openbsd/arm64,windows/arm64,android/*,ios/*
126127
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
128+
//go:multibuild:format=raw
127129
`,
128130
expectedTargets: "linux/arm64\n",
129131
},
@@ -139,6 +141,75 @@ func main() {
139141
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
140142
//go:multibuild:exclude=android/*,ios/*
141143
//go:multibuild:output=bin/${TARGET}-hello-${GOOS}-world-${GOARCH}
144+
//go:multibuild:format=raw
145+
`,
146+
expectedTargets: "linux/amd64\nlinux/arm64\n",
147+
},
148+
{
149+
name: "format=raw",
150+
config: `//go:multibuild:include=linux/amd64,linux/arm64
151+
//go:multibuild:format=raw
152+
`,
153+
expectedBinaries: []string{
154+
"${TARGET}-linux-amd64",
155+
"${TARGET}-linux-arm64",
156+
},
157+
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
158+
//go:multibuild:exclude=android/*,ios/*
159+
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
160+
//go:multibuild:format=raw
161+
`,
162+
expectedTargets: "linux/amd64\nlinux/arm64\n",
163+
},
164+
{
165+
name: "format=zip",
166+
config: `//go:multibuild:include=linux/amd64,linux/arm64
167+
//go:multibuild:format=zip
168+
`,
169+
expectedBinaries: []string{
170+
"${TARGET}-linux-amd64.zip",
171+
"${TARGET}-linux-arm64.zip",
172+
},
173+
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
174+
//go:multibuild:exclude=android/*,ios/*
175+
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
176+
//go:multibuild:format=zip
177+
`,
178+
expectedTargets: "linux/amd64\nlinux/arm64\n",
179+
},
180+
{
181+
name: "format=tar.gz",
182+
config: `//go:multibuild:include=linux/amd64,linux/arm64
183+
//go:multibuild:format=tar.gz
184+
`,
185+
expectedBinaries: []string{
186+
"${TARGET}-linux-amd64.tar.gz",
187+
"${TARGET}-linux-arm64.tar.gz",
188+
},
189+
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
190+
//go:multibuild:exclude=android/*,ios/*
191+
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
192+
//go:multibuild:format=tar.gz
193+
`,
194+
expectedTargets: "linux/amd64\nlinux/arm64\n",
195+
},
196+
{
197+
name: "format=raw,zip,tar.gz",
198+
config: `//go:multibuild:include=linux/amd64,linux/arm64
199+
//go:multibuild:format=raw,zip,tar.gz
200+
`,
201+
expectedBinaries: []string{
202+
"${TARGET}-linux-amd64",
203+
"${TARGET}-linux-arm64",
204+
"${TARGET}-linux-amd64.zip",
205+
"${TARGET}-linux-arm64.zip",
206+
"${TARGET}-linux-amd64.tar.gz",
207+
"${TARGET}-linux-arm64.tar.gz",
208+
},
209+
expectedConfig: `//go:multibuild:include=linux/amd64,linux/arm64
210+
//go:multibuild:exclude=android/*,ios/*
211+
//go:multibuild:output=${TARGET}-${GOOS}-${GOARCH}
212+
//go:multibuild:format=raw,zip,tar.gz
142213
`,
143214
expectedTargets: "linux/amd64\nlinux/arm64\n",
144215
},
@@ -187,7 +258,14 @@ func main() {
187258
if err != nil {
188259
t.Fatalf("failed to multibuild: %v\nOutput:\n%s", err, out)
189260
}
261+
if len(out) != 0 {
262+
t.Fatalf("unexpected output: %s", out)
263+
}
190264

265+
// FIXME: This test has a small oversight. It was written to assert that the expected output is created.
266+
// But ideally it should also be asserting that no *unexpected* output is created.
267+
//
268+
// An example here is that if we request format=zip, we should assert that the 'raw' binaries are removed.
191269
for _, want := range test.expectedBinaries {
192270
want := strings.ReplaceAll(want, "${TARGET}", filepath.Base(testTmp))
193271
if _, err := os.Stat(filepath.Join(testTmp, want)); err != nil {

cmd/multibuild/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func displayConfigAndExit(opts options) {
3131
fmt.Fprintf(os.Stderr, "//go:multibuild:include=%s\n", strings.Join(mapSlice(opts.Include, func(f filter) string { return string(f) }), ","))
3232
fmt.Fprintf(os.Stderr, "//go:multibuild:exclude=%s\n", strings.Join(mapSlice(opts.Exclude, func(f filter) string { return string(f) }), ","))
3333
fmt.Fprintf(os.Stderr, "//go:multibuild:output=%s\n", opts.Output)
34+
fmt.Fprintf(os.Stderr, "//go:multibuild:format=%s\n", strings.Join(mapSlice(opts.Format, func(f format) string { return string(f) }), ","))
3435
os.Exit(0)
3536
}
3637

cmd/multibuild/multibuild.go

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
package main
66

77
import (
8+
"archive/tar"
9+
"archive/zip"
810
"bufio"
911
"bytes"
12+
"compress/gzip"
1013
"encoding/json"
1114
"fmt"
1215
"io"
1316
"os"
1417
"os/exec"
1518
"path/filepath"
19+
"slices"
1620
"strings"
1721
"sync"
1822
)
@@ -117,30 +121,123 @@ func doMultibuild(args cliArgs) {
117121
out := formattedOutput
118122
out = strings.ReplaceAll(out, "${GOOS}", goos)
119123
out = strings.ReplaceAll(out, "${GOARCH}", goarch)
124+
outBin := out
120125

121126
if goos == "windows" {
122-
out += ".exe"
127+
outBin += ".exe"
123128
}
124129

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

128133
wg.Add(1) // acquire for global
129-
go func(goos, goarch string, buildArgs []string) {
134+
go func(out, outBin, goos, goarch string, buildArgs []string) {
130135
if args.verbose {
131136
fmt.Fprintf(os.Stderr, "%s/%s: waiting\n", goos, goarch)
132137
}
133138
sem <- struct{}{} // acquire for job
134139
if args.verbose {
135-
fmt.Fprintf(os.Stderr, "%s/%s: building\n", goos, goarch)
140+
fmt.Fprintf(os.Stderr, "%s/%s: build\n", goos, goarch)
136141
}
137142
runBuild(buildArgs, goos, goarch)
138143
if args.verbose {
139-
fmt.Fprintf(os.Stderr, "%s/%s: done\n", goos, goarch)
144+
fmt.Fprintf(os.Stderr, "%s/%s: archive\n", goos, goarch)
145+
}
146+
147+
for _, format := range opts.Format {
148+
switch format {
149+
case formatRaw:
150+
// already built (obvs)..
151+
case formatZip:
152+
arPath := out + ".zip"
153+
f, err := os.Create(arPath)
154+
defer f.Close()
155+
if err != nil {
156+
fmt.Fprintf(os.Stderr, "%s/%s: failed to create archive %s: %s\n", goos, goarch, arPath, err)
157+
os.Exit(1)
158+
}
159+
160+
zw := zip.NewWriter(f)
161+
defer zw.Close()
162+
163+
w, err := zw.Create(outBin)
164+
if err != nil {
165+
fmt.Fprintf(os.Stderr, "%s/%s: failed to create header %s: %s\n", goos, goarch, arPath, err)
166+
os.Exit(1)
167+
}
168+
169+
st, err := os.Stat(outBin)
170+
if err != nil {
171+
fmt.Fprintf(os.Stderr, "%s/%s: failed to stat raw %s: %s\n", goos, goarch, outBin, err)
172+
os.Exit(1)
173+
}
174+
bin, err := os.Open(outBin)
175+
if err != nil {
176+
fmt.Fprintf(os.Stderr, "%s/%s: failed to open raw %s: %s\n", goos, goarch, outBin, err)
177+
os.Exit(1)
178+
}
179+
defer bin.Close()
180+
sz, err := io.Copy(w, bin)
181+
if err != nil {
182+
fmt.Fprintf(os.Stderr, "%s/%s: failed to copy %s: %s\n", goos, goarch, outBin, err)
183+
os.Exit(1)
184+
}
185+
if sz != st.Size() {
186+
fmt.Fprintf(os.Stderr, "%s/%s: size mismatch in copy of %s: (%d vs %d)\n", goos, goarch, outBin, sz, st.Size())
187+
os.Exit(1)
188+
}
189+
case formatTgz:
190+
arPath := out + ".tar.gz"
191+
f, err := os.Create(arPath)
192+
if err != nil {
193+
fmt.Fprintf(os.Stderr, "%s/%s: failed to create archive %s: %s\n", goos, goarch, arPath, err)
194+
os.Exit(1)
195+
}
196+
defer f.Close()
197+
198+
gz := gzip.NewWriter(f)
199+
defer gz.Close()
200+
201+
tw := tar.NewWriter(gz)
202+
defer tw.Close()
203+
204+
st, err := os.Stat(outBin)
205+
if err != nil {
206+
fmt.Fprintf(os.Stderr, "%s/%s: failed to stat raw %s: %s\n", goos, goarch, outBin, err)
207+
os.Exit(1)
208+
}
209+
bin, err := os.Open(outBin)
210+
if err != nil {
211+
fmt.Fprintf(os.Stderr, "%s/%s: failed to open raw %s: %s\n", goos, goarch, outBin, err)
212+
os.Exit(1)
213+
}
214+
defer bin.Close()
215+
216+
hdr := &tar.Header{Name: outBin, Mode: 0755, Size: st.Size()}
217+
tw.WriteHeader(hdr)
218+
sz, err := io.Copy(tw, bin)
219+
if err != nil {
220+
fmt.Fprintf(os.Stderr, "%s/%s: failed to copy %s: %s\n", goos, goarch, outBin, err)
221+
os.Exit(1)
222+
}
223+
if sz != st.Size() {
224+
fmt.Fprintf(os.Stderr, "%s/%s: size mismatch in copy of %s: (%d vs %d)\n", goos, goarch, outBin, sz, st.Size())
225+
os.Exit(1)
226+
}
227+
}
228+
}
229+
230+
// If the format list specifically excluded raw, remove the binary.
231+
// I don't know why one would want to do this, but nevertheless...
232+
if !slices.Contains(opts.Format, formatRaw) {
233+
err := os.Remove(outBin)
234+
if err != nil {
235+
fmt.Fprintf(os.Stderr, "%s/%s: failed to remove unwanted raw output %s: %s\n", goos, goarch, outBin, err)
236+
}
140237
}
141238
<-sem // release for job
142239
wg.Done() // release for global
143-
}(goos, goarch, buildArgs)
240+
}(out, outBin, goos, goarch, buildArgs)
144241
}
145242

146243
wg.Wait()

cmd/multibuild/options.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,23 @@ type target string
2828
// e.g. ${TARGET}_${GOOS}_${GOARCH}
2929
type outputTemplate string
3030

31+
// raw, tar.gz, ...
32+
type format string
33+
34+
const (
35+
formatRaw format = "raw"
36+
formatZip = "zip"
37+
formatTgz = "tar.gz"
38+
)
39+
3140
// All options for multibuild go here..
3241
type options struct {
33-
// Output format
42+
// Output filename format
3443
Output outputTemplate
3544

45+
// Output formats to produce
46+
Format []format
47+
3648
// Targets to include
3749
Include []filter
3850

@@ -171,6 +183,30 @@ func validateTemplate(s string) (outputTemplate, error) {
171183
return outputTemplate(s), nil
172184
}
173185

186+
// Validates that the 's' is a list of formats.
187+
func validateFormatString(s string) ([]format, error) {
188+
if s == "" {
189+
return nil, fmt.Errorf("empty string is not a valid format")
190+
}
191+
192+
var allowedFormats = map[format]struct{}{
193+
formatRaw: {},
194+
formatZip: {},
195+
formatTgz: {},
196+
}
197+
198+
var formats []format
199+
formatStrs := strings.SplitSeq(s, ",")
200+
for formatStr := range formatStrs {
201+
format := format(formatStr)
202+
if _, ok := allowedFormats[format]; !ok {
203+
return nil, fmt.Errorf("format %q is not valid", formatStr)
204+
}
205+
formats = append(formats, format)
206+
}
207+
return formats, nil
208+
}
209+
174210
func validateFilterString(s string) ([]filter, error) {
175211
isAlphaNum := func(b byte) bool {
176212
return (b >= 'a' && b <= 'z') ||
@@ -274,6 +310,19 @@ func scanBuildPath(reader io.Reader, path string) (options, error) {
274310
return options{}, fmt.Errorf("%s:%d: go:multibuild:output=%s is invalid: %s", path, i, rest, err)
275311
}
276312
opts.Output = parsed
313+
} else if strings.HasPrefix(line, "//go:multibuild:format=") {
314+
if dlog {
315+
log.Printf("Found format: %s:%d: %s", path, i, line)
316+
}
317+
rest := strings.TrimPrefix(line, "//go:multibuild:format=")
318+
if len(opts.Format) > 0 {
319+
return options{}, fmt.Errorf("%s:%d: go:multibuild:format was already set to %s, found: %q here", path, i, opts.Format, rest)
320+
}
321+
parsed, err := validateFormatString(rest)
322+
if err != nil {
323+
return options{}, fmt.Errorf("%s:%d: go:multibuild:format=%s is invalid: %s", path, i, rest, err)
324+
}
325+
opts.Format = parsed
277326
} else if strings.HasPrefix(line, "//go:multibuild:include=") {
278327
if dlog {
279328
log.Printf("Found include: %s:%d: %s", path, i, line)
@@ -321,6 +370,11 @@ func scanBuildDir(sources []string) (options, error) {
321370
} else if len(topts.Output) > 0 {
322371
opts.Output = topts.Output
323372
}
373+
if len(opts.Format) > 0 && len(topts.Format) > 0 {
374+
return options{}, fmt.Errorf("%s: format= already set elsewhere", path)
375+
} else if len(topts.Format) > 0 {
376+
opts.Format = topts.Format
377+
}
324378
opts.Exclude = append(opts.Exclude, topts.Exclude...)
325379
opts.Include = append(opts.Include, topts.Include...)
326380
}
@@ -329,6 +383,9 @@ func scanBuildDir(sources []string) (options, error) {
329383
if len(opts.Include) == 0 {
330384
opts.Include = []filter{"*/*"}
331385
}
386+
if len(opts.Format) == 0 {
387+
opts.Format = []format{formatRaw}
388+
}
332389

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

0 commit comments

Comments
 (0)