From f1430c151aa0b23276e36d66301039dde84e8063 Mon Sep 17 00:00:00 2001 From: William Bezuidenhout Date: Tue, 19 May 2026 12:29:35 +0200 Subject: [PATCH] batches: upload large exec artifacts Batch executor jobs can produce stdout, stderr, and diffs large enough to bloat the final JSONL result stream. Upload oversized step artifacts from the existing `src batch exec` execution path instead of adding a separate command or user-facing auth flags. `src batch exec` now discovers the executor job context from the environment, uses the existing executor job bearer token plus executor name auth model to stream large artifacts to the Batch Changes artifacts endpoint, and records returned references on `AfterStepResult`. Small outputs stay inline for compatibility, and per-step progress output is suppressed when artifact uploads are enabled so large logs are not duplicated into task-step JSONL events. companian branch in sg: wb/proto-exec-upload Test Plan: - go test ./cmd/src ./internal/batches/executor ./internal/batches/ui github.com/sourcegraph/sourcegraph/lib/batches/execution --- cmd/src/batch_exec.go | 9 +- cmd/src/batch_exec_artifacts.go | 108 +++++++++++++++++++ cmd/src/batch_exec_artifacts_test.go | 72 +++++++++++++ internal/batches/executor/artifacts.go | 144 +++++++++++++++++++++++++ internal/batches/executor/run_steps.go | 70 ++++++++---- internal/batches/ui/json_lines.go | 21 ++-- lib/batches/execution/results.go | 47 +++++--- 7 files changed, 429 insertions(+), 42 deletions(-) create mode 100644 cmd/src/batch_exec_artifacts.go create mode 100644 cmd/src/batch_exec_artifacts_test.go create mode 100644 internal/batches/executor/artifacts.go diff --git a/cmd/src/batch_exec.go b/cmd/src/batch_exec.go index 11aadf39e3..57e6ecc042 100644 --- a/cmd/src/batch_exec.go +++ b/cmd/src/batch_exec.go @@ -123,7 +123,11 @@ Examples: } func executeBatchSpecInWorkspaces(ctx context.Context, flags *executorModeFlags) (err error) { - ui := &ui.JSONLines{BinaryDiffs: flags.binaryDiffs} + artifactUploader, err := newBatchArtifactUploaderFromEnv(cfg.endpointURL) + if err != nil { + return err + } + ui := &ui.JSONLines{BinaryDiffs: flags.binaryDiffs, SuppressStepOutput: artifactUploader != nil} // Ensure the temp dir exists. tempDir := flags.tempDir @@ -214,6 +218,9 @@ func executeBatchSpecInWorkspaces(ctx context.Context, flags *executorModeFlags) UI: taskExecUI.StepsExecutionUI(task), ForceRoot: !flags.runAsImageUser, BinaryDiffs: flags.binaryDiffs, + + ArtifactUploader: artifactUploader, + ArtifactUploadThresholdBytes: defaultArtifactUploadThresholdBytes, } results, err := executor.RunSteps(ctx, opts) diff --git a/cmd/src/batch_exec_artifacts.go b/cmd/src/batch_exec_artifacts.go new file mode 100644 index 0000000000..8f2d2fdd09 --- /dev/null +++ b/cmd/src/batch_exec_artifacts.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/sourcegraph/src-cli/internal/batches/executor" + + "github.com/sourcegraph/sourcegraph/lib/batches/execution" + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// defaultArtifactUploadThresholdBytes is 1 MiB. +const defaultArtifactUploadThresholdBytes = 1 << 20 + +type batchArtifactUploader struct { + endpointURL *url.URL + jobID int + jobToken string + executorName string + client *http.Client +} + +var _ executor.ArtifactUploader = (*batchArtifactUploader)(nil) + +func newBatchArtifactUploaderFromEnv(endpointURL *url.URL) (*batchArtifactUploader, error) { + jobID, _ := strconv.Atoi(os.Getenv("SRC_EXECUTOR_JOB_ID")) + jobToken := os.Getenv("SRC_EXECUTOR_JOB_TOKEN") + executorName := os.Getenv("SRC_EXECUTOR_NAME") + + configured := jobID != 0 || jobToken != "" || executorName != "" + if !configured { + return nil, nil + } + if jobID == 0 || jobToken == "" || executorName == "" { + return nil, errors.New("artifact upload requires job ID, job token, and executor name") + } + + return &batchArtifactUploader{ + endpointURL: endpointURL, + jobID: jobID, + jobToken: jobToken, + executorName: executorName, + client: http.DefaultClient, + }, nil +} + +func (u *batchArtifactUploader) Upload(ctx context.Context, artifactKey string, r io.Reader) (execution.Artifact, error) { + if strings.Contains(artifactKey, "/") || strings.Contains(artifactKey, "\\") || strings.Contains(artifactKey, "..") { + return execution.Artifact{}, errors.Newf("invalid artifact key %q", artifactKey) + } + sizeReader := &artifactSizeReader{r: r} + + url := u.endpointURL.JoinPath( + ".executors", + "queue", + "batches", + "artifacts", + artifactKey, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), sizeReader) + if err != nil { + return execution.Artifact{}, err + } + req.Header.Set("Authorization", "Bearer "+u.jobToken) + req.Header.Set("X-Sourcegraph-Executor-Name", u.executorName) + req.Header.Set("X-Sourcegraph-Job-ID", strconv.Itoa(u.jobID)) + + resp, err := u.client.Do(req) + if err != nil { + return execution.Artifact{}, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return execution.Artifact{}, errors.Newf("artifact upload failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var ref execution.Artifact + if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil { + return execution.Artifact{}, errors.Wrap(err, "decoding artifact upload response") + } + if ref.ObjectStorageKey == "" { + return execution.Artifact{}, errors.New("artifact upload response did not include an object storage key") + } + ref.Size = sizeReader.size + return ref, nil +} + +type artifactSizeReader struct { + r io.Reader + size int64 +} + +func (r *artifactSizeReader) Read(p []byte) (int, error) { + n, err := r.r.Read(p) + if n > 0 { + r.size += int64(n) + } + return n, err +} diff --git a/cmd/src/batch_exec_artifacts_test.go b/cmd/src/batch_exec_artifacts_test.go new file mode 100644 index 0000000000..90ed71e023 --- /dev/null +++ b/cmd/src/batch_exec_artifacts_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/sourcegraph/sourcegraph/lib/batches/execution" +) + +func TestBatchArtifactUploaderUploadAddsMetadata(t *testing.T) { + const artifactContents = "artifact contents" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method %q", r.Method) + } + if r.URL.Path != "/.executors/queue/batches/artifacts/stdout" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer token" { + t.Fatalf("unexpected authorization header %q", got) + } + if got := r.Header.Get("X-Sourcegraph-Executor-Name"); got != "executor" { + t.Fatalf("unexpected executor header %q", got) + } + if got := r.Header.Get("X-Sourcegraph-Job-ID"); got != "42" { + t.Fatalf("unexpected job ID header %q", got) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != artifactContents { + t.Fatalf("unexpected body %q", string(body)) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(execution.Artifact{ObjectStorageKey: "key"}) + })) + t.Cleanup(server.Close) + + endpointURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + uploader := &batchArtifactUploader{ + endpointURL: endpointURL, + jobID: 42, + jobToken: "token", + executorName: "executor", + client: server.Client(), + } + + ref, err := uploader.Upload(context.Background(), "stdout", strings.NewReader(artifactContents)) + if err != nil { + t.Fatal(err) + } + + if ref.ObjectStorageKey != "key" { + t.Fatalf("unexpected object storage key %q", ref.ObjectStorageKey) + } + if ref.Size != int64(len(artifactContents)) { + t.Fatalf("unexpected size %d", ref.Size) + } +} diff --git a/internal/batches/executor/artifacts.go b/internal/batches/executor/artifacts.go new file mode 100644 index 0000000000..348d570162 --- /dev/null +++ b/internal/batches/executor/artifacts.go @@ -0,0 +1,144 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "io" + "math" + "os" + + "github.com/sourcegraph/sourcegraph/lib/batches/execution" + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +const maxInlineArtifactSize = math.MaxInt64 + +type ArtifactUploader interface { + Upload(ctx context.Context, artifactKey string, r io.Reader) (execution.Artifact, error) +} + +type stepOutput struct { + stdout *artifactOutput + stderr *artifactOutput +} + +func newStepOutput(dir string, threshold int64) (*stepOutput, error) { + stdout, err := newArtifactOutput(dir, "stdout-*", threshold) + if err != nil { + return nil, err + } + stderr, err := newArtifactOutput(dir, "stderr-*", threshold) + if err != nil { + stdout.cleanup() + return nil, err + } + return &stepOutput{stdout: stdout, stderr: stderr}, nil +} + +func (o *stepOutput) cleanup() { + o.stdout.cleanup() + o.stderr.cleanup() +} + +type artifactOutput struct { + file *os.File + buf bytes.Buffer + size int64 + threshold int64 +} + +func newArtifactOutput(dir, pattern string, threshold int64) (*artifactOutput, error) { + file, err := os.CreateTemp(dir, pattern) + if err != nil { + return nil, errors.Wrap(err, "creating artifact output file") + } + return &artifactOutput{file: file, threshold: threshold}, nil +} + +func (o *artifactOutput) writer() io.Writer { return o } + +func (o *artifactOutput) Write(p []byte) (int, error) { + n, err := o.file.Write(p) + o.size += int64(n) + if o.size <= o.threshold { + _, _ = o.buf.Write(p[:n]) + } + return n, err +} + +func (o *artifactOutput) inline() string { + if o.size > o.threshold { + return "" + } + return o.buf.String() +} + +func (o *artifactOutput) shouldUpload(threshold int64) bool { + return o.size > threshold +} + +func (o *artifactOutput) reader() (io.Reader, error) { + if _, err := o.file.Seek(0, io.SeekStart); err != nil { + return nil, errors.Wrap(err, "rewinding artifact output file") + } + return o.file, nil +} + +func (o *artifactOutput) cleanup() { + if o.file == nil { + return + } + name := o.file.Name() + _ = o.file.Close() + _ = os.Remove(name) + o.file = nil +} + +func uploadStepArtifacts(ctx context.Context, uploader ArtifactUploader, threshold int64, stepIndex int, result *execution.AfterStepResult, output *stepOutput) error { + defer output.cleanup() + result.Artifacts = make(map[string]execution.Artifact) + + if output.stdout.shouldUpload(threshold) { + ref, err := uploadArtifactOutput(ctx, uploader, artifactKey(stepIndex, execution.ArtifactStdout), output.stdout) + if err != nil { + return err + } + result.Artifacts[execution.ArtifactStdout] = ref + } + + if output.stderr.shouldUpload(threshold) { + ref, err := uploadArtifactOutput(ctx, uploader, artifactKey(stepIndex, execution.ArtifactStderr), output.stderr) + if err != nil { + return err + } + result.Artifacts[execution.ArtifactStderr] = ref + } + + if int64(len(result.Diff)) > threshold { + ref, err := uploader.Upload(ctx, artifactKey(stepIndex, execution.ArtifactDiff), bytes.NewReader(result.Diff)) + if err != nil { + return errors.Wrap(err, "uploading diff artifact") + } + result.Artifacts[execution.ArtifactDiff] = ref + result.Diff = nil + } + + return nil +} + +func uploadArtifactOutput(ctx context.Context, uploader ArtifactUploader, key string, output *artifactOutput) (execution.Artifact, error) { + reader, err := output.reader() + if err != nil { + return execution.Artifact{}, err + } + ref, err := uploader.Upload(ctx, key, reader) + if err != nil { + return execution.Artifact{}, errors.Wrapf(err, "uploading %s artifact", key) + } + return ref, nil +} + +func artifactKey(stepIndex int, name string) string { + return fmt.Sprintf("step-%d-%s", stepIndex, name) +} diff --git a/internal/batches/executor/run_steps.go b/internal/batches/executor/run_steps.go index 47b5715887..f1b2474f1f 100644 --- a/internal/batches/executor/run_steps.go +++ b/internal/batches/executor/run_steps.go @@ -57,6 +57,13 @@ type RunStepsOpts struct { ForceRoot bool BinaryDiffs bool + + // ArtifactUploader uploads oversized step artifacts when set. Small artifacts + // remain inline for backwards compatibility. + ArtifactUploader ArtifactUploader + // ArtifactUploadThresholdBytes is the number of bytes an artifact may contain + // before it is externalized through ArtifactUploader. + ArtifactUploadThresholdBytes int64 } func RunSteps(ctx context.Context, opts *RunStepsOpts) (stepResults []execution.AfterStepResult, err error) { @@ -175,7 +182,7 @@ func RunSteps(ctx context.Context, opts *RunStepsOpts) (stepResults []execution. return nil, err } - stdoutBuffer, stderrBuffer, err := executeSingleStep(ctx, opts, ws, i, step, digest, &stepContext) + stepOutput, err := executeSingleStep(ctx, opts, ws, i, step, digest, &stepContext) defer func() { if err != nil { exitCode := -1 @@ -189,6 +196,7 @@ func RunSteps(ctx context.Context, opts *RunStepsOpts) (stepResults []execution. if err != nil { return stepResults, err } + defer stepOutput.cleanup() // Get the current diff and store that away as the per-step result. stepDiff, err := ws.Diff(ctx) @@ -209,8 +217,8 @@ func RunSteps(ctx context.Context, opts *RunStepsOpts) (stepResults []execution. stepResult := execution.AfterStepResult{ Version: version, ChangedFiles: changes, - Stdout: stdoutBuffer.String(), - Stderr: stderrBuffer.String(), + Stdout: stepOutput.stdout.inline(), + Stderr: stepOutput.stderr.inline(), StepIndex: i, Diff: stepDiff, // Those will be set below. @@ -224,6 +232,15 @@ func RunSteps(ctx context.Context, opts *RunStepsOpts) (stepResults []execution. return stepResults, errors.Wrap(err, "setting step outputs") } maps.Copy(stepResult.Outputs, lastOutputs) + + // Upload artifacts only after outputs have been rendered so this first pass + // stores the final step result artifacts, not intermediate step output. + if opts.ArtifactUploader != nil { + if err := uploadStepArtifacts(ctx, opts.ArtifactUploader, opts.ArtifactUploadThresholdBytes, i, &stepResult, stepOutput); err != nil { + return stepResults, err + } + } + stepResults = append(stepResults, stepResult) previousStepResult = stepResult @@ -251,7 +268,20 @@ func executeSingleStep( step batcheslib.Step, imageDigest string, stepContext *template.StepContext, -) (stdout bytes.Buffer, stderr bytes.Buffer, err error) { +) (output *stepOutput, err error) { + artifactUploadThreshold := opts.ArtifactUploadThresholdBytes + if opts.ArtifactUploader == nil { + artifactUploadThreshold = maxInlineArtifactSize + } + output, err = newStepOutput(opts.TempDir, artifactUploadThreshold) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + output.cleanup() + } + }() // ---------- // PREPARATION // ---------- @@ -260,7 +290,7 @@ func executeSingleStep( cidFile, cleanup, err := createCidFile(ctx, opts.TempDir, util.SlugForRepo(opts.Task.Repository.Name, opts.Task.Repository.Rev())) if err != nil { opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } defer cleanup() @@ -269,13 +299,13 @@ func executeSingleStep( if err != nil { err = errors.Wrapf(err, "probing image %q for shell", step.Container) opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } runScriptFile, runScript, cleanup, err := createRunScriptFile(ctx, opts.TempDir, step.Run, stepContext) if err != nil { opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } defer cleanup() @@ -283,7 +313,7 @@ func executeSingleStep( filesToMount, cleanup, err := createFilesToMount(opts.TempDir, step, stepContext) if err != nil { opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } defer cleanup() @@ -292,7 +322,7 @@ func executeSingleStep( if err != nil { err = errors.Wrap(err, "resolving step environment") opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } // Render the step.Env variables as templates. @@ -300,7 +330,7 @@ func executeSingleStep( if err != nil { err = errors.Wrap(err, "parsing step environment") opts.UI.StepPreparingFailed(stepIdx+1, err) - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } opts.UI.StepPreparingSuccess(stepIdx + 1) @@ -312,7 +342,7 @@ func executeSingleStep( workspaceOpts, err := workspace.DockerRunOpts(ctx, workDir) if err != nil { - return bytes.Buffer{}, bytes.Buffer{}, errors.Wrap(err, "getting Docker options for workspace") + return output, errors.Wrap(err, "getting Docker options for workspace") } // Where should we execute the steps.run script? @@ -342,7 +372,7 @@ func executeSingleStep( for _, mount := range step.Mount { workspaceFilePath, err := getAbsoluteMountPath(opts.WorkingDirectory, mount.Path) if err != nil { - return bytes.Buffer{}, bytes.Buffer{}, err + return output, err } args = append(args, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s,ro", workspaceFilePath, mount.Mountpoint)) } @@ -366,13 +396,13 @@ func executeSingleStep( outputWriter.Close() }() - stdoutWriter := io.MultiWriter(&stdout, outputWriter.StdoutWriter(), opts.Logger.PrefixWriter("stdout")) - stderrWriter := io.MultiWriter(&stderr, outputWriter.StderrWriter(), opts.Logger.PrefixWriter("stderr")) + stdoutWriter := io.MultiWriter(output.stdout.writer(), outputWriter.StdoutWriter(), opts.Logger.PrefixWriter("stdout")) + stderrWriter := io.MultiWriter(output.stderr.writer(), outputWriter.StderrWriter(), opts.Logger.PrefixWriter("stderr")) // Setup readers that pipe the output into the given buffers wg, err := process.PipeOutput(ctx, cmd, stdoutWriter, stderrWriter) if err != nil { - return stdout, stderr, errors.Wrap(err, "piping process output") + return output, errors.Wrap(err, "piping process output") } newStepFailedErr := func(wrappedErr error) stepFailedErr { @@ -388,8 +418,8 @@ func executeSingleStep( Run: runScript, Container: step.Container, TmpFilename: containerTemp, - Stdout: strings.TrimSpace(stdout.String()), - Stderr: strings.TrimSpace(stderr.String()), + Stdout: strings.TrimSpace(output.stdout.inline()), + Stderr: strings.TrimSpace(output.stderr.inline()), } } @@ -400,7 +430,7 @@ func executeSingleStep( t0 := time.Now() if err := cmd.Start(); err != nil { opts.Logger.Logf("[Step %d] error starting Docker container: %+v", stepIdx+1, err) - return stdout, stderr, newStepFailedErr(err) + return output, newStepFailedErr(err) } // Wait for the readers, because the pipes used by PipeOutput under the @@ -412,11 +442,11 @@ func executeSingleStep( elapsed := time.Since(t0).Round(time.Millisecond) if err != nil { opts.Logger.Logf("[Step %d] took %s; error running Docker container: %+v", stepIdx+1, elapsed, err) - return stdout, stderr, newStepFailedErr(err) + return output, newStepFailedErr(err) } opts.Logger.Logf("[Step %d] complete in %s", stepIdx+1, elapsed) - return stdout, stderr, nil + return output, nil } func setOutputs(stepOutputs batcheslib.Outputs, global map[string]any, stepCtx *template.StepContext) error { diff --git a/internal/batches/ui/json_lines.go b/internal/batches/ui/json_lines.go index d5c36c8da2..77867b5abd 100644 --- a/internal/batches/ui/json_lines.go +++ b/internal/batches/ui/json_lines.go @@ -24,7 +24,8 @@ import ( var _ ExecUI = &JSONLines{} type JSONLines struct { - BinaryDiffs bool + BinaryDiffs bool + SuppressStepOutput bool } func (ui *JSONLines) ParsingBatchSpec() { @@ -92,7 +93,8 @@ func (ui *JSONLines) CheckingCacheSuccess(cachedSpecsFound int, tasksToExecute i func (ui *JSONLines) ExecutingTasks(_ bool, _ int) executor.TaskExecutionUI { return &taskExecutionJSONLines{ - binaryDiffs: ui.BinaryDiffs, + binaryDiffs: ui.BinaryDiffs, + suppressStepOutput: ui.SuppressStepOutput, } } @@ -186,8 +188,9 @@ Error: %s } type taskExecutionJSONLines struct { - linesTasks map[*executor.Task]batcheslib.JSONLinesTask - binaryDiffs bool + linesTasks map[*executor.Task]batcheslib.JSONLinesTask + binaryDiffs bool + suppressStepOutput bool } // seededRand is used in randomID() to generate a "random" number. @@ -265,12 +268,13 @@ func (ui *taskExecutionJSONLines) StepsExecutionUI(task *executor.Task) executor panic("unknown task started") } - return &stepsExecutionJSONLines{linesTask: <} + return &stepsExecutionJSONLines{linesTask: <, binaryDiffs: ui.binaryDiffs, suppressStepOutput: ui.suppressStepOutput} } type stepsExecutionJSONLines struct { - linesTask *batcheslib.JSONLinesTask - binaryDiffs bool + linesTask *batcheslib.JSONLinesTask + binaryDiffs bool + suppressStepOutput bool } const stepFlushDuration = 500 * time.Millisecond @@ -319,6 +323,9 @@ func (ui *stepsExecutionJSONLines) StepStarted(step int, runScript string, env m } func (ui *stepsExecutionJSONLines) StepOutputWriter(ctx context.Context, task *executor.Task, step int) executor.StepOutputWriter { + if ui.suppressStepOutput { + return executor.NoopStepOutputWriter{} + } sink := func(data string) { logOperationProgress( batcheslib.LogEventOperationTaskStep, diff --git a/lib/batches/execution/results.go b/lib/batches/execution/results.go index 4426430708..adb7e771e0 100644 --- a/lib/batches/execution/results.go +++ b/lib/batches/execution/results.go @@ -20,12 +20,26 @@ type AfterStepResult struct { StepIndex int `json:"stepIndex"` // Diff is the cumulative `git diff` after executing the Step. Diff []byte `json:"diff"` + // Artifacts points to externally stored step artifacts by artifact name when + // they are too large to inline. + Artifacts map[string]Artifact `json:"artifacts,omitempty"` // Outputs is a copy of the Outputs after executing the Step. Outputs map[string]any `json:"outputs"` // Skipped determines whether the step was skipped. Skipped bool `json:"skipped"` } +const ( + ArtifactStdout = "stdout" + ArtifactStderr = "stderr" + ArtifactDiff = "diff" +) + +type Artifact struct { + ObjectStorageKey string `json:"objectStorageKey"` + Size int64 `json:"size"` +} + func (a AfterStepResult) MarshalJSON() ([]byte, error) { if a.Version == 2 { return json.Marshal(v2AfterStepResult(a)) @@ -36,6 +50,7 @@ func (a AfterStepResult) MarshalJSON() ([]byte, error) { Stderr: a.Stderr, StepIndex: a.StepIndex, Diff: string(a.Diff), + Artifacts: a.Artifacts, Outputs: a.Outputs, }) } @@ -56,6 +71,7 @@ func (a *AfterStepResult) UnmarshalJSON(data []byte) error { a.Stderr = v2.Stderr a.StepIndex = v2.StepIndex a.Diff = v2.Diff + a.Artifacts = v2.Artifacts a.Outputs = v2.Outputs a.Skipped = v2.Skipped return nil @@ -69,6 +85,7 @@ func (a *AfterStepResult) UnmarshalJSON(data []byte) error { a.Stderr = v1.Stderr a.StepIndex = v1.StepIndex a.Diff = []byte(v1.Diff) + a.Artifacts = v1.Artifacts a.Outputs = v1.Outputs return nil } @@ -78,21 +95,23 @@ type versionAfterStepResult struct { } type v2AfterStepResult struct { - Version int `json:"version"` - ChangedFiles git.Changes `json:"changedFiles"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - StepIndex int `json:"stepIndex"` - Diff []byte `json:"diff"` - Outputs map[string]any `json:"outputs"` - Skipped bool `json:"skipped"` + Version int `json:"version"` + ChangedFiles git.Changes `json:"changedFiles"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StepIndex int `json:"stepIndex"` + Diff []byte `json:"diff"` + Artifacts map[string]Artifact `json:"artifacts,omitempty"` + Outputs map[string]any `json:"outputs"` + Skipped bool `json:"skipped"` } type v1AfterStepResult struct { - ChangedFiles git.Changes `json:"changedFiles"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - StepIndex int `json:"stepIndex"` - Diff string `json:"diff"` - Outputs map[string]any `json:"outputs"` + ChangedFiles git.Changes `json:"changedFiles"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StepIndex int `json:"stepIndex"` + Diff string `json:"diff"` + Artifacts map[string]Artifact `json:"artifacts,omitempty"` + Outputs map[string]any `json:"outputs"` }