package ci
import (
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// A workflow with an environment: job, or one that names any secret,
// deploys or holds something the forge gives to reviewed code only:
// every secret is refused to a merge, a repository's own included. It
// never runs on a merge's tree and never inherits a verdict (RFC 0009
// §once). A secret is a ${{ secrets.NAME }} reference.
var secretRef = regexp.MustCompile(`\$\{\{\s*secrets\.`)
// reviewedOnly reports a pipeline that needs what only reviewed code
// gets: an environment, or a secret. A ci.yml is parsed, so a key is a
// key however it is spelled (quoted, in a flow mapping) and text that
// only looks like one (a comment, a line of a script) is not; one that
// does not parse is kept on the tip, where the engine refuses it in
// words. In JSON, an "environment" or a "secrets" key anywhere (a
// job's declaration) counts, as does a ${{ secrets.NAME }} reference.
func reviewedOnly(def []byte, isJSON bool) bool {
if !isJSON {
return yamlReviewedOnly(def)
}
if secretRef.Match(def) {
return true
}
var doc any
if json.Unmarshal(def, &doc) != nil {
return false
}
var walk func(v any) bool
walk = func(v any) bool {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
if (k == "environment" || k == "secrets") && !empty(e) {
return true
}
if walk(e) {
return true
}
}
case []any:
for _, e := range x {
if walk(e) {
return true
}
}
}
return false
}
return walk(doc)
}
// yamlReviewedOnly is reviewedOnly for a ci.yml: a job (jobs.<id>)
// naming an environment, or a secrets reference in any value.
func yamlReviewedOnly(def []byte) bool {
var doc yaml.Node
if err := yaml.Unmarshal(def, &doc); err != nil {
return true
}
var refers func(n *yaml.Node) bool
refers = func(n *yaml.Node) bool {
if n.Kind == yaml.ScalarNode && secretRef.MatchString(n.Value) {
return true
}
for _, c := range n.Content {
if refers(c) {
return true
}
}
return false
}
if refers(&doc) {
return true
}
if len(doc.Content) == 0 {
return false
}
jobs := value(doc.Content[0], "jobs")
if jobs == nil || jobs.Kind != yaml.MappingNode {
return false
}
for i := 1; i < len(jobs.Content); i += 2 {
if env := value(jobs.Content[i], "environment"); env != nil && !(env.Kind == yaml.ScalarNode && (env.Value == "" || env.Tag == "!!null")) {
return true
}
}
return false
}
// value is a mapping's value for key, or nil.
func value(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// empty is a JSON value that says nothing: null, "", [] or {}.
func empty(v any) bool {
switch x := v.(type) {
case nil:
return true
case string:
return x == ""
case []any:
return len(x) == 0
case map[string]any:
return len(x) == 0
}
return false
}
// yamlInherit is a ci.yml's top-level inherit: line, and its value.
var yamlInherit = regexp.MustCompile(`(?m)^inherit[ \t]*:[ \t]*([^#\n]*?)[ \t]*(?:#[^\n]*)?$`)
// inheritOf reads a pipeline's top-level inherit (RFC 0009
// §inheritance): false makes the default branch run it even when its
// merge was tested as a pair. Absent is true. Anything but true or
// false is an error: guessing would be wrong half the time.
func inheritOf(def []byte, isJSON bool) (bool, error) {
if isJSON {
var p struct {
Inherit json.RawMessage `json:"inherit"`
}
if json.Unmarshal(def, &p) != nil || p.Inherit == nil {
return true, nil // the engine's registration says what is wrong with it
}
var b bool
if err := json.Unmarshal(p.Inherit, &b); err != nil {
return false, fmt.Errorf("inherit is %s; it may be true or false", p.Inherit)
}
return b, nil
}
m := yamlInherit.FindSubmatch(def)
if m == nil {
return true, nil
}
switch v := strings.Trim(string(m[1]), `"'`); v {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("inherit is %q; it may be true or false", v)
}
}
// withoutInherit blanks a ci.yml's inherit: line before the engine
// compiles it: the key is quarry's, and the compiler, which knows only
// the workflow's own keys, refuses any other. The line stays, empty, so
// the compiler's line numbers still point at the author's lines.
func withoutInherit(spec []byte) []byte {
return yamlInherit.ReplaceAll(spec, nil)
}
// heir finds what a default-branch check-in may inherit: the runs of
// the pair it merged, when its tree is that pair's. The parents are
// the target tip and the branch tip; which is which is not recorded,
// so both spellings are asked, as carryForward does. Built once per
// launch, on the first pipeline that asks.
type heir struct {
asked bool
runs []RunRecord // the equal pair's, newest first
}
func (h *heir) of(st Store, repo string, r Repo, c Checkin) []RunRecord {
if h.asked {
return h.runs
}
h.asked = true
parents, err := r.ParentsOf(c.Hash)
if err != nil || len(parents) != 2 {
return nil
}
for _, tips := range [][2]string{{parents[1], parents[0]}, {parents[0], parents[1]}} {
runs, err := st.RunsOf(repo, Pair(tips[0], tips[1]))
if err != nil || len(runs) == 0 {
continue
}
same, err := r.SameTree(c.Hash, tips[0], tips[1])
if err != nil {
log.Printf("ci: %s %.12s: comparing with its pair: %v; it runs", repo, c.Hash, err)
return nil
}
if same {
h.runs = runs
return runs
}
}
return nil
}
// verdict is the pair run c inherits for pipeline p, whose definition
// at c is def: the pair's newest run of it, when that ended in a pass
// or a fail and ran def byte for byte. An equal tree reads equal
// definitions, but a run recorded before pairs read the merged tree
// ran the branch tip's, which the target may have changed since. A
// run still going, cancelled, in error, or of another definition is no
// verdict, and p runs.
func (h *heir) verdict(st Store, repo string, r Repo, c Checkin, p string, def []byte) (RunRecord, bool) {
for _, run := range h.of(st, repo, r, c) {
if run.Pipeline != p {
continue
}
decided := (Run{Status: run.Status}).Passed() || run.Status == "failed"
return run, decided && run.Def != "" && run.Def == DefHash(def)
}
return RunRecord{}, false
}