1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "path"
11 "runtime/debug"
12 "slices"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/bnema/zerowrap"
18
19 "bnema.dev/ermite-actions/expr"
20 "bnema.dev/ermite-actions/template"
21 "bnema.dev/ermite-actions/workflow"
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"
24)
25
26const (
27 workflowDir = ".github/workflows"
28 // maxRunError bounds Run.error (8 KiB, run.proto).
29 maxRunError = 8 << 10
30 // maxPushCommits is the number of commits of the push event payload.
31 maxPushCommits = 20
32)
33
34// ciTrigger is a push waiting to be planned.
35type ciTrigger struct {
36 repo domain.RepoName
37 updates []domain.RefUpdate
38 actor string
39 pusher domain.CIPusher
40 // trusted: the principal that pushed or merged is the owner
41 // (domain.TrustedActor), so the run gets secrets.
42 trusted bool
43 at time.Time
44 log zerowrap.Logger
45}
46
47// OnPush queues the refs of a push or a merge for planning and returns; a
48// worker plans them. A full queue drops the trigger, logged at error: a push is
49// never slowed down or failed by CI. The push workflows are queued first; the
50// merge requests the push touches are then handed to their own worker, with a
51// short lock and no I/O, so nothing here waits for Git or the store.
52//
53// A push made with a job token starts nothing, as on GitHub (events created
54// with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
55// from running itself again.
56func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
57 // Whoever pushed, the workflow files of the default branch may have changed.
58 // (Who pushed was recorded before the refs moved: Pushes.Check.)
59 c.markSchedulesDirty(repo, updates)
60 // The merge requests follow the push whoever made it (their refs); only a
61 // push that is not a job's starts pull_request runs.
62 defer c.onBranchesMoved(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher), !pusher.ViaJobToken())
63 if pusher.ViaJobToken() {
64 log := zerowrap.FromCtx(ctx)
65 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
66 return
67 }
68 c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
69}
70
71// OnMerge queues the move of a target branch by a merge. The actor of the run is
72// the owner, as GitHub shows; the pusher recorded with it, whose rights the
73// actions of other repositories are resolved with, is the principal that merged:
74// a token limited to some repositories does not gain the owner's reach by
75// merging. For the same reason the run is trusted (gets secrets) only when the
76// merger is: the displayed actor is not the one that decides.
77func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
78 c.markSchedulesDirty(repo, updates)
79 // The merge moved a target branch: the test merges of the other requests
80 // into it are computed again (a merge starts no synchronize run), after the
81 // push workflows are queued.
82 defer c.onBranchesMoved(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger), false)
83 if merger.ViaJobToken() {
84 log := zerowrap.FromCtx(ctx)
85 log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
86 return
87 }
88 c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
89}
90
91func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) {
92 var kept []domain.RefUpdate
93 for _, u := range updates {
94 if strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/") {
95 kept = append(kept, u)
96 }
97 }
98 if len(kept) == 0 {
99 return
100 }
101 t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, trusted: trusted, at: c.now(), log: zerowrap.FromCtx(ctx)}
102 c.mu.Lock()
103 closed := c.closed
104 c.mu.Unlock()
105 if closed {
106 return
107 }
108 select {
109 case c.queue <- t:
110 default:
111 t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
112 }
113}
114
115func (c *CI) triggerLoop() {
116 defer c.wg.Done()
117 for {
118 select {
119 case <-c.ctx.Done():
120 return
121 case t := <-c.queue:
122 c.planPush(t)
123 }
124 }
125}
126
127// planPush plans one push; a panic or an error is logged and never escapes.
128func (c *CI) planPush(t ciTrigger) {
129 ctx := zerowrap.WithCtx(c.ctx, t.log)
130 defer func() {
131 if r := recover(); r != nil {
132 t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
133 }
134 }()
135 if err := c.handlePush(ctx, t); err != nil && ctx.Err() == nil {
136 t.log.Error().Err(err).Str("repo", t.repo.String()).Msg("ci trigger failed")
137 }
138}
139
140// handlePush starts the workflows that match each ref of the push.
141func (c *CI) handlePush(ctx context.Context, t ciTrigger) error {
142 // CI is on once a runner is registered (plan, "Config"): without one a push
143 // makes no run and no check, so a forge that does not use CI shows nothing.
144 runners, err := c.cfg.Store.ListRunners(ctx)
145 if err != nil {
146 return fmt.Errorf("list the runners: %w", err)
147 }
148 if len(runners) == 0 {
149 return nil
150 }
151 var tags, created, deleted int
152 for _, u := range t.updates {
153 if strings.HasPrefix(u.Ref, "refs/tags/") {
154 switch {
155 case u.IsDelete():
156 deleted++
157 case u.IsCreate():
158 tags++
159 created++
160 default:
161 tags++
162 }
163 }
164 }
165 var errs []error
166 for _, u := range t.updates {
167 var err error
168 if u.IsDelete() {
169 err = c.handleDelete(ctx, t, u, deleted)
170 } else {
171 err = c.handleRef(ctx, t, u, tags, created)
172 }
173 if err != nil {
174 errs = append(errs, fmt.Errorf("%s: %w", u.Ref, err))
175 }
176 }
177 return errors.Join(errs...)
178}
179
180// pushFacts is what is known of one pushed ref.
181type pushFacts struct {
182 update domain.RefUpdate
183 commit domain.ObjectID // the commit the ref points to, tags followed
184 head domain.Commit
185 commits []domain.Commit // oldest first, at most maxPushCommits
186 forced bool
187 created bool
188 files []string
189 // filesTruncated: the list of changed files is not available.
190 filesTruncated bool
191 tagCount int
192 isTag bool
193 // defaultBranch is the default branch of the repository, "" when unknown.
194 defaultBranch string
195 // payload is the commit part of the push event, shared by the workflows
196 // of the ref; handleRef builds it once, before the first one that matches.
197 payload *pushPayload
198 // pull is set for a pull_request run: update.Ref is then
199 // refs/pull/<n>/merge (or /head), commit the test merge commit (or the
200 // head) and head the head commit. For a pull_request_target run
201 // (pull.target) update.Ref is the default branch and commit its tip.
202 pull *pullFacts
203 // custom is set for the events that are not a push or a merge request:
204 // workflow_dispatch, schedule, workflow_run, create and delete (ci_events.go).
205 custom *customEvent
206}
207
208// eventName is the event of the run, as the run records and github.event_name
209// show it.
210func (f pushFacts) eventName() string {
211 switch {
212 case f.pull != nil && f.pull.target:
213 return eventPullTarget
214 case f.pull != nil:
215 return "pull_request"
216 case f.custom != nil:
217 return f.custom.name
218 }
219 return "push"
220}
221
222// runTitle is the title of a run without run-name: the commit message of a
223// push, the title of the merge request for a pull_request.
224func (f pushFacts) runTitle() string {
225 switch {
226 case f.pull != nil:
227 return firstLine(f.pull.mr.Title.String())
228 case f.custom != nil && f.custom.title != "":
229 return firstLine(f.custom.title)
230 }
231 return firstLine(f.head.Message)
232}
233
234// pullNumber is CIRun.PullNumber: the merge request, 0 for a push.
235func (f pushFacts) pullNumber() int64 {
236 if f.pull != nil {
237 return f.pull.mr.Number
238 }
239 return 0
240}
241
242// headSha is CIRun.HeadSha: the head of the merge request (pull_request and
243// pull_request_target), "" for a push.
244func (f pushFacts) headSha() string {
245 if f.pull != nil {
246 return f.pull.head.String()
247 }
248 return ""
249}
250
251// handleRef starts the push workflows of a pushed ref and, for a created one,
252// its create workflows. tagCount is the number of tags the push sets,
253// tagsCreated the number it creates.
254func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount, tagsCreated int) error {
255 isTag := strings.HasPrefix(u.Ref, "refs/tags/")
256 // GitHub creates no push event for the tags of a push that sets more than
257 // three, and no create event when it creates more than three.
258 push := !isTag || tagCount <= workflow.MaxTagsPerPush
259 create := u.IsCreate() && (!isTag || tagsCreated <= workflow.MaxTagsPerPush)
260 if !push && !create {
261 return nil
262 }
263 repo, err := c.cfg.Meta.Get(ctx, t.repo)
264 if err != nil {
265 return nil // deleted meanwhile
266 }
267 facts, err := c.collect(ctx, t.repo, u, isTag, tagCount)
268 if err != nil {
269 return err
270 }
271 files, err := c.workflowFiles(ctx, t.repo, facts.commit)
272 if err != nil || len(files) == 0 {
273 return err
274 }
275 skip := workflow.SkipCI(facts.head.Message)
276 var errs []error
277 for _, f := range files {
278 wf, perrs := parseWorkflow(f.path, f.content)
279 var problems []string
280 for _, e := range perrs {
281 problems = append(problems, e.Error())
282 }
283 switch {
284 case !push:
285 case f.problem != "":
286 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, f.problem))
287 case len(problems) > 0:
288 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.path, strings.Join(problems, "\n")))
289 case skip || !wf.MatchPush(workflow.PushInfo{
290 Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
291 }):
292 default:
293 if facts.payload == nil {
294 facts.payload = c.pushPayload(ctx, repo, facts)
295 }
296 if problem := c.matrixProblem(ctx, repo, t, facts, f.path, wf); problem.text != "" {
297 name := f.path
298 if problem.named {
299 name = workflowName(wf, f.path)
300 }
301 errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, name, problem.text))
302 continue
303 }
304 errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
305 }
306 }
307 if create {
308 errs = append(errs, c.startCreateRuns(ctx, repo, t, facts, files))
309 }
310 return errors.Join(errs...)
311}
312
313// collect reads what the payload and the filters need from Git.
314func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpdate, isTag bool, tagCount int) (pushFacts, error) {
315 newID, err := domain.ParseObjectID(u.New)
316 if err != nil {
317 return pushFacts{}, err
318 }
319 f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount}
320 if def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo); err == nil {
321 f.defaultBranch = def
322 }
323 res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID))
324 if err != nil {
325 return f, fmt.Errorf("resolve %s: %w", u.New, err)
326 }
327 f.commit = res.CommitID
328 if isTag {
329 // Recorded (push-tag, skip-directive-tag, push-tags-limit): the event of
330 // a tag carries the commits that no branch has, newest as head_commit
331 // ("compare" is then that commit's page, or "<first>^...<head>" for
332 // several). Not the range from the previous push, and not the commits
333 // the default branch lacks: in push-tag the branch commit under the
334 // tagged one is not listed, in skip-directive-tag, which has no branch,
335 // both commits are. A tag on a commit that a branch has lists that
336 // commit alone (not recorded). No file list: paths filters are not
337 // evaluated for tags.
338 cs, _, err := c.cfg.Diff.CommitsOffBranches(ctx, repo, f.commit, ports.MaxCommitLimit)
339 if err != nil {
340 return f, fmt.Errorf("read the commits of the tag: %w", err)
341 }
342 if len(cs) == 0 {
343 cs, _, err = c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
344 if err != nil {
345 return f, fmt.Errorf("read the commit of the tag: %w", err)
346 }
347 if len(cs) == 0 {
348 return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
349 }
350 }
351 f.head = cs[0]
352 f.commits = slices.Clone(cs[:min(len(cs), maxPushCommits)])
353 slices.Reverse(f.commits)
354 return f, nil
355 }
356
357 var exclude domain.ObjectID
358 if !f.created {
359 if old, err := domain.ParseObjectID(u.Old); err == nil {
360 exclude = old
361 if ok, err := c.cfg.Git.IsAncestor(ctx, repo, old, f.commit); err == nil && !ok {
362 f.forced = true
363 }
364 }
365 }
366 base := exclude
367 if f.created {
368 base = c.defaultBase(ctx, repo, strings.TrimPrefix(u.Ref, "refs/heads/"), f.commit)
369 exclude = base
370 }
371 cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit)
372 if err != nil {
373 return f, fmt.Errorf("read the commits of the push: %w", err)
374 }
375 if len(cs) == 0 {
376 // A new branch at a commit the default branch has, or a branch rewound
377 // to an ancestor: no new commit, no changed file. GitHub still runs
378 // the push workflows that have no paths filter, for the head commit.
379 head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
380 if err != nil {
381 return f, fmt.Errorf("read the head commit of the push: %w", err)
382 }
383 if len(head) == 0 {
384 return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound)
385 }
386 f.head = head[0]
387 return f, nil
388 }
389 f.head = cs[0]
390 n := min(len(cs), maxPushCommits)
391 f.commits = slices.Clone(cs[:n])
392 slices.Reverse(f.commits)
393 if more || base == f.commit {
394 // More than 1,000 commits: the diff is not looked at (it runs).
395 f.filesTruncated = more
396 }
397 if !more && base != f.commit {
398 paths, _, err := c.cfg.Diff.ChangedPaths(ctx, repo, base, f.commit, workflow.MaxFilesEvaluated)
399 if err != nil {
400 f.filesTruncated = true // diff unavailable: runs
401 } else {
402 f.files = paths
403 }
404 }
405 return f, nil
406}
407
408// defaultBase is the merge base of a new branch with the default branch, the
409// zero id when there is none (every file of the branch counts).
410func (c *CI) defaultBase(ctx context.Context, repo domain.RepoName, branch string, head domain.ObjectID) domain.ObjectID {
411 def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
412 if err != nil || !exists || def == branch {
413 return domain.ObjectID{}
414 }
415 tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
416 if err != nil {
417 return domain.ObjectID{}
418 }
419 base, err := c.cfg.Git.MergeBase(ctx, repo, tip, head)
420 if err != nil {
421 return domain.ObjectID{}
422 }
423 return base
424}
425
426type workflowFile struct {
427 path string
428 content []byte
429 // problem is set when the file cannot even be parsed (too large).
430 problem string
431}
432
433// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit.
434func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
435 dir, err := domain.ParseTreePath(workflowDir)
436 if err != nil {
437 return nil, err
438 }
439 tree, err := c.cfg.Reader.GetTree(ctx, repo, domain.RevisionOf(commit), dir, false)
440 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotDirectory) {
441 return nil, nil
442 }
443 if err != nil {
444 return nil, fmt.Errorf("list %s: %w", workflowDir, err)
445 }
446 var out []workflowFile
447 for _, e := range tree.Entries {
448 ext := strings.ToLower(path.Ext(e.Name))
449 if (e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable) || (ext != ".yml" && ext != ".yaml") {
450 continue
451 }
452 if len(out) == maxCIWorkflowFiles {
453 break
454 }
455 wf := workflowFile{path: e.Path}
456 if wf.path == "" {
457 wf.path = workflowDir + "/" + e.Name
458 }
459 if e.Size > maxCIWorkflowBytes {
460 wf.problem = fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path)
461 out = append(out, wf)
462 continue
463 }
464 tp, err := domain.ParseTreePath(wf.path)
465 if err != nil {
466 continue
467 }
468 _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp)
469 if err != nil {
470 return nil, fmt.Errorf("read %s: %w", wf.path, err)
471 }
472 wf.content, err = io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
473 _ = rc.Close()
474 if err != nil {
475 return nil, fmt.Errorf("read %s: %w", wf.path, err)
476 }
477 if len(wf.content) > maxCIWorkflowBytes {
478 wf.content, wf.problem = nil, fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path)
479 }
480 out = append(out, wf)
481 }
482 return out, nil
483}
484
485// --- the run ---
486
487func firstLine(s string) string {
488 line, _, _ := strings.Cut(s, "\n")
489 return truncateRunes(strings.TrimSpace(line), 256)
490}
491
492// startInvalid records the failed run of an invalid workflow file: no job, the
493// errors as Run.error, like GitHub's "Invalid workflow file".
494//
495// name is what the run is called: the path of the file when the file is not a
496// workflow GitHub accepts, or the workflow's own name for a workflow that
497// parses but whose strategy GitHub refuses (recorded: matrix-over-256).
498func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, name, problem string) error {
499 now := c.now()
500 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
501 run := domain.CIRun{
502 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
503 Title: f.runTitle(), Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
504 Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError), Trusted: t.trusted,
505 CreatedAt: now, StartedAt: now, CompletedAt: now,
506 }
507 return ports.CIRunDraft{
508 Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher},
509 Checks: []domain.CICheck{{
510 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.CheckSha(), Name: wfPath, Status: domain.CICompleted,
511 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
512 }},
513 }
514 })
515 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) {
516 return nil
517 }
518 if err != nil {
519 return err
520 }
521 c.publishRun(ctx, repo, draft.Run)
522 c.onRunEvent(ctx, draft.Run, runRequested)
523 c.onRunEvent(ctx, draft.Run, runCompleted)
524 log := logRun(ctx, draft.Run)
525 log.Info().Str("workflow", wfPath).Msg("ci run failed: invalid workflow file")
526 return nil
527}
528
529func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun) {
530 c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: run, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
531}
532
533// startRun creates the run of a matching workflow with one waiting record per
534// job of the file, then plans the jobs that can start.
535func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
536 _, err := c.launchRun(ctx, repo, t, f, wfPath, yaml, wf)
537 return err
538}
539
540// launchRun is startRun that also returns the run it created, as it was when
541// created (the zero run when the repository or the merge request is gone).
542func (c *CI) launchRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) (domain.CIRun, error) {
543 now := c.now()
544 name := workflowName(wf, wfPath)
545 // The title of a run without run-name is the commit message for a branch.
546 // For a tag GitHub shows the name of the workflow registered for the path,
547 // not the commit message (recorded: filter-tags, filter-paths-tag,
548 // filter-branches-ignore, and push-tag, push-tags-limit, push-delete-tag,
549 // skip-directive-tag, whose ci.yml is called "push-branch-create" on the
550 // default branch of the recording repository although each run's own
551 // workflow has another name: 14 of 14 tag runs). Registered is read as the
552 // "name:" of the file on the default branch; the first version GitHub saw
553 // is the same file in the recording and cannot be told apart.
554 title := f.runTitle()
555 if f.isTag {
556 title = c.registeredName(ctx, repo.Name, wfPath, name)
557 }
558 vars, err := c.varsContext(ctx, repo.Name)
559 if err != nil {
560 return domain.CIRun{}, err
561 }
562 draft, err := c.createRun(ctx, f, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
563 run := domain.CIRun{
564 Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name, Name: name,
565 Title: title, Event: f.eventName(), Ref: f.update.Ref, Sha: f.commit.String(), HeadSha: f.headSha(), PullNumber: f.pullNumber(), Actor: t.actor,
566 Status: domain.CIQueued, Trusted: t.trusted, CreatedAt: now,
567 }
568 githubJSON := c.githubJSON(ctx, repo, run, f)
569 if runName, ok := c.evalRunName(ctx, wf, githubJSON, vars); ok {
570 run.Name, run.Title = runName, runName
571 }
572 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
573 Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher,
574 }}
575 for i, j := range wf.Jobs {
576 job := domain.CIJob{
577 ID: domain.NewJobID(id, i, 0), RunID: id, Attempt: 1, RunAttempt: 1, Repo: repo.Name, Key: j.ID, Name: j.ID,
578 Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: i, CreatedAt: now,
579 }
580 d.Jobs = append(d.Jobs, job)
581 d.Checks = append(d.Checks, domain.CICheck{
582 ID: job.ID, Repo: repo.Name, Sha: run.CheckSha(), Name: job.Name, Status: domain.CIQueued, RunID: id, JobID: job.ID,
583 Attempt: 1, WorkflowName: name, CreatedAt: now,
584 })
585 }
586 return d
587 })
588 if errors.Is(err, domain.ErrNotFound) || errors.Is(err, errPullClosed) {
589 return domain.CIRun{}, nil
590 }
591 if err != nil {
592 return domain.CIRun{}, err
593 }
594 c.publishRun(ctx, repo, draft.Run)
595 for _, j := range draft.Jobs {
596 c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
597 }
598 c.onRunEvent(ctx, draft.Run, runRequested)
599 log := logRun(ctx, draft.Run)
600 log.Info().Str("workflow", wfPath).Str("ref", f.update.Ref).Str("event", draft.Run.Event).Int("jobs", len(draft.Jobs)).Msg("ci run created")
601 return draft.Run, c.advanceRun(ctx, draft.Run.ID)
602}
603
604// workflowName is the name of a workflow, the path of its file when it has no
605// "name:".
606func workflowName(wf *workflow.Workflow, path string) string {
607 if wf.Name == "" {
608 return path
609 }
610 return wf.Name
611}
612
613// registeredName is the name GitHub shows for a tag run without run-name: the
614// name of the workflow registered for the path, here the "name:" of the file on
615// the default branch (the path when that file has none). It falls back to own,
616// the name of the workflow of the run, when the default branch has no such
617// file, the file does not parse, or anything cannot be read; a failure other
618// than a missing file is logged at debug.
619func (c *CI) registeredName(ctx context.Context, repo domain.RepoName, wfPath, own string) string {
620 name, err := c.registeredNameOf(ctx, repo, wfPath)
621 if err != nil {
622 if !errors.Is(err, domain.ErrNotFound) {
623 log := zerowrap.FromCtx(ctx)
624 log.Debug().Err(err).Str("workflow", wfPath).Msg("ci tag run title: the registered workflow name is not readable, using the run's own")
625 }
626 return own
627 }
628 return name
629}
630
631// registeredNameOf is registeredName without the fallback; a missing default
632// branch or file is domain.ErrNotFound.
633func (c *CI) registeredNameOf(ctx context.Context, repo domain.RepoName, wfPath string) (string, error) {
634 def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
635 if err != nil {
636 return "", err
637 }
638 if !exists {
639 return "", domain.ErrNotFound
640 }
641 tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
642 if err != nil {
643 return "", err
644 }
645 tp, err := domain.ParseTreePath(wfPath)
646 if err != nil {
647 return "", err
648 }
649 _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(tip), tp)
650 if err != nil {
651 return "", err
652 }
653 content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
654 _ = rc.Close()
655 if err != nil {
656 return "", err
657 }
658 if len(content) > maxCIWorkflowBytes {
659 return "", fmt.Errorf("workflow file over %d bytes", maxCIWorkflowBytes)
660 }
661 wf, errs := workflow.Parse(wfPath, content)
662 if len(errs) > 0 {
663 return "", fmt.Errorf("workflow does not parse: %v", errs[0])
664 }
665 return workflowName(wf, wfPath), nil
666}
667
668// evalRunName evaluates the run-name of a workflow with the github, inputs and
669// vars contexts (the contexts the schema allows there). ok is false when the
670// workflow has none, when it does not evaluate (logged), or when it evaluates
671// to nothing: the run then has no name of its own, GitHub's display title falls
672// back to the commit message (a branch) or the registered name (a tag), and
673// the name of the run to the name of the workflow.
674func (c *CI) evalRunName(ctx context.Context, wf *workflow.Workflow, githubJSON string, vars *expr.Dictionary) (string, bool) {
675 if wf.RunName == nil {
676 return "", false
677 }
678 gh, err := expr.FromJSON([]byte(githubJSON))
679 if err != nil {
680 return "", false
681 }
682 env := template.Env{Contexts: map[string]expr.Value{
683 "github": gh, "inputs": eventInputs(wf, githubJSON), "vars": vars,
684 }}
685 name, err := template.EvaluateString(ctx, wf.RunName, env)
686 if err != nil {
687 log := zerowrap.FromCtx(ctx)
688 log.Warn().Err(err).Msg("ci run-name does not evaluate: the run keeps the workflow name and the default title")
689 return "", false
690 }
691 if name = firstLine(name); name == "" {
692 return "", false
693 }
694 return name, true
695}
696
697// matrixProblem expands the strategy of every job of the file, whatever its
698// "if:" says, with an empty needs context (a matrix built from a needs output does not
699// evaluate here, and that error is ignored),
700// and returns the error text of the first strategy that produces more
701// configurations than GitHub allows: GitHub then fails the run with no job,
702// like an invalid workflow file (recorded: matrix-over-256; the run keeps the
703// name of the workflow). An "exclude" entry with a key that is no vector of the
704// matrix is refused too, and there GitHub names the run after the file path
705// (recorded: matrix-edge-values, a-exclude-unknown-key.yml). Any other error
706// of a strategy is ignored here and stays a job failure, found when the job is
707// planned. Whether GitHub counts the matrix of a job that "if:" skips is not
708// recorded yet (needs an "if: false" scenario over 256). The github context
709// is that of a run with no id yet, on the commits shared by the ref.
710func (c *CI) matrixProblem(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath string, wf *workflow.Workflow) matrixIssue {
711 if !slices.ContainsFunc(wf.Jobs, func(j *workflow.Job) bool { return j.Strategy != nil }) {
712 return matrixIssue{} // nothing to expand: no context to build
713 }
714 probe := domain.CIRun{Repo: repo.Name, Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor, WorkflowPath: wfPath, WorkflowName: wf.Name, CreatedAt: c.now()}
715 ghJSON := c.githubJSON(ctx, repo, probe, f)
716 gh, err := expr.FromJSON([]byte(ghJSON))
717 if err != nil {
718 return matrixIssue{}
719 }
720 vars, err := c.varsContext(ctx, repo.Name)
721 if err != nil {
722 // Only the sizes of the matrices are looked at here: the job fails on
723 // the same error when it is planned.
724 vars = expr.NewDictionary()
725 }
726 env := template.Env{Contexts: map[string]expr.Value{
727 "github": gh, "needs": expr.NewDictionary(), "inputs": eventInputs(wf, ghJSON), "vars": vars,
728 }}
729 for _, j := range wf.Jobs {
730 _, err := j.EvaluateStrategy(ctx, env)
731 switch {
732 case errors.Is(err, workflow.ErrTooManyConfigurations):
733 return matrixIssue{text: wfPath + ": " + err.Error(), named: true}
734 case errors.Is(err, workflow.ErrExcludeUnknownKey):
735 // One line per offending entry, each with the file.
736 return matrixIssue{text: wfPath + ": " + strings.ReplaceAll(err.Error(), "\n", "\n"+wfPath+": ")}
737 }
738 }
739 return matrixIssue{}
740}
741
742// matrixIssue is a strategy GitHub refuses: the error text, and whether the run
743// keeps the name of the workflow (else it is named after the file).
744type matrixIssue struct {
745 text string
746 named bool
747}
748
749// --- the github context (see ci_github.go) ---
750
751// shortID is the 12-character form of an object id that GitHub puts in the
752// compare URL of a push.
753func shortID(id string) string {
754 if len(id) > 12 {
755 return id[:12]
756 }
757 return id
758}
759
760// retentionDays is the retention in days, as github.retention_days shows it
761// (at least 1).
762func (c *CI) retentionDays() int {
763 return max(int(c.cfg.Retention/(24*time.Hour)), 1)
764}