Builder diagnostics go to stderr; stdout is the result alone

The logging added a line to stdout, and the genesis path parses the builder's
stdout as JSON — so the first log line broke the parse with "invalid character
'c'", the c from "[clone]". A build that had worked stopped working because of a
print statement.

The installer's runner captures stdout alone (cmd.Output), and the contract was
already stdout=result, stderr=everything else. The fix is to honour it: every
builder diagnostic — the step log, the per-command echo, the module path's own
lines — goes to stderr. Stdout carries only once.go's result JSON.

And a unit test now fails if any fmt.Print to stdout appears in the two builder
command files, except the three that belong there: the result, --version, and
--help. A guard, because this was invisible until a 20-minute run hit it, and the
same class of mistake should fail in milliseconds next time.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 23:01:04 +02:00
parent 9070d2502c
commit 2fb700d61b
4 changed files with 57 additions and 13 deletions
+9 -9
View File
@@ -139,7 +139,7 @@ func run() error {
return err
}
fmt.Printf("building for the mesh, publishing to %s\n", registry)
fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry)
publisher := builder.Registry{Address: registry, Run: builder.Command}
for {
@@ -164,14 +164,14 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
// until it either finishes or fails is indistinguishable from one that never arrived — which
// cost a long diagnosis against a running mesh, chasing "the handler never fired" when the
// truth was only that the handler said nothing until the end.
fmt.Printf("a build request arrived (%d bytes)\n", len(delivery.Body))
fmt.Fprintf(os.Stderr, "a build request arrived (%d bytes)\n", len(delivery.Body))
var request link.BuildRequest
if err := json.Unmarshal(delivery.Body, &request); err != nil {
// Unreadable. Acknowledged and dropped rather than requeued: a message this builder
// cannot parse will not become parseable by being delivered again, and requeueing it
// would put it in front of every real request for ever.
fmt.Printf("a request could not be read and was dropped: %v\n", err)
fmt.Fprintf(os.Stderr, "a request could not be read and was dropped: %v\n", err)
_ = delivery.Ack(false)
return
}
@@ -180,19 +180,19 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
ID: request.ID, Repository: request.Repository, Path: request.Path,
Ref: request.Ref, On: on,
}
fmt.Printf("building %s", request.Repository)
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
if request.Path != "" {
fmt.Printf(" at %s", request.Path)
fmt.Fprintf(os.Stderr, " at %s", request.Path)
}
if request.Ref != "" {
fmt.Printf(" at %s", request.Ref)
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
}
fmt.Println()
fmt.Fprintln(os.Stderr)
built, err := builder.Build(ctx, builder.Command, publisher,
request.Repository, request.Path, request.Ref, workspace, request.Held,
func(step, message string) {
fmt.Printf(" [%s] %s\n", step, message)
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
})
if err != nil {
// A failure is a result. A build that fails and says nothing is indistinguishable from a
@@ -212,7 +212,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis
})
}
result.Against = built.Against
fmt.Printf(" built %s from %s\n", built.Manifest.Module, short(built.Commit))
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
}
}