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