1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10
11 "bnema.dev/ermite-actions/workflow"
12 "bnema.dev/ermite/internal/domain"
13)
14
15// The events create and delete (CI-4, D6): a branch or a tag is created or
16// deleted. As on GitHub ("Events that trigger workflows"), a create run is on
17// the created ref, its last commit, and runs the workflows of that commit; a
18// delete run is on the default branch, its last commit, and runs the workflows
19// of that commit: the deleted ref is gone. Neither has filters, and GitHub's
20// skip directives do not apply to them. A push that creates or deletes more
21// than three tags creates no event for them.
22//
23// Trust: a create run is trusted when the pusher is and the record of the
24// created ref (ci_tips.go) names the commit the run executes; a delete run when
25// the deleter is and a trusted principal set the default branch to the commit
26// it executes. The deleted ref's record went with the ref and is never looked
27// at. Job tokens start neither (OnPush).
28
29const (
30 eventCreate = "create"
31 eventDelete = "delete"
32)
33
34// startCreateRuns starts the create workflows of a ref a push created, from
35// the files of the commit it points to (files; an invalid file is reported by
36// the push run).
37func (c *CI) startCreateRuns(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, files []workflowFile) error {
38 newID, err := domain.ParseObjectID(f.update.New)
39 if err != nil {
40 return err
41 }
42 ct := t
43 ct.trusted = t.trusted && c.refTrusted(ctx, repo.Name, pinnedRef{ref: f.update.Ref, commit: f.commit, object: newID})
44 if t.trusted && !ct.trusted {
45 ctxLog(ctx).Info().Str("repo", repo.Name.String()).Str("ref", f.update.Ref).
46 Msg("ci create runs are not trusted: the created ref is not at a commit a trusted principal pushed")
47 }
48 cf := f
49 cf.payload = nil
50 cf.custom = &customEvent{name: eventCreate, ref: f.update.Ref}
51 return c.startRefEventRuns(ctx, repo, ct, cf, files)
52}
53
54// handleDelete starts the delete workflows of the default branch for a deleted
55// branch or tag; deletedTags is the number of tags the push deletes.
56func (c *CI) handleDelete(ctx context.Context, t ciTrigger, u domain.RefUpdate, deletedTags int) error {
57 if strings.HasPrefix(u.Ref, "refs/tags/") && deletedTags > workflow.MaxTagsPerPush {
58 return nil
59 }
60 repo, err := c.cfg.Meta.Get(ctx, t.repo)
61 if err != nil {
62 return nil // deleted meanwhile
63 }
64 tip := c.defaultTipOf(ctx, t.repo)
65 if tip.commit.IsZero() {
66 return nil // no default branch to run from
67 }
68 head, _, err := c.cfg.Git.ListRange(ctx, t.repo, domain.ObjectID{}, tip.commit, 1)
69 if err != nil {
70 return fmt.Errorf("read the head commit of %s: %w", tip.branch, err)
71 }
72 if len(head) == 0 {
73 return fmt.Errorf("read the head commit of %s: %w", tip.branch, domain.ErrNotFound)
74 }
75 files, err := c.workflowFiles(ctx, t.repo, tip.commit)
76 if err != nil || len(files) == 0 {
77 return err
78 }
79 dt := t
80 dt.trusted = t.trusted && tip.trusted
81 if t.trusted && !tip.trusted {
82 ctxLog(ctx).Info().Str("repo", t.repo.String()).Str("ref", u.Ref).Str("tip", tip.commit.String()).
83 Msg("ci delete runs are not trusted: the default branch tip was not pushed by a trusted principal")
84 }
85 f := pushFacts{
86 update: domain.RefUpdate{Ref: "refs/heads/" + tip.branch, Old: domain.ZeroOID, New: tip.commit.String()},
87 commit: tip.commit, head: head[0], defaultBranch: tip.branch,
88 custom: &customEvent{name: eventDelete, ref: u.Ref},
89 }
90 return c.startRefEventRuns(ctx, repo, dt, f, files)
91}
92
93// startRefEventRuns starts the workflows of files that are on the event of
94// f.custom (create or delete).
95func (c *CI) startRefEventRuns(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, files []workflowFile) error {
96 var errs []error
97 for _, file := range files {
98 if file.problem != "" {
99 continue
100 }
101 wf, perrs := parseWorkflow(file.path, file.content)
102 if len(perrs) > 0 || wf.On == nil || !wf.On.Has(f.custom.name) {
103 continue
104 }
105 if problem := c.matrixProblem(ctx, repo, t, f, file.path, wf); problem.text != "" {
106 name := file.path
107 if problem.named {
108 name = workflowName(wf, file.path)
109 }
110 errs = append(errs, c.startInvalid(ctx, repo, t, f, file.path, name, problem.text))
111 continue
112 }
113 errs = append(errs, c.startRun(ctx, repo, t, f, file.path, string(file.content), wf))
114 }
115 return errors.Join(errs...)
116}
117
118// refEventPayload is the payload of a create or a delete event: the ref by its
119// short name and its type; create adds the default branch and the description
120// of the repository (ermite has none: null). pusher_type is "user": a job
121// token, GitHub's "deploy key" case aside, starts no run.
122func refEventPayload(name, ref, defaultBranch string, event map[string]any) {
123 refName, refType := strings.TrimPrefix(ref, "refs/heads/"), "branch"
124 if tag, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
125 refName, refType = tag, "tag"
126 }
127 event["ref"], event["ref_type"], event["pusher_type"] = refName, refType, "user"
128 if name == eventCreate {
129 event["master_branch"], event["description"] = defaultBranch, nil
130 }
131}