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