Skip to content

Commit bac2b90

Browse files
authored
Merge branch 'main' into bahrmichael/dynamic-containers
2 parents c24c25c + 06cd2cd commit bac2b90

29 files changed

Lines changed: 251 additions & 206 deletions

lib/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ This directory contains vendored packages from `github.com/sourcegraph/sourcegra
55
## Source
66

77
- **Repository**: https://github.com/sourcegraph/sourcegraph
8-
- **Commit**: 2ee2b8e77de9663b08ce5f6e5a2c7d2217ce721a
9-
- **Date**: 2025-11-17 19:49:42 -0800
8+
- **Commit**: bdc2f4bb8b59f78f4fa8868b2690b673b41948d4
9+
- **Date**: 2026-06-01 07:34:50 +0100
1010

1111
## Updating
1212

lib/batches/batch_spec.go

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package batches
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"strings"
67

@@ -38,8 +39,31 @@ type BatchSpec struct {
3839
TransformChanges *TransformChanges `json:"transformChanges,omitempty" yaml:"transformChanges,omitempty"`
3940
ImportChangesets []ImportChangeset `json:"importChangesets,omitempty" yaml:"importChangesets"`
4041
ChangesetTemplate *ChangesetTemplate `json:"changesetTemplate,omitempty" yaml:"changesetTemplate"`
42+
ChangesetHooks *ChangesetHooks `json:"changesetHooks,omitempty" yaml:"hooks,omitempty"`
4143
}
4244

45+
// Hooks declares side-effect actions to run at well-defined changeset
46+
// lifecycle events. Only allowed when Version is 3.
47+
type ChangesetHooks struct {
48+
OnCIFailure ChangesetHookAction `json:"onCIFailure,omitempty" yaml:"onCIFailure,omitempty"`
49+
OnMergeConflict ChangesetHookAction `json:"onMergeConflict,omitempty" yaml:"onMergeConflict,omitempty"`
50+
}
51+
52+
// HookAction is a single action attached to a changeset lifecycle event.
53+
//
54+
// Hook actions reuse the Step shape from the top-level steps block.
55+
type ChangesetHookAction struct {
56+
Steps []Step `json:"steps,omitempty" yaml:"steps,omitempty"`
57+
}
58+
59+
type changesetHookEvent string
60+
61+
// Hook event names. Kept here so callers don't pass typoed strings.
62+
const (
63+
ChangesetHookEventOnCIFailure changesetHookEvent = "onCIFailure"
64+
ChangesetHookEventOnMergeConflict changesetHookEvent = "onMergeConflict"
65+
)
66+
4367
type ChangesetTemplate struct {
4468
Title string `json:"title,omitempty" yaml:"title"`
4569
Body string `json:"body,omitempty" yaml:"body"`
@@ -90,13 +114,46 @@ func (oqor *OnQueryOrRepository) GetBranches() ([]string, error) {
90114
}
91115

92116
type Step struct {
93-
Run string `json:"run,omitempty" yaml:"run"`
94-
Container string `json:"container,omitempty" yaml:"container"`
95-
Env env.Environment `json:"env" yaml:"env"`
96-
Files map[string]string `json:"files,omitempty" yaml:"files,omitempty"`
97-
Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
98-
Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
99-
If any `json:"if,omitempty" yaml:"if,omitempty"`
117+
Run string `json:"run,omitempty" yaml:"run"`
118+
CodingAgent *CodingAgentStep `json:"codingAgent,omitempty" yaml:"codingAgent,omitempty"`
119+
BuildImage *BuildImageStep `json:"buildImage,omitempty" yaml:"buildImage,omitempty"`
120+
Container string `json:"container,omitempty" yaml:"container"`
121+
Image string `json:"image,omitempty" yaml:"image"`
122+
MaxAttempts int `json:"maxAttempts,omitempty" yaml:"maxAttempts,omitempty"`
123+
Env env.Environment `json:"env" yaml:"env"`
124+
Files map[string]string `json:"files,omitempty" yaml:"files,omitempty"`
125+
Outputs Outputs `json:"outputs,omitempty" yaml:"outputs,omitempty"`
126+
Mount []Mount `json:"mount,omitempty" yaml:"mount,omitempty"`
127+
If any `json:"if,omitempty" yaml:"if,omitempty"`
128+
}
129+
130+
type CodingAgentStep struct {
131+
Type string `json:"type,omitempty" yaml:"type"`
132+
Prompt string `json:"prompt,omitempty" yaml:"prompt"`
133+
}
134+
135+
type BuildImageStep struct {
136+
Run string `json:"run" yaml:"run"`
137+
BaseImage string `json:"baseImage" yaml:"baseImage"`
138+
}
139+
140+
// MarshalJSON canonicalizes the v3 `image:` field into `container:` on the
141+
// wire. Both fields exist on Step for ergonomic reasons (v3 specs use
142+
// `image:`, v1/v2 specs use `container:`), but src-cli's Step has only
143+
// `Container`. Without canonicalization, the prep-side cache key — computed
144+
// by JSON-marshaling Step — would include `image` while the executor side
145+
// (which round-trips through src-cli) would not, producing divergent keys
146+
// and silent cache misses for any v3 spec. See the regression test in
147+
// lib/batches/execution/cache.
148+
func (s Step) MarshalJSON() ([]byte, error) {
149+
// Use an alias type to avoid infinite recursion through MarshalJSON.
150+
type stepAlias Step
151+
canon := stepAlias(s)
152+
if canon.Container == "" {
153+
canon.Container = canon.Image
154+
}
155+
canon.Image = ""
156+
return json.Marshal(canon)
100157
}
101158

102159
func (s *Step) IfCondition() string {
@@ -161,33 +218,97 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
161218
return nil, err
162219
}
163220

164-
var errs error
221+
if spec.Version == 3 {
222+
// Mirror v3 `image:` into `container:` so in-memory consumers that
223+
// read step.Container (e.g. the executor transform) keep working.
224+
// JSON serialization is canonicalized separately in Step.MarshalJSON
225+
// so prep-side cache hashing matches src-cli/executor serialization.
226+
for i := range spec.Steps {
227+
spec.Steps[i].Container = spec.Steps[i].Image
228+
}
229+
}
165230

231+
var errs error
166232
if len(spec.Steps) != 0 && spec.ChangesetTemplate == nil {
167233
errs = errors.Append(errs, NewValidationError(errors.New("batch spec includes steps but no changesetTemplate")))
168234
}
169235

236+
// v3 specs do not support changesetTemplate.published — publication is
237+
// driven exclusively via the batchchangeagent tools. Reject the field at
238+
// parse time.
239+
if spec.Version == 3 && spec.ChangesetTemplate != nil && spec.ChangesetTemplate.Published != nil {
240+
errs = errors.Append(errs, NewValidationError(errors.New("changesetTemplate.published is not supported in batch spec version 3; drive publication via the publish_changesets tool instead")))
241+
}
242+
170243
for i, step := range spec.Steps {
171244
for _, mount := range step.Mount {
172-
if strings.ContainsAny(mount.Path, invalidMountCharacters) {
245+
if strings.Contains(mount.Path, invalidMountCharacters) {
173246
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d mount path contains invalid characters", i+1)))
174247
}
175-
if strings.ContainsAny(mount.Mountpoint, invalidMountCharacters) {
248+
if strings.Contains(mount.Mountpoint, invalidMountCharacters) {
176249
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d mount mountpoint contains invalid characters", i+1)))
177250
}
178251
}
252+
if step.CodingAgent != nil && step.Run != "" {
253+
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d: codingAgent and run cannot be combined in the same step", i+1)))
254+
}
255+
if step.BuildImage != nil && step.Run != "" {
256+
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d: buildImage and run cannot be combined in the same step", i+1)))
257+
}
179258
for name := range step.Files {
180-
if strings.ContainsAny(name, invalidMountCharacters) {
259+
if strings.Contains(name, invalidMountCharacters) {
181260
errs = errors.Append(errs, NewValidationError(errors.Newf("step %d files target path contains invalid characters", i+1)))
182261
}
183262
}
184263
}
185264

265+
if hookErr := validateHooks(&spec); hookErr != nil {
266+
errs = errors.Append(errs, hookErr)
267+
}
268+
186269
return &spec, errs
187270
}
188271

189-
// docker uses Golang's `encoding/csv` library to parse arguments passed to `--mount`
190-
const invalidMountCharacters = ",\"\n\r"
272+
// validateHooks performs Go-level validation of spec.Hooks beyond what the
273+
// JSON schema enforces. The schema already gates `hooks:` on `version: 3` and
274+
// rejects unknown event names. We re-check the version invariant here so
275+
// non-schema callers (and any future schema drift) still fail safely, and we
276+
// run the per-step mount-character validator that the schema cannot express.
277+
func validateHooks(spec *BatchSpec) error {
278+
if spec.ChangesetHooks == nil {
279+
return nil
280+
}
281+
282+
var errs error
283+
284+
if spec.Version != 3 {
285+
errs = errors.Append(errs, NewValidationError(errors.New("batch spec hooks require version: 3")))
286+
}
287+
288+
validate := func(event changesetHookEvent, action ChangesetHookAction) {
289+
for i, step := range action.Steps {
290+
for _, mount := range step.Mount {
291+
if strings.Contains(mount.Path, invalidMountCharacters) {
292+
errs = errors.Append(errs, NewValidationError(errors.Newf(
293+
"hooks.%s step %d mount path contains invalid characters", event, i+1,
294+
)))
295+
}
296+
if strings.Contains(mount.Mountpoint, invalidMountCharacters) {
297+
errs = errors.Append(errs, NewValidationError(errors.Newf(
298+
"hooks.%s step %d mount mountpoint contains invalid characters", event, i+1,
299+
)))
300+
}
301+
}
302+
}
303+
}
304+
305+
validate(ChangesetHookEventOnCIFailure, spec.ChangesetHooks.OnCIFailure)
306+
validate(ChangesetHookEventOnMergeConflict, spec.ChangesetHooks.OnMergeConflict)
307+
308+
return errs
309+
}
310+
311+
const invalidMountCharacters = ","
191312

192313
func (on *OnQueryOrRepository) String() string {
193314
if on.RepositoriesMatchingQuery != "" {
@@ -212,10 +333,6 @@ func (e BatchSpecValidationError) Error() string {
212333
return e.err.Error()
213334
}
214335

215-
func IsValidationError(err error) bool {
216-
return errors.HasType[*BatchSpecValidationError](err)
217-
}
218-
219336
// SkippedStepsForRepo calculates the steps required to run on the given repo.
220337
func SkippedStepsForRepo(spec *BatchSpec, repoName string, fileMatches []string) (skipped map[int]struct{}, err error) {
221338
skipped = map[int]struct{}{}

lib/batches/changeset_spec.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,6 @@ func (d *ChangesetSpec) IsImportingExisting() bool {
203203
return d.Type() == ChangesetSpecDescriptionTypeExisting
204204
}
205205

206-
// IsBranch returns whether the description is of type
207-
// ChangesetSpecDescriptionTypeBranch.
208-
func (d *ChangesetSpec) IsBranch() bool {
209-
return d.Type() == ChangesetSpecDescriptionTypeBranch
210-
}
211-
212206
// ChangesetSpecDescriptionType tells the consumer what the type of a
213207
// ChangesetSpecDescription is without having to look into the description.
214208
// Useful in the GraphQL when a HiddenChangesetSpec is returned.

lib/batches/overridable/bool.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@ type Bool struct {
77
rules rules
88
}
99

10-
// FromBool creates a Bool representing a static, scalar value.
11-
func FromBool(b bool) Bool {
12-
return Bool{
13-
rules: rules{simpleRule(b)},
14-
}
15-
}
16-
1710
// Value returns the bool value for the given repository.
1811
func (b *Bool) Value(name string) bool {
1912
v := b.rules.Match(name)

lib/batches/template/partial_eval.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ func parseAndPartialEval(input string, ctx *StepContext) (*template.Template, er
8989
Funcs(builtins).
9090
Funcs(ctx.ToFuncMap()).
9191
Parse(input)
92-
9392
if err != nil {
9493
return nil, err
9594
}
@@ -349,7 +348,7 @@ func isTrue(val reflect.Value) (truth bool) {
349348
return val.Bool()
350349
case reflect.Complex64, reflect.Complex128:
351350
return val.Complex() != 0
352-
case reflect.Chan, reflect.Func, reflect.Ptr, reflect.Interface:
351+
case reflect.Chan, reflect.Func, reflect.Pointer, reflect.Interface:
353352
return !val.IsNil()
354353
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
355354
return val.Int() != 0

lib/batches/template/template.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package template
22

3-
import "text/template"
3+
import (
4+
"strings"
5+
"text/template"
6+
)
47

58
func New(name, tmpl, option string, ctxs ...template.FuncMap) (*template.Template, error) {
69
t := template.New(name).Delims(startDelim, endDelim)
@@ -16,3 +19,7 @@ func New(name, tmpl, option string, ctxs ...template.FuncMap) (*template.Templat
1619

1720
return t.Parse(tmpl)
1821
}
22+
23+
func ContainsTemplateAction(tmpl string) bool {
24+
return strings.Contains(tmpl, startDelim)
25+
}

lib/batches/template/templating.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import (
1616
"github.com/sourcegraph/sourcegraph/lib/errors"
1717
)
1818

19-
const startDelim = "${{"
20-
const endDelim = "}}"
19+
const (
20+
startDelim = "${{"
21+
endDelim = "}}"
22+
)
2123

2224
var builtins = template.FuncMap{
2325
"join": strings.Join,
@@ -74,7 +76,6 @@ func ValidateBatchSpecTemplate(spec string) (bool, error) {
7476
// option "missingkey=error". See https://pkg.go.dev/text/template#Template.Option for
7577
// more.
7678
t, err := New("validateBatchSpecTemplate", spec, "missingkey=error", sfm, cstfm)
77-
7879
if err != nil {
7980
// Attempt to extract the specific template variable field that caused the error
8081
// to provide a clearer message.

lib/codeintel/upload/indexer_name.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ import (
2020
// is 10639 characters long.
2121
const MaxBufferSize = 128 * 1024
2222

23-
// ErrMetadataExceedsBuffer occurs when the first line of an LSIF index is too long to read.
24-
var ErrMetadataExceedsBuffer = errors.New("metaData vertex exceeds buffer")
25-
2623
// ErrInvalidMetaDataVertex occurs when the first line of an LSIF index is not a valid metadata vertex.
2724
var ErrInvalidMetaDataVertex = errors.New("invalid metaData vertex")
2825

lib/codeintel/upload/request.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ type uploadRequestOptions struct {
3030
// ErrUnauthorized occurs when the upload endpoint returns a 401 response.
3131
var ErrUnauthorized = errors.New("unauthorized upload")
3232

33+
// ErrForbidden occurs when the upload endpoint returns a 403 response.
34+
var ErrForbidden = errors.New("insufficient permissions")
35+
3336
// performUploadRequest performs an HTTP POST to the upload endpoint. The query string of the request
3437
// is constructed from the given request options and the body of the request is the unmodified reader.
3538
// If target is a non-nil pointer, it will be assigned the value of the upload identifier present
@@ -105,17 +108,17 @@ func performRequest(ctx context.Context, req *http.Request, httpClient Client, l
105108
// returns a boolean flag indicating if the function can be retried on failure (error-dependent).
106109
func decodeUploadPayload(resp *http.Response, body []byte, target *int) (bool, error) {
107110
if resp.StatusCode >= 300 {
111+
detail := extractBodyDetail(body)
112+
108113
if resp.StatusCode == http.StatusUnauthorized {
109-
return false, ErrUnauthorized
114+
return false, errors.Wrap(ErrUnauthorized, detail)
110115
}
111-
112-
suffix := ""
113-
if !bytes.HasPrefix(bytes.TrimSpace(body), []byte{'<'}) {
114-
suffix = fmt.Sprintf(" (%s)", bytes.TrimSpace(body))
116+
if resp.StatusCode == http.StatusForbidden {
117+
return false, errors.Wrap(ErrForbidden, detail)
115118
}
116119

117120
// Do not retry client errors
118-
return resp.StatusCode >= 500, errors.Errorf("unexpected status code: %d%s", resp.StatusCode, suffix)
121+
return resp.StatusCode >= 500, errors.Errorf("unexpected status code: %d: %s", resp.StatusCode, detail)
119122
}
120123

121124
if target == nil {
@@ -199,6 +202,16 @@ func makeUploadURL(opts uploadRequestOptions) (*url.URL, error) {
199202
return parsedUrl, nil
200203
}
201204

205+
// extractBodyDetail returns the response body as a suffix string for error messages.
206+
// Returns an empty string if the body is empty or appears to be HTML.
207+
func extractBodyDetail(body []byte) string {
208+
trimmed := bytes.TrimSpace(body)
209+
if len(trimmed) == 0 || bytes.HasPrefix(trimmed, []byte{'<'}) {
210+
return ""
211+
}
212+
return string(trimmed)
213+
}
214+
202215
func formatInt(v int) string {
203216
return strconv.FormatInt(int64(v), 10)
204217
}

lib/codeintel/upload/upload.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,6 @@ func uploadMultipartIndexParts(ctx context.Context, httpClient Client, opts Uplo
218218
}
219219

220220
for i, reader := range readers {
221-
i, reader := i, reader
222-
223221
pool.Go(func(ctx context.Context) error {
224222
// Determine size of this reader. If we're not the last reader in the slice,
225223
// then we're the maximum payload size. Otherwise, we're whatever is left.

0 commit comments

Comments
 (0)