@@ -35,11 +35,39 @@ type BatchSpec struct {
3535 Description string `json:"description,omitempty" yaml:"description"`
3636 On []OnQueryOrRepository `json:"on,omitempty" yaml:"on"`
3737 Workspaces []WorkspaceConfiguration `json:"workspaces,omitempty" yaml:"workspaces"`
38+ Checkout * CheckoutOptions `json:"checkout,omitempty" yaml:"checkout,omitempty"`
3839 Steps []Step `json:"steps,omitempty" yaml:"steps"`
3940 TransformChanges * TransformChanges `json:"transformChanges,omitempty" yaml:"transformChanges,omitempty"`
4041 ImportChangesets []ImportChangeset `json:"importChangesets,omitempty" yaml:"importChangesets"`
4142 ChangesetTemplate * ChangesetTemplate `json:"changesetTemplate,omitempty" yaml:"changesetTemplate"`
42- ChangesetHooks * ChangesetHooks `json:"changesetHooks,omitempty" yaml:"hooks,omitempty"`
43+ ChangesetHooks * ChangesetHooks `json:"changesetHooks,omitempty" yaml:"changesetHooks,omitempty"`
44+ }
45+
46+ // DefaultCheckoutFetchDepth is the git fetch depth used for workspace checkouts
47+ // when the spec does not configure checkout.fetchDepth. It fetches only the
48+ // target commit (a shallow clone), which is sufficient for most batch changes.
49+ const DefaultCheckoutFetchDepth = 1
50+
51+ // CheckoutOptions controls how repositories are checked out for workspace
52+ // execution. Only allowed when Version is 3.
53+ type CheckoutOptions struct {
54+ // FetchDepth controls how many commits of git history are fetched into the
55+ // workspace checkout. A value of 0 fetches the full history; any positive
56+ // value fetches that many commits. When nil, DefaultCheckoutFetchDepth is
57+ // used. History is required by tasks that inspect git history (e.g.
58+ // "update codeowners based on git history") or changeset hooks such as
59+ // onMergeConflict.
60+ FetchDepth * int `json:"fetchDepth,omitempty" yaml:"fetchDepth,omitempty"`
61+ }
62+
63+ // CheckoutFetchDepth returns the configured git fetch depth for workspace
64+ // checkouts, applying DefaultCheckoutFetchDepth when unset. A return value of 0
65+ // means full history.
66+ func (s * BatchSpec ) CheckoutFetchDepth () int {
67+ if s .Checkout == nil || s .Checkout .FetchDepth == nil {
68+ return DefaultCheckoutFetchDepth
69+ }
70+ return * s .Checkout .FetchDepth
4371}
4472
4573// Hooks declares side-effect actions to run at well-defined changeset
@@ -54,16 +82,76 @@ type ChangesetHooks struct {
5482// Hook actions reuse the Step shape from the top-level steps block.
5583type ChangesetHookAction struct {
5684 Steps []Step `json:"steps,omitempty" yaml:"steps,omitempty"`
85+ // Commit configures the follow-up commits this hook produces. Its message
86+ // and author are resolved independently: when the message is empty a
87+ // per-event default is used, and when the author is nil the
88+ // changesetTemplate's author is inherited (which itself falls back to the
89+ // changeset's author). It may be nil if the hook does not configure a
90+ // commit at all.
91+ Commit * ExpandedGitCommitDescription `json:"commit,omitempty" yaml:"commit,omitempty"`
92+ }
93+
94+ // HasCommit reports whether the hook action declares its own commit
95+ // information (a message and/or an author).
96+ func (a ChangesetHookAction ) HasCommit () bool {
97+ return a .Commit != nil && (a .Commit .Message != "" || a .Commit .Author != nil )
98+ }
99+
100+ // DefaultCommitMessage returns the commit message used for follow-up commits
101+ // produced by this hook event when the hook action does not provide its own
102+ // message. The returned value may contain changeset template variables (e.g.
103+ // ${{ repository.branch }}) that are rendered when the follow-up commit is
104+ // built.
105+ func (e ChangesetHookEvent ) DefaultCommitMessage () string {
106+ switch e {
107+ case ChangesetHookEventOnMergeConflict :
108+ return "Fix for merge conflict on ${{ repository.branch }}"
109+ case ChangesetHookEventOnCIFailure :
110+ return "Fix for CI failure on ${{ repository.branch }}"
111+ default :
112+ return "Changeset hook fix on ${{ repository.branch }}"
113+ }
114+ }
115+
116+ // ActionForEvent returns the hook action configured for the given event, and
117+ // whether the event is known.
118+ func (h * ChangesetHooks ) ActionForEvent (event ChangesetHookEvent ) (ChangesetHookAction , bool ) {
119+ switch event {
120+ case ChangesetHookEventOnCIFailure :
121+ return h .OnCIFailure , true
122+ case ChangesetHookEventOnMergeConflict :
123+ return h .OnMergeConflict , true
124+ default :
125+ return ChangesetHookAction {}, false
126+ }
57127}
58128
59- type changesetHookEvent string
129+ type ChangesetHookEvent string
60130
61131// Hook event names. Kept here so callers don't pass typoed strings.
62132const (
63- ChangesetHookEventOnCIFailure changesetHookEvent = "onCIFailure"
64- ChangesetHookEventOnMergeConflict changesetHookEvent = "onMergeConflict"
133+ ChangesetHookEventOnCIFailure ChangesetHookEvent = "onCIFailure"
134+ ChangesetHookEventOnMergeConflict ChangesetHookEvent = "onMergeConflict"
65135)
66136
137+ // AllChangesetHookEvents is the canonical list of supported changeset hook
138+ // events. Add new events here so callers (validation, filtering, etc.) stay in
139+ // sync from a single source of truth.
140+ var AllChangesetHookEvents = []ChangesetHookEvent {
141+ ChangesetHookEventOnCIFailure ,
142+ ChangesetHookEventOnMergeConflict ,
143+ }
144+
145+ // Valid reports whether e is a known changeset hook event.
146+ func (e ChangesetHookEvent ) Valid () bool {
147+ for _ , known := range AllChangesetHookEvents {
148+ if e == known {
149+ return true
150+ }
151+ }
152+ return false
153+ }
154+
67155type ChangesetTemplate struct {
68156 Title string `json:"title,omitempty" yaml:"title"`
69157 Body string `json:"body,omitempty" yaml:"body"`
@@ -127,16 +215,47 @@ type Step struct {
127215 If any `json:"if,omitempty" yaml:"if,omitempty"`
128216}
129217
218+ type CodingAgentType string
219+
220+ const (
221+ CodingAgentTypeCodex CodingAgentType = "codex"
222+ CodingAgentTypeClaudeCode CodingAgentType = "claude-code"
223+ )
224+
130225type CodingAgentStep struct {
131- Type string `json:"type,omitempty" yaml:"type"`
132- Prompt string `json:"prompt,omitempty" yaml:"prompt"`
226+ Type CodingAgentType `json:"type,omitempty" yaml:"type"`
227+ Prompt string `json:"prompt,omitempty" yaml:"prompt"`
133228}
134229
135230type BuildImageStep struct {
136231 Run string `json:"run" yaml:"run"`
137232 BaseImage string `json:"baseImage" yaml:"baseImage"`
138233}
139234
235+ // KanikoImage is the container image used to build new OCI images from a base
236+ // image and a run script (see buildImage steps). It is referenced both when
237+ // desugaring buildImage steps into run steps and by the executor when deciding
238+ // whether a step is a buildImage-derived build container. Can be exchanged for
239+ // other image build tooling, as long as the build script is updated as well.
240+ //
241+ // Kaniko builds run unprivileged under docker's default seccomp/apparmor
242+ // profiles, so no extra docker flags are required for this container.
243+ //
244+ // The -alpine variant is required: the desugared buildImage step runs as an
245+ // ordinary `run` step, so the runner (batch-exec/src-cli) probes the image by
246+ // running `<shell> -c mktemp` and overrides the entrypoint with /bin/sh. The
247+ // plain kaniko image has no shell at all, and the -debug variant is built FROM
248+ // scratch without a /tmp directory, so its mktemp fails and the probe rejects
249+ // the image. -alpine has /bin/sh, /tmp, and the busybox wget/base64 tools used
250+ // by the generated build script. It also presets KANIKO_PRE_CLEANUP=1 and
251+ // KANIKO_CLEANUP=1, which wipe the container filesystem around the build (the
252+ // generated script must not rely on external binaries after invoking kaniko).
253+ //
254+ // Pinned to a digest so the build is reproducible and not subject to the tag
255+ // being re-pushed; the tag is retained for readability. When bumping, update
256+ // both the tag and the @sha256 digest (the multi-arch index digest).
257+ const KanikoImage = "ghcr.io/osscontainertools/kaniko:v1.27.6-alpine@sha256:795a358f6c22a9fcd66bb7e14bd97728155e1c171ca951f3c3ba6501054234ce"
258+
140259// MarshalJSON canonicalizes the v3 `image:` field into `container:` on the
141260// wire. Both fields exist on Step for ergonomic reasons (v3 specs use
142261// `image:`, v1/v2 specs use `container:`), but src-cli's Step has only
@@ -240,6 +359,12 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
240359 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" )))
241360 }
242361
362+ // v3 specs do not support importChangesets — batch change agents only
363+ // manage changesets they own.
364+ if spec .Version == 3 && len (spec .ImportChangesets ) != 0 {
365+ errs = errors .Append (errs , NewValidationError (errors .New ("importChangesets is not supported in batch spec version 3" )))
366+ }
367+
243368 for i , step := range spec .Steps {
244369 for _ , mount := range step .Mount {
245370 if strings .Contains (mount .Path , invalidMountCharacters ) {
@@ -255,20 +380,39 @@ func parseBatchSpec(schema string, data []byte) (*BatchSpec, error) {
255380 if step .BuildImage != nil && step .Run != "" {
256381 errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d: buildImage and run cannot be combined in the same step" , i + 1 )))
257382 }
258- for name := range step .Files {
259- if strings .Contains (name , invalidMountCharacters ) {
260- errs = errors .Append (errs , NewValidationError (errors .Newf ("step %d files target path contains invalid characters" , i + 1 )))
261- }
262- }
263383 }
264384
265385 if hookErr := validateHooks (& spec ); hookErr != nil {
266386 errs = errors .Append (errs , hookErr )
267387 }
268388
389+ if checkoutErr := validateCheckout (& spec ); checkoutErr != nil {
390+ errs = errors .Append (errs , checkoutErr )
391+ }
392+
269393 return & spec , errs
270394}
271395
396+ // validateCheckout performs Go-level validation of spec.Checkout beyond what
397+ // the JSON schema enforces. The schema already gates `checkout:` on `version: 3`
398+ // and constrains `fetchDepth` to a non-negative integer. We re-check the version
399+ // invariant here so non-schema callers (and any future schema drift) still fail
400+ // safely.
401+ func validateCheckout (spec * BatchSpec ) error {
402+ if spec .Checkout == nil {
403+ return nil
404+ }
405+
406+ var errs error
407+ if spec .Version != 3 {
408+ errs = errors .Append (errs , NewValidationError (errors .New ("batch spec checkout requires version: 3" )))
409+ }
410+ if spec .Checkout .FetchDepth != nil && * spec .Checkout .FetchDepth < 0 {
411+ errs = errors .Append (errs , NewValidationError (errors .New ("checkout.fetchDepth must be greater than or equal to 0" )))
412+ }
413+ return errs
414+ }
415+
272416// validateHooks performs Go-level validation of spec.Hooks beyond what the
273417// JSON schema enforces. The schema already gates `hooks:` on `version: 3` and
274418// rejects unknown event names. We re-check the version invariant here so
@@ -285,8 +429,30 @@ func validateHooks(spec *BatchSpec) error {
285429 errs = errors .Append (errs , NewValidationError (errors .New ("batch spec hooks require version: 3" )))
286430 }
287431
288- validate := func (event changesetHookEvent , action ChangesetHookAction ) {
432+ validate := func (event ChangesetHookEvent , action ChangesetHookAction ) {
289433 for i , step := range action .Steps {
434+ // Hook steps use the v3 step shape, which requires an image and
435+ // exactly one of a run command or a codingAgent. The JSON schema
436+ // also enforces this (see the version 3 conditional in
437+ // batch_spec.schema.json), so for spec strings parsed through the
438+ // schema these checks are a defense-in-depth backstop; we keep them
439+ // here so non-schema callers (and any future schema drift) still
440+ // fail safely.
441+ if step .Image == "" {
442+ errs = errors .Append (errs , NewValidationError (errors .Newf (
443+ "hooks.%s step %d must specify an image" , event , i + 1 ,
444+ )))
445+ }
446+ if step .Run == "" && step .CodingAgent == nil {
447+ errs = errors .Append (errs , NewValidationError (errors .Newf (
448+ "hooks.%s step %d must specify either run or codingAgent" , event , i + 1 ,
449+ )))
450+ }
451+ if step .CodingAgent != nil && step .Run != "" {
452+ errs = errors .Append (errs , NewValidationError (errors .Newf (
453+ "hooks.%s step %d: codingAgent and run cannot be combined in the same step" , event , i + 1 ,
454+ )))
455+ }
290456 for _ , mount := range step .Mount {
291457 if strings .Contains (mount .Path , invalidMountCharacters ) {
292458 errs = errors .Append (errs , NewValidationError (errors .Newf (
@@ -370,12 +536,14 @@ func SkippedStepsForRepo(spec *BatchSpec, repoName string, fileMatches []string)
370536 return skipped , nil
371537}
372538
373- // RequiredEnvVars inspects all steps for outer environment variables used and
374- // compiles a deduplicated list from those.
375- func (s * BatchSpec ) RequiredEnvVars () []string {
539+ // RequiredEnvVarsForSteps inspects the given steps for outer environment
540+ // variables used and compiles a deduplicated list from those. Callers pass the
541+ // specific steps they care about (e.g. a spec's top-level steps or its
542+ // changeset hook steps).
543+ func RequiredEnvVarsForSteps (steps []Step ) []string {
376544 requiredMap := map [string ]struct {}{}
377545 required := []string {}
378- for _ , step := range s . Steps {
546+ for _ , step := range steps {
379547 for _ , v := range step .Env .OuterVars () {
380548 if _ , ok := requiredMap [v ]; ! ok {
381549 requiredMap [v ] = struct {}{}
0 commit comments