package ci
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
)
// A TAG ADDED TO A CHECK-IN STARTS ITS TAG WORKFLOWS, ONCE (RFC 0020).
//
// The run's subject is the tagged check-in, recorded as an ordinary run
// of it, so secret delivery judges it by that check-in's branch as it
// judges every run. Only workflows whose on.tag matches start: not the
// push tests, not a JSON pipeline. A tag run supersedes nothing, and a
// first sight starts nothing.
func TestATagStartsItsWorkflowsOnTheTaggedCheckin(t *testing.T) {
var mu sync.Mutex
var started []string
var labels []map[string]string
superseded := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case r.URL.Path == "/v1/ci/compile":
var body struct{ Name, Spec string }
json.NewDecoder(r.Body).Decode(&body)
on, _, _ := strings.Cut(body.Spec, "\n")
fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true,"on":%s}`, body.Name, on)
case r.URL.Path == "/workflows":
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"status":"registered"}`)
case r.URL.Path == "/runs" && r.Method == http.MethodPost:
var body struct {
Input map[string]any `json:"input"`
Labels map[string]string `json:"labels"`
}
json.NewDecoder(r.Body).Decode(&body)
started = append(started, fmt.Sprint(body.Input["checkin"], " ", body.Input["branch"], " ", body.Input["pipeline"]))
labels = append(labels, body.Labels)
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"run_id":"r"}`)
case r.URL.Path == "/runs":
superseded++
fmt.Fprint(w, `[]`)
}
}))
defer srv.Close()
take := func() []string {
mu.Lock()
defer mu.Unlock()
out := started
started = nil
slices.Sort(out)
return out
}
e := New(srv.URL)
st := &fakeStore{mark: map[string]string{"alpha": "t1"}}
repo := &fakeRepo{
files: []string{"ci/build.json", "ci/release.yml", "ci/test.yml"},
blobs: map[string][]byte{
"ci/build.json": []byte(`{"name":"build","version":"1.0","steps":[]}`),
"ci/test.yml": []byte(`{"push":{},"landing":{}}` + "\njobs: {}"),
"ci/release.yml": []byte(`{"tag":{"tags":["version-*"]}}` + "\njobs: {}"),
},
}
at := func(hash, branch string) Checkin { return Checkin{Hash: hash, Branch: branch, User: "dan"} }
mark := func(date, name, target string) TagMark {
return TagMark{Date: date, Src: "src-" + date, Target: target, Name: name}
}
old := Tagged{TagMark: mark("2026-09-01", "version-0.1.0", "t0"), At: at("t0", "trunk")}
// First sight: the newest tag becomes the watermark and nothing runs.
repo.tags = []Tagged{old}
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := take(); len(got) != 0 {
t.Errorf("FAIL: first sight started %v; want nothing", got)
}
if m, ok := st.CITagWatermark("alpha"); !ok || m != old.TagMark {
t.Errorf("FAIL: first sight left the tag watermark at %+v, %v; want the newest tag", m, ok)
}
release := Tagged{TagMark: mark("2026-10-01", "version-0.2.0", "t1"), At: at("t1", "trunk")}
moving := Tagged{TagMark: mark("2026-10-01b", "release", "t1"), At: at("t1", "trunk")}
repo.tags = []Tagged{old, release, moving}
superseded = 0
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := take(); !slices.Equal(got, []string{"t1 trunk ci/release.yml"}) {
t.Errorf("FAIL: tagging t1 version-0.2.0 and release started %v; want the release workflow once, on t1", got)
}
if len(st.runs) != 1 || !strings.HasPrefix(st.runs[0], "r t1 ") {
t.Errorf("FAIL: recorded runs %v; want one run of t1, the tagged check-in", st.runs)
}
if len(labels) != 1 || labels[0]["tag"] != "version-0.2.0" || labels[0]["branch"] != "trunk" || labels[0]["checkin"] != "t1" {
t.Errorf("FAIL: the tag run's labels %v; want its tag, branch and check-in", labels)
}
if superseded != 0 {
t.Errorf("FAIL: a tag run swept %d time(s) for runs to supersede; want none", superseded)
}
if m, _ := st.CITagWatermark("alpha"); m != moving.TagMark {
t.Errorf("FAIL: the tag watermark is %+v; want it past the non-matching release tag too", m)
}
// Nothing new: nothing starts again.
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := take(); len(got) != 0 {
t.Errorf("FAIL: a round with no new tag started %v", got)
}
// A tag on a branch is still a tag event; whether its run receives
// secrets is the environment's branch policy, read from the branch
// of the check-in the run records.
feat := Tagged{TagMark: mark("2026-10-02", "version-0.3.0", "b1"), At: at("b1", "feat")}
repo.tags = append(repo.tags, feat)
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if got := take(); !slices.Equal(got, []string{"b1 feat ci/release.yml"}) {
t.Errorf("FAIL: a tag on feat started %v; want the release workflow on b1, branch feat", got)
}
t.Log("ok: a tag starts its tag workflows once, on the tagged check-in, with its branch")
}
// A repository with no tags at first sight starts from before every
// tag, so its first tag is not mistaken for history.
func TestTheFirstTagOfAnUntaggedRepositoryStarts(t *testing.T) {
var mu sync.Mutex
var started []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case r.URL.Path == "/v1/ci/compile":
var body struct{ Name, Spec string }
json.NewDecoder(r.Body).Decode(&body)
on, _, _ := strings.Cut(body.Spec, "\n")
fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true,"on":%s}`, body.Name, on)
case r.URL.Path == "/runs" && r.Method == http.MethodPost:
var body struct {
Input map[string]any `json:"input"`
}
json.NewDecoder(r.Body).Decode(&body)
started = append(started, fmt.Sprint(body.Input["checkin"]))
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, `{"run_id":"r"}`)
case r.URL.Path == "/runs":
fmt.Fprint(w, `[]`)
}
}))
defer srv.Close()
e := New(srv.URL)
st := &fakeStore{mark: map[string]string{"alpha": "t1"}}
repo := &fakeRepo{
files: []string{"ci/release.yml"},
blobs: map[string][]byte{"ci/release.yml": []byte(`{"tag":{}}` + "\njobs: {}")},
}
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
if m, ok := st.CITagWatermark("alpha"); !ok || m != (TagMark{}) {
t.Fatalf("FAIL: an untagged first sight left %+v, %v; want the zero mark", m, ok)
}
repo.tags = []Tagged{{TagMark: TagMark{Date: "2026-10-01", Src: "s", Target: "t1", Name: "version-0.2.0"}, At: Checkin{Hash: "t1", Branch: "trunk"}}}
if err := TriggerTags(e, st, "alpha", repo, "u"); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !slices.Equal(started, []string{"t1"}) {
t.Errorf("FAIL: the first tag started %v; want t1", started)
}
t.Log("ok: the first tag of an untagged repository starts")
}