Skip to content
quarry Artifact [a4b9811901]
Login

Artifact [a4b9811901]

Artifact a4b98119012a247270aab373c48f05b2d74bbc9275642a3bfe0d1cdba8337945:


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, a tag added to a check-in (RFC 0020), or somebody
// asking for a run.
type event struct {
	kind   string // "push", "landing", "tag" or "workflow_dispatch"
	branch string // push and tag: the check-in's branch; landing: the target
	tag    string // tag: the tag's name
	// 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},
	"tag":               {"tags": true, "tags-ignore": true},
	"workflow_dispatch": {},
	"schedule":          {},
}

// defaultTags is which tags a tag workflow that names none starts on:
// the spelling fossil, sqlite, zit and quarry give a release.
var defaultTags = []string{"version-*"}

// 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 and a tag'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 ev.kind != "tag", 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 "tag":
		f, ok := events["tag"]
		if !ok {
			return false, nil
		}
		return refsMatch(f, "tags", defaultTags, ev.tag)
	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 reports what in a workflow's normalised on: quarry cannot
// honour, the same refusal the trigger makes, so the pipeline editor's
// Validate says it before a push finds it. An empty on is fine.
func CheckOn(on json.RawMessage) error {
	if len(on) == 0 || string(on) == "null" {
		return nil
	}
	var events map[string]map[string]json.RawMessage
	if err := json.Unmarshal(on, &events); err != nil {
		return fmt.Errorf("on: is not a map of event to filter: %v", err)
	}
	return checkOn(events)
}

// 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 or tags beside tags-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, tag, 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)
			}
		}
		for _, k := range []string{"branches", "tags"} {
			_, a := events[e][k]
			_, b := events[e][k+"-ignore"]
			if a && b {
				return fmt.Errorf("on.%s: use %s or %s-ignore, not both", e, k, k)
			}
		}
	}
	return nil
}

// branchesMatch applies an event's branch filter. No filter matches
// every branch.
func branchesMatch(f map[string]json.RawMessage, branch string) (bool, error) {
	return refsMatch(f, "branches", nil, branch)
}

// refsMatch applies a branches or tags filter as Actions does: key
// lists patterns in order, a later "!pattern" excluding what an earlier
// one included; key-ignore excludes any match from what key, or dflt in
// its absence, admits. A nil dflt admits every name.
func refsMatch(f map[string]json.RawMessage, key string, dflt []string, name string) (bool, error) {
	pats := dflt
	if raw, ok := f[key]; ok {
		var err error
		if pats, err = patterns(raw, key); err != nil {
			return false, err
		}
	}
	in := pats == nil
	for _, p := range pats {
		neg := strings.HasPrefix(p, "!")
		ok, err := globMatch(strings.TrimPrefix(p, "!"), name)
		if err != nil {
			return false, err
		}
		if ok {
			in = !neg
		}
	}
	if !in {
		return false, nil
	}
	if raw, ok := f[key+"-ignore"]; ok {
		pats, err := patterns(raw, key+"-ignore")
		if err != nil {
			return false, err
		}
		for _, p := range pats {
			ok, err := globMatch(p, name)
			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
}