Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,51 @@ would be represented in go as:

This go struct would be marshaled back out to YAML equivalent to the original input.

## Checkout

The `checkout` block configures git checkout behaviour for a pipeline or a command step. The simplest case opts a step out of checkout entirely:

```yaml
steps:
- command: echo "no git checkout for me"
checkout:
skip: true
```

`Checkout.Skip` is a `*bool` so the model preserves the difference between `skip: true`, `skip: false`, and an absent `skip`. This matters because `skip: false` at the step level explicitly overrides any pipeline-level or agent-level default that would otherwise skip checkout, while an absent `skip` inherits whatever default applies. Round-trips preserve the distinction; `skip: false` does not collapse to an empty mapping.

A pipeline-level `checkout` is inherited by command steps that do not set their own. The step value wins per leaf when both are set:

```yaml
checkout:
skip: true

steps:
- command: echo "inherits skip: true from the pipeline"

- command: echo "explicit override - checkout runs"
checkout:
skip: false
```

After calling `step.MergeCheckoutFromPipeline(pipeline.Checkout)` on the second step, the merged result has `skip: false` (step wins). The first step inherits `skip: true` from the pipeline.

`checkout: false` as a shorthand is rejected at unmarshal time; `checkout` is a multi-field namespace, so opt-out is spelled `checkout: { skip: true }`.

`Checkout.Depth` is a `*int` for the same reason as `Skip`, this is distinguishable from any explicit value, so an inherited pipeline level depth can be cleanly overridden at the step level. The backend validates `depth >= 1`, this library does not.

```yaml
checkout:
depth: 10

steps:
- command: echo "Shallow depth defaulting to 10 from build level checkout"

- command: echo "Deeper shallow at the step level"
checkout:
depth: 50
```

## What's up with the ordered module?

While implementing the pipeline module, we ran into a problem: in some cases, in the buildkite pipeline.yaml, the order of map fields is significant. Because of this, whenever the pipeline gets unmarshaled from YAML or JSON, it needs to be stored in a way that preserves the order of the fields. The `ordered` module is a simple implementation of an ordered map. In most cases, when the pipeline is dealing with user-input maps, it will store them internally as `ordered.Map`s. When the pipeline is marshaled back out to YAML or JSON, the `ordered.Map`s will be marshaled in the correct order.
Expand Down
105 changes: 105 additions & 0 deletions checkout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package pipeline

import (
"encoding/json"
"fmt"

"github.com/buildkite/go-pipeline/ordered"
)

var _ interface {
json.Marshaler
ordered.Unmarshaler
selfInterpolater
} = (*Checkout)(nil)

var errUnsupportedCheckoutType = fmt.Errorf("unsupported type for checkout")

// Checkout models the checkout configuration for a pipeline or command step.
type Checkout struct {
// Skip is *bool to preserve the tristate distinction (true / false / absent).
// `bool` plus `omitempty` would collapse `skip: false` and an absent `skip`
// field into the same empty output.
Skip *bool `yaml:"skip,omitempty"`

// Depth as *int to allow integers and not set
Depth *int `yaml:"depth,omitempty"`

// LFS enables Git LFS when true. Defaults to false (zero value).
LFS bool `json:"lfs,omitempty" yaml:"lfs,omitempty"`
Comment thread
stephanieatte marked this conversation as resolved.
Outdated

// RemainingFields stores any other top-level mapping items so they at least
// survive an unmarshal-marshal round-trip.
RemainingFields map[string]any `yaml:",inline"`
}

// MarshalJSON marshals the checkout to JSON. Special handling is needed because
// yaml.v3 has "inline" but encoding/json has no concept of it.
func (c *Checkout) MarshalJSON() ([]byte, error) {
return inlineFriendlyMarshalJSON(c)
}

// IsEmpty reports whether the checkout is empty, used by signing.
func (c *Checkout) IsEmpty() bool {
return c == nil || (c.Skip == nil && c.Depth == nil && !c.LFS && len(c.RemainingFields) == 0)
}

// UnmarshalOrdered unmarshals a Checkout from an ordered map. Bool inputs are
// rejected; see the error message for the supported form.
func (c *Checkout) UnmarshalOrdered(o any) error {
switch v := o.(type) {
case bool:
return fmt.Errorf("unmarshaling checkout: bool is not a valid value; use checkout.skip (e.g. checkout: { skip: %t }) instead", v)

case *ordered.MapSA:
type wrappedCheckout Checkout
if err := ordered.Unmarshal(o, (*wrappedCheckout)(c)); err != nil {
return fmt.Errorf("unmarshaling checkout: %w", err)
}
return nil

default:
return fmt.Errorf("unmarshaling checkout: %w: got %T, want a mapping with checkout.skip and other fields", errUnsupportedCheckoutType, o)
}
}

// interpolate is a no-op today: Skip is *bool, and RemainingFields is not
// traversed.
func (c *Checkout) interpolate(stringTransformer) error {
return nil
}

// mergeFrom merges parent values into c. Child wins per top-level key;
// parent contributes only keys the child does not set.
func (c *Checkout) mergeFrom(parent *Checkout) {
if c == nil || parent == nil {
return
}

if c.Skip == nil && parent.Skip != nil {
v := *parent.Skip
c.Skip = &v
}

if c.Depth == nil && parent.Depth != nil {
v := *parent.Depth
c.Depth = &v
}

if !c.LFS && parent.LFS {
c.LFS = parent.LFS
}

if len(parent.RemainingFields) == 0 {
return
}
if c.RemainingFields == nil {
c.RemainingFields = make(map[string]any, len(parent.RemainingFields))
}
for k, pv := range parent.RemainingFields {
if _, ok := c.RemainingFields[k]; ok {
continue
}
c.RemainingFields[k] = pv
}
}
Loading