Skip to content
quarry Artifact [c18bc7aa09]
Login

Artifact [c18bc7aa09]

Artifact c18bc7aa0963efc3b5a3f2d85e59bddf5a13d2e0d246d4d7bc677f9e15fe9fc2:


package ci

import (
	"encoding/json"
	"fmt"
	"regexp"
	"sort"
	"strings"
)

// An event is what started a launch, in the words a workflow's `on:`
// uses (RFC 0019 §on): a check-in pushed to a branch, a landing's merge
// with its target, or somebody asking for a run.
type event struct {
	kind   string // "push", "landing" or "workflow_dispatch"
	branch string // push: the check-in's branch; landing: the target
	// pushed is the branch a workflow_dispatch's check-in is on: a
	// re-run runs what the check-in's push ran, as Actions' re-run does,
	// which is how a deploy is repeated or rolled back.
	pushed string
}

// The events quarry emits, and the filters each takes. schedule is
// dagnats' own trigger and never starts here.
var onFilters = map[string]map[string]bool{
	"push":              {"branches": true, "branches-ignore": true},
	"landing":           {"branches": true, "branches-ignore": true},
	"workflow_dispatch": {},
	"schedule":          {},
}

// runsOn answers whether a workflow whose `on:` is on (the compiler's
// normalised map of event to filter, dagnats#728) runs for ev. An empty
// on is RFC 0019's default, push and landing, which is every launch but
// a schedule's. An error is the workflow's own refusal, in words: an
// event quarry does not emit, a filter it does not implement, or a
// pattern it cannot read.
func runsOn(on json.RawMessage, ev event) (bool, error) {
	if len(on) == 0 || string(on) == "null" {
		return true, nil
	}
	var events map[string]map[string]json.RawMessage
	if err := json.Unmarshal(on, &events); err != nil {
		return false, fmt.Errorf("on: is not a map of event to filter: %v", err)
	}
	if err := checkOn(events); err != nil {
		return false, err
	}
	switch ev.kind {
	case "push", "landing":
		f, ok := events[ev.kind]
		if !ok {
			return false, nil
		}
		return branchesMatch(f, ev.branch)
	case "workflow_dispatch":
		if _, ok := events["workflow_dispatch"]; ok {
			return true, nil
		}
		if f, ok := events["push"]; ok && ev.pushed != "" {
			return branchesMatch(f, ev.pushed)
		}
		return false, nil
	}
	return false, fmt.Errorf("quarry does not start workflows on %q", ev.kind)
}

// checkOn refuses what quarry cannot honour, so a workflow never runs on
// a filter it silently ignored: an unknown event, an unknown filter
// (paths among them, until quarry computes what a push changed), and
// branches beside branches-ignore, which Actions refuses too.
func checkOn(events map[string]map[string]json.RawMessage) error {
	names := make([]string, 0, len(events))
	for e := range events {
		names = append(names, e)
	}
	sort.Strings(names)
	for _, e := range names {
		allowed, ok := onFilters[e]
		if !ok {
			return fmt.Errorf("on: quarry emits no %q event (it has push, landing, workflow_dispatch and schedule)", e)
		}
		for k := range events[e] {
			switch {
			case k == "paths" || k == "paths-ignore":
				return fmt.Errorf("on.%s.%s: path filters are not supported yet; run a step that exits early instead", e, k)
			case !allowed[k]:
				return fmt.Errorf("on.%s: quarry reads no %q filter", e, k)
			}
		}
		if _, a := events[e]["branches"]; a {
			if _, b := events[e]["branches-ignore"]; b {
				return fmt.Errorf("on.%s: use branches or branches-ignore, not both", e)
			}
		}
	}
	return nil
}

// branchesMatch applies an event's branch filter as Actions does:
// branches lists patterns in order, a later "!pattern" excluding what
// an earlier one included; branches-ignore excludes any match. No
// filter matches every branch.
func branchesMatch(f map[string]json.RawMessage, branch string) (bool, error) {
	if raw, ok := f["branches"]; ok {
		pats, err := patterns(raw, "branches")
		if err != nil {
			return false, err
		}
		in := false
		for _, p := range pats {
			neg := strings.HasPrefix(p, "!")
			ok, err := globMatch(strings.TrimPrefix(p, "!"), branch)
			if err != nil {
				return false, err
			}
			if ok {
				in = !neg
			}
		}
		return in, nil
	}
	if raw, ok := f["branches-ignore"]; ok {
		pats, err := patterns(raw, "branches-ignore")
		if err != nil {
			return false, err
		}
		for _, p := range pats {
			ok, err := globMatch(p, branch)
			if err != nil {
				return false, err
			}
			if ok {
				return false, nil
			}
		}
	}
	return true, nil
}

func patterns(raw json.RawMessage, key string) ([]string, error) {
	var list []string
	if err := json.Unmarshal(raw, &list); err == nil {
		return list, nil
	}
	var one string
	if err := json.Unmarshal(raw, &one); err == nil {
		return []string{one}, nil
	}
	return nil, fmt.Errorf("%s: want a pattern or a list of patterns", key)
}

// globMatch is Actions' filter pattern language: * matches within one
// path segment, ** across them, ? one character, + one or more of the
// character before it, and [...] a class.
func globMatch(pattern, name string) (bool, error) {
	var b strings.Builder
	b.WriteString("^")
	for i := 0; i < len(pattern); i++ {
		c := pattern[i]
		switch {
		case c == '*' && i+1 < len(pattern) && pattern[i+1] == '*':
			b.WriteString(".*")
			i++
		case c == '*':
			b.WriteString("[^/]*")
		case c == '?':
			b.WriteString(".")
		case c == '+':
			b.WriteString("+")
		case c == '[':
			end := strings.IndexByte(pattern[i:], ']')
			if end < 0 {
				return false, fmt.Errorf("pattern %q: unclosed [", pattern)
			}
			b.WriteString(pattern[i : i+end+1])
			i += end
		default:
			b.WriteString(regexp.QuoteMeta(string(c)))
		}
	}
	b.WriteString("$")
	re, err := regexp.Compile(b.String())
	if err != nil {
		return false, fmt.Errorf("pattern %q: %v", pattern, err)
	}
	return re.MatchString(name), nil
}