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
}