v0.15.0 8a6cfec

ci_trigger.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close