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