v0.8.0 eaf4295

ci.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"errors"
7	"fmt"
8	"regexp"
9	"slices"
10	"strconv"
11	"strings"
12	"time"
13)
14
15// CIStatus is where a run, a job or a check is in its life (GitHub's status).
16type CIStatus string
17
18// Statuses.
19const (
20	CIQueued     CIStatus = "queued"
21	CIInProgress CIStatus = "in_progress"
22	CICompleted  CIStatus = "completed"
23)
24
25// CIConclusion is how a run, a job, a step or a check ended, with GitHub's
26// values. It is empty until the status is completed.
27type CIConclusion string
28
29// Conclusions.
30const (
31	CINoConclusion   CIConclusion = ""
32	CISuccess        CIConclusion = "success"
33	CIFailure        CIConclusion = "failure"
34	CICancelled      CIConclusion = "cancelled"
35	CISkipped        CIConclusion = "skipped"
36	CITimedOut       CIConclusion = "timed_out"
37	CINeutral        CIConclusion = "neutral"
38	CIActionRequired CIConclusion = "action_required"
39)
40
41// PassesMerge reports whether a check that ended this way lets a merge go
42// through under FailingChecksBlock: success, skipped and neutral do.
43func (c CIConclusion) PassesMerge() bool {
44	return c == CISuccess || c == CISkipped || c == CINeutral
45}
46
47// IsFailure reports whether the conclusion is a failure or a timeout.
48func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut }
49
50// CIStepState is where a step is in its life.
51type CIStepState string
52
53// Step states.
54const (
55	CIStepPending    CIStepState = "pending"
56	CIStepInProgress CIStepState = "in_progress"
57	CIStepCompleted  CIStepState = "completed"
58)
59
60// CIJobPhase is where a job is for the server, finer than the status owners
61// see.
62type CIJobPhase string
63
64// Job phases.
65const (
66	// CIPhaseWaiting: waits for the jobs it needs or for a max-parallel slot.
67	CIPhaseWaiting CIJobPhase = "waiting"
68	// CIPhaseReady: planned, waits for a runner.
69	CIPhaseReady CIJobPhase = "ready"
70	// CIPhaseAssigned: given to a runner that has not acknowledged it.
71	CIPhaseAssigned CIJobPhase = "assigned"
72	// CIPhaseRunning: acknowledged by its runner.
73	CIPhaseRunning CIJobPhase = "running"
74	// CIPhaseEnded: over.
75	CIPhaseEnded CIJobPhase = "ended"
76)
77
78// CIIsolation is how a runner isolates jobs.
79type CIIsolation string
80
81// Isolations.
82const (
83	CIIsolationNone                CIIsolation = ""
84	CIIsolationVM                  CIIsolation = "vm"
85	CIIsolationContainerSysbox     CIIsolation = "container-sysbox"
86	CIIsolationContainerPrivileged CIIsolation = "container-privileged"
87)
88
89// CICancelReason says why a job is asked to stop.
90type CICancelReason string
91
92// Cancel reasons.
93const (
94	CICancelNone     CICancelReason = ""
95	CICancelUser     CICancelReason = "user"
96	CICancelFailFast CICancelReason = "fail-fast"
97	CICancelTimeout  CICancelReason = "timeout"
98)
99
100// Limits and durations of the CI protocol (runner.proto).
101const (
102	// CIMaxLogBytes is the size of a job log, as stored (512 MiB).
103	CIMaxLogBytes = 512 << 20
104	// CIMaxOutputBytes bounds one output and all outputs together (1 MiB).
105	CIMaxOutputBytes = 1 << 20
106	// CIMaxLogLines and CIMaxLogChunkBytes bound one AppendLog request.
107	CIMaxLogLines      = 1000
108	CIMaxLogChunkBytes = 64 << 10
109	// CIMaxSteps bounds the steps of a job.
110	CIMaxSteps = 1000
111	// CIMaxStepName bounds a step name, in characters.
112	CIMaxStepName = 1024
113	// CIMaxLabels and CIMaxLabelLen bound the labels of a runner.
114	CIMaxLabels   = 64
115	CIMaxLabelLen = 64
116	// CIDefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
117	CIDefaultTimeoutMinutes = 360
118	// CIMaxAssignCancels is how many times in a row an assignment may be
119	// cancelled before the job fails.
120	CIMaxAssignCancels = 3
121	// CIRetentionDays is the retention of runs, jobs, checks and logs.
122	CIRetentionDays = 90
123)
124
125// Durations of the CI protocol.
126const (
127	// CIAckTimeout is how long a runner has to send its first heartbeat.
128	CIAckTimeout = 30 * time.Second
129	// CIHeartbeatDeadline is how long a running job may go without heartbeat.
130	CIHeartbeatDeadline = 2 * time.Minute
131	// CICancelGrace is how long a runner has to conclude after a cancel request.
132	CICancelGrace = 60 * time.Second
133	// CITimeoutSlack is added to timeout-minutes by the server.
134	CITimeoutSlack = 60 * time.Second
135	// CIQueueTimeout is how long a job waits for a runner.
136	CIQueueTimeout = 24 * time.Hour
137	// CITokenGrace is how long a job token is recognised after the job ended.
138	CITokenGrace = 24 * time.Hour
139)
140
141// Messages of jobs ended by the server, GitHub's where it has them.
142const (
143	// CIQueueTimeoutMessage is GitHub's message for a job that no runner took.
144	CIQueueTimeoutMessage = "The job was not acquired by Runner of type self-hosted even after multiple attempts"
145	// CIShutdownMessage is GitHub's message for a runner that stops.
146	CIShutdownMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled."
147	// CILogLimitMessage ends a log that reached its limit.
148	CILogLimitMessage = "The log of this job reached the limit of 512 MiB; the job is stopped."
149)
150
151// CILostMessage is GitHub's message for a runner that stopped sending heartbeats.
152func CILostMessage(runner string) string {
153	return "The self-hosted runner: " + runner + " lost communication with the server. Verify the machine is running and has a healthy network connection. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error."
154}
155
156// CIRun is one execution of one workflow file for one event.
157type CIRun struct {
158	Repo              RepoName
159	ID                int64
160	Number            int64
161	Attempt           int32
162	WorkflowPath      string
163	WorkflowName      string
164	Title             string
165	Event             string
166	Ref               string
167	Sha               string
168	Actor             string
169	Status            CIStatus
170	Conclusion        CIConclusion
171	Error             string
172	CreatedAt         time.Time
173	StartedAt         time.Time
174	CompletedAt       time.Time
175	CancelRequested   bool
176	CancelRequestedAt time.Time
177	// Version is the version of the stored record, which grows at every
178	// change and never goes back.
179	Version uint64
180}
181
182// CIRunSource is what a run was planned from: the workflow file at the sha of
183// the run, and the "github" context without the parts that depend on the job
184// and the attempt.
185type CIRunSource struct {
186	RunID        int64
187	Repo         RepoName
188	WorkflowYAML string
189	GithubJSON   string
190}
191
192// CIStep is a step of a job with where its lines are in the job log.
193type CIStep struct {
194	Number      int32
195	Name        string
196	State       CIStepState
197	Conclusion  CIConclusion
198	StartedAt   time.Time
199	CompletedAt time.Time
200	LogStart    int64
201	// HasLines is set once a line of the step reached the log; LogStart is
202	// then the number of its first line. LogEnd is not stored: see StepRanges.
203	HasLines bool
204}
205
206// CIJob is one job instance (one matrix combination) in one attempt.
207type CIJob struct {
208	ID    string
209	RunID int64
210	// Attempt is the attempt in which the job executed, RunAttempt the attempt
211	// of the run the record belongs to: they differ for a job kept by a re-run
212	// of the failed jobs.
213	Attempt    int32
214	RunAttempt int32
215	Repo       RepoName
216	Key        string
217	Name       string
218	Phase      CIJobPhase
219	Conclusion CIConclusion
220	Error      string
221	Labels     []string
222	Needs      []string
223	RunnerID   string
224	RunnerName string
225	Isolation  CIIsolation
226	Steps      []CIStep
227
228	CreatedAt   time.Time
229	StartedAt   time.Time
230	CompletedAt time.Time
231
232	// Index is the position of the job in the workflow file, Combo the
233	// position of the instance in its matrix.
234	Index int
235	Combo int
236	// Planned is set once runs-on, the name and the contexts are evaluated.
237	// A job that is not planned has not been expanded yet.
238	Planned    bool
239	MatrixJSON string
240	// ContextsJSON is the JSON object of contexts given to the runner, computed
241	// when the job is planned.
242	ContextsJSON string
243	// ComboTotal is the number of instances of the job's matrix.
244	ComboTotal int
245	// TimeoutMinutes is the timeout of the job, at least 1.
246	TimeoutMinutes int32
247	FailFast       bool
248	// ContinueOnError is the job's continue-on-error, evaluated when the job is
249	// planned: its failure leaves the run, its siblings and the jobs that
250	// need it unaffected (needs.<job>.result is success). The conclusion of
251	// the job itself stays failure.
252	ContinueOnError bool
253	// MaxParallel is the max-parallel of the strategy, 0 for no limit.
254	MaxParallel int
255	Outputs     map[string]string
256
257	// TokenHash is the hash of the job token of the current assignment; zero
258	// when there is none.
259	TokenHash       TokenHash
260	TokenValidUntil time.Time
261	ReadyAt         time.Time
262	AssignedAt      time.Time
263	AckedAt         time.Time
264	// AssignCancels counts the assignments cancelled in a row.
265	AssignCancels int
266
267	CancelReason      CICancelReason
268	CancelMessage     string
269	CancelRequestedAt time.Time
270
271	Version uint64
272}
273
274// Status is the status owners see: a job is queued until its runner
275// acknowledged it.
276func (j CIJob) Status() CIStatus {
277	switch j.Phase {
278	case CIPhaseRunning:
279		return CIInProgress
280	case CIPhaseEnded:
281		return CICompleted
282	}
283	return CIQueued
284}
285
286// Active reports whether the job is given to a runner and not over.
287func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
288
289// Ended reports whether the job is over.
290func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
291
292// HasToken reports whether a job token is set.
293func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
294
295// CICheck is the result of one job on one commit.
296type CICheck struct {
297	ID           string
298	Repo         RepoName
299	Sha          string
300	Name         string
301	Status       CIStatus
302	Conclusion   CIConclusion
303	RunID        int64
304	JobID        string
305	Attempt      int32
306	WorkflowName string
307	CreatedAt    time.Time
308	StartedAt    time.Time
309	CompletedAt  time.Time
310	Version      uint64
311}
312
313// CIRunner is a registered CI runner. TokenHash is never sent to clients.
314type CIRunner struct {
315	ID           string
316	Name         string
317	Labels       []string
318	Isolation    CIIsolation
319	Version      string
320	Arch         string
321	OS           string
322	RegisteredAt time.Time
323	TokenHash    TokenHash
324}
325
326// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
327const (
328	RunnerTokenPrefix = "ermiterun_"
329	JobTokenPrefix    = "ermitejob_"
330	// CITokenRandomLen is the length of the base64url part of both.
331	CITokenRandomLen = 43
332)
333
334// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
335func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
336func IsJobToken(s string) bool    { return hasTokenShape(s, JobTokenPrefix) }
337
338func hasTokenShape(s, prefix string) bool {
339	rest, ok := strings.CutPrefix(s, prefix)
340	if !ok || len(rest) != CITokenRandomLen {
341		return false
342	}
343	for _, r := range rest {
344		switch {
345		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
346		default:
347			return false
348		}
349	}
350	return true
351}
352
353// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
354// every attempt of the run.
355func NewJobID(runID int64, index, combo int) string {
356	return fmt.Sprintf("%d-%d-%d", runID, index, combo)
357}
358
359// ParseJobID returns the run of a job id, or ok false when s is not one.
360func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
361	parts := strings.Split(s, "-")
362	if len(parts) != 3 {
363		return 0, 0, 0, false
364	}
365	r, err1 := strconv.ParseInt(parts[0], 10, 64)
366	i, err2 := strconv.Atoi(parts[1])
367	c, err3 := strconv.Atoi(parts[2])
368	if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
369		return 0, 0, 0, false
370	}
371	return r, i, c, true
372}
373
374var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
375
376// ParseRunnerName validates a runner name.
377func ParseRunnerName(s string) error {
378	if !labelRE.MatchString(s) {
379		return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
380	}
381	return nil
382}
383
384// ParseRunnerLabels validates labels and returns them lowercased, without
385// duplicates, in order of first appearance.
386func ParseRunnerLabels(labels []string) ([]string, error) {
387	if len(labels) > CIMaxLabels {
388		return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
389	}
390	var out []string
391	for _, l := range labels {
392		if !labelRE.MatchString(l) {
393			return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
394		}
395		l = strings.ToLower(l)
396		if !slices.Contains(out, l) {
397			out = append(out, l)
398		}
399	}
400	return out, nil
401}
402
403// RunnerLabels returns the labels a runner matches: the ones it chose plus
404// the implicit "self-hosted", its os and its arch, lowercase and sorted.
405func RunnerLabels(chosen []string, os, arch string) []string {
406	out := slices.Clone(chosen)
407	for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
408		if !slices.Contains(out, l) {
409			out = append(out, l)
410		}
411	}
412	slices.Sort(out)
413	return out
414}
415
416// LabelsMatch reports whether every wanted label is among have, ignoring case.
417func LabelsMatch(want, have []string) bool {
418	for _, w := range want {
419		if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
420			return false
421		}
422	}
423	return true
424}
425
426var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
427
428// CheckOutputs validates the outputs of a job: names, case-insensitive
429// uniqueness and the 1 MiB limits.
430func CheckOutputs(outputs map[string]string) error {
431	seen := make(map[string]bool, len(outputs))
432	total := 0
433	for k, v := range outputs {
434		if !outputNameRE.MatchString(k) {
435			return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
436		}
437		lk := strings.ToLower(k)
438		if seen[lk] {
439			return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
440		}
441		seen[lk] = true
442		if len(v) > CIMaxOutputBytes {
443			return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
444		}
445		total += len(k) + len(v)
446	}
447	if total > CIMaxOutputBytes {
448		return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
449	}
450	return nil
451}
452
453// JobEndedError is returned for a call that needs a running job when the job
454// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
455// detail.
456type JobEndedError struct {
457	Conclusion CIConclusion
458	Message    string
459}
460
461// Error implements error.
462func (e *JobEndedError) Error() string {
463	return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
464}
465
466// Is makes errors.Is(err, ErrPreconditionFailed) true.
467func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
468
469// IsJobEnded returns the JobEndedError in err.
470func IsJobEnded(err error) (*JobEndedError, bool) {
471	var e *JobEndedError
472	if errors.As(err, &e) {
473		return e, true
474	}
475	return nil, false
476}
477
478// ErrOutOfRange means an offset is beyond what the server holds.
479var ErrOutOfRange = errors.New("out of range")
480
481// CILogLine is one line of a job log.
482type CILogLine struct {
483	Time time.Time
484	// Text has no line feed.
485	Text string
486	// Continued marks a piece of a line that was too long to send whole.
487	Continued bool
488}
489
490// StepRange is where the lines of a step are in the job log.
491type StepRange struct {
492	Start, End int64
493}
494
495// StepRanges returns the range of every step of the job for a log of lines
496// lines: a step with lines ends where the next step with lines begins (the
497// last ends at the end of the log); a step without lines is empty, placed
498// where the previous step ends.
499func StepRanges(steps []CIStep, lines int64) []StepRange {
500	out := make([]StepRange, len(steps))
501	var pos int64
502	for i, s := range steps {
503		if !s.HasLines {
504			out[i] = StepRange{Start: pos, End: pos}
505			continue
506		}
507		end := lines
508		for _, n := range steps[i+1:] {
509			if n.HasLines {
510				end = n.LogStart
511				break
512			}
513		}
514		out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
515		pos = out[i].End
516	}
517	return out
518}
519
520// StepOfLine returns the number of the step a log line belongs to, 0 when no
521// step has that line.
522func StepOfLine(steps []CIStep, line int64) int32 {
523	var found int32
524	for _, s := range steps {
525		if s.HasLines && s.LogStart <= line {
526			found = s.Number
527		}
528	}
529	return found
530}
531
532// RunUpdated is published when a CI run is created or changes. It carries the
533// visibility and the creation time of the repository, like PushEvent.
534type RunUpdated struct {
535	Run        CIRun
536	At         time.Time
537	Visibility Visibility
538	CreatedAt  time.Time
539}
540
541// EventKind implements Event.
542func (RunUpdated) EventKind() string { return "ci.run_updated" }
543
544// JobUpdated is published when a CI job is created or changes (status,
545// conclusion, runner or steps; never per log line). Lines is the number of
546// lines of the job log at that time, for the end of the step in progress.
547type JobUpdated struct {
548	Job        CIJob
549	Lines      int64
550	At         time.Time
551	Visibility Visibility
552	CreatedAt  time.Time
553}
554
555// EventKind implements Event.
556func (JobUpdated) EventKind() string { return "ci.job_updated" }
557
558// CheckState is the combined state of the checks of a commit.
559type CheckState string
560
561// Combined check states.
562const (
563	// CheckStateNone: the commit has no check.
564	CheckStateNone CheckState = ""
565	// CheckStatePending: a check is queued or in progress, none has failed.
566	CheckStatePending CheckState = "pending"
567	// CheckStateFailure: a check failed (or needs an action), whatever the others.
568	CheckStateFailure CheckState = "failure"
569	// CheckStateSuccess: every check passed.
570	CheckStateSuccess CheckState = "success"
571)
572
573// CombineChecks reduces the checks of a commit to one state, the one the
574// merge rule uses too: a failed check makes the commit failing even while
575// others are pending; else a pending check makes it pending; else it is a
576// success.
577func CombineChecks(checks []CICheck) CheckState {
578	if len(checks) == 0 {
579		return CheckStateNone
580	}
581	pending := false
582	for _, c := range checks {
583		switch {
584		case c.Status != CICompleted:
585			pending = true
586		case !c.Conclusion.PassesMerge():
587			return CheckStateFailure
588		}
589	}
590	if pending {
591		return CheckStatePending
592	}
593	return CheckStateSuccess
594}

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 3

Tags 43

↑↓ moveenter openesc close