v0.10.0 ce48ee3

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	// Name is GitHub's name of the run: the evaluated run-name when the
165	// workflow has one, else WorkflowName. WorkflowName stays the name of the
166	// workflow (github.workflow, the checks).
167	Name              string
168	Title             string
169	Event             string
170	Ref               string
171	Sha               string
172	Actor             string
173	Status            CIStatus
174	Conclusion        CIConclusion
175	Error             string
176	CreatedAt         time.Time
177	StartedAt         time.Time
178	CompletedAt       time.Time
179	CancelRequested   bool
180	CancelRequestedAt time.Time
181	// Version is the version of the stored record, which grows at every
182	// change and never goes back.
183	Version uint64
184}
185
186// CIRunSource is what a run was planned from: the workflow file at the sha of
187// the run, and the "github" context without the parts that depend on the job
188// and the attempt.
189type CIRunSource struct {
190	RunID        int64
191	Repo         RepoName
192	WorkflowYAML string
193	GithubJSON   string
194	// Pusher is who pushed the commit: what the actions of other repositories
195	// of the server may be resolved on behalf of.
196	Pusher CIPusher
197}
198
199// CIStep is a step of a job with where its lines are in the job log.
200type CIStep struct {
201	Number     int32
202	Name       string
203	State      CIStepState
204	Conclusion CIConclusion
205	// Outcome is how the step ended before continue-on-error was applied
206	// ("steps.<id>.outcome"). Empty for a step that was not completed by a
207	// runner that reports it: read it as Conclusion.
208	Outcome     CIConclusion
209	StartedAt   time.Time
210	CompletedAt time.Time
211	LogStart    int64
212	// HasLines is set once a line of the step reached the log; LogStart is
213	// then the number of its first line. LogEnd is not stored: see StepRanges.
214	HasLines bool
215}
216
217// CIJob is one job instance (one matrix combination) in one attempt.
218type CIJob struct {
219	ID    string
220	RunID int64
221	// Attempt is the attempt in which the job executed, RunAttempt the attempt
222	// of the run the record belongs to: they differ for a job kept by a re-run
223	// of the failed jobs.
224	Attempt    int32
225	RunAttempt int32
226	Repo       RepoName
227	Key        string
228	Name       string
229	Phase      CIJobPhase
230	Conclusion CIConclusion
231	Error      string
232	Labels     []string
233	Needs      []string
234	RunnerID   string
235	RunnerName string
236	Isolation  CIIsolation
237	Steps      []CIStep
238
239	CreatedAt   time.Time
240	StartedAt   time.Time
241	CompletedAt time.Time
242
243	// Index is the position of the job in the workflow file, Combo the
244	// position of the instance in its matrix.
245	Index int
246	Combo int
247	// Planned is set once runs-on, the name and the contexts are evaluated.
248	// A job that is not planned has not been expanded yet.
249	Planned    bool
250	MatrixJSON string
251	// ContextsJSON is the JSON object of contexts given to the runner, computed
252	// when the job is planned.
253	ContextsJSON string
254	// ComboTotal is the number of instances of the job's matrix.
255	ComboTotal int
256	// TimeoutMinutes is the timeout of the job, at least 1.
257	TimeoutMinutes int32
258	FailFast       bool
259	// ContinueOnError is the job's continue-on-error, evaluated when the job is
260	// planned: its failure leaves the run, its siblings and the jobs that
261	// need it unaffected (needs.<job>.result is success). The conclusion of
262	// the job itself stays failure.
263	ContinueOnError bool
264	// MaxParallel is the max-parallel of the strategy, 0 for no limit.
265	MaxParallel int
266	Outputs     map[string]string
267
268	// TokenHash is the hash of the job token of the current assignment; zero
269	// when there is none.
270	TokenHash       TokenHash
271	TokenValidUntil time.Time
272	ReadyAt         time.Time
273	AssignedAt      time.Time
274	AckedAt         time.Time
275	// AssignCancels counts the assignments cancelled in a row.
276	AssignCancels int
277
278	CancelReason      CICancelReason
279	CancelMessage     string
280	CancelRequestedAt time.Time
281
282	Version uint64
283}
284
285// Status is the status owners see: a job is queued until its runner
286// acknowledged it.
287func (j CIJob) Status() CIStatus {
288	switch j.Phase {
289	case CIPhaseRunning:
290		return CIInProgress
291	case CIPhaseEnded:
292		return CICompleted
293	}
294	return CIQueued
295}
296
297// Active reports whether the job is given to a runner and not over.
298func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
299
300// Ended reports whether the job is over.
301func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
302
303// HasToken reports whether a job token is set.
304func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
305
306// CICheck is the result of one job on one commit.
307type CICheck struct {
308	ID           string
309	Repo         RepoName
310	Sha          string
311	Name         string
312	Status       CIStatus
313	Conclusion   CIConclusion
314	RunID        int64
315	JobID        string
316	Attempt      int32
317	WorkflowName string
318	CreatedAt    time.Time
319	StartedAt    time.Time
320	CompletedAt  time.Time
321	Version      uint64
322}
323
324// CIRunner is a registered CI runner. TokenHash is never sent to clients.
325type CIRunner struct {
326	ID           string
327	Name         string
328	Labels       []string
329	Isolation    CIIsolation
330	Version      string
331	Arch         string
332	OS           string
333	RegisteredAt time.Time
334	TokenHash    TokenHash
335}
336
337// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
338const (
339	RunnerTokenPrefix = "ermiterun_"
340	JobTokenPrefix    = "ermitejob_"
341	// CITokenRandomLen is the length of the base64url part of both.
342	CITokenRandomLen = 43
343)
344
345// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
346func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
347func IsJobToken(s string) bool    { return hasTokenShape(s, JobTokenPrefix) }
348
349func hasTokenShape(s, prefix string) bool {
350	rest, ok := strings.CutPrefix(s, prefix)
351	if !ok || len(rest) != CITokenRandomLen {
352		return false
353	}
354	for _, r := range rest {
355		switch {
356		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
357		default:
358			return false
359		}
360	}
361	return true
362}
363
364// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
365// every attempt of the run.
366func NewJobID(runID int64, index, combo int) string {
367	return fmt.Sprintf("%d-%d-%d", runID, index, combo)
368}
369
370// ParseJobID returns the run of a job id, or ok false when s is not one.
371func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
372	parts := strings.Split(s, "-")
373	if len(parts) != 3 {
374		return 0, 0, 0, false
375	}
376	r, err1 := strconv.ParseInt(parts[0], 10, 64)
377	i, err2 := strconv.Atoi(parts[1])
378	c, err3 := strconv.Atoi(parts[2])
379	if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
380		return 0, 0, 0, false
381	}
382	return r, i, c, true
383}
384
385var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
386
387// ParseRunnerName validates a runner name.
388func ParseRunnerName(s string) error {
389	if !labelRE.MatchString(s) {
390		return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
391	}
392	return nil
393}
394
395// ParseRunnerLabels validates labels and returns them lowercased, without
396// duplicates, in order of first appearance.
397func ParseRunnerLabels(labels []string) ([]string, error) {
398	if len(labels) > CIMaxLabels {
399		return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
400	}
401	var out []string
402	for _, l := range labels {
403		if !labelRE.MatchString(l) {
404			return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
405		}
406		l = strings.ToLower(l)
407		if !slices.Contains(out, l) {
408			out = append(out, l)
409		}
410	}
411	return out, nil
412}
413
414// RunnerLabels returns the labels a runner matches: the ones it chose plus
415// the implicit "self-hosted", its os and its arch, lowercase and sorted.
416func RunnerLabels(chosen []string, os, arch string) []string {
417	out := slices.Clone(chosen)
418	for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
419		if !slices.Contains(out, l) {
420			out = append(out, l)
421		}
422	}
423	slices.Sort(out)
424	return out
425}
426
427// LabelsMatch reports whether every wanted label is among have, ignoring case.
428func LabelsMatch(want, have []string) bool {
429	for _, w := range want {
430		if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
431			return false
432		}
433	}
434	return true
435}
436
437var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
438
439// CheckOutputs validates the outputs of a job: names, case-insensitive
440// uniqueness and the 1 MiB limits.
441func CheckOutputs(outputs map[string]string) error {
442	seen := make(map[string]bool, len(outputs))
443	total := 0
444	for k, v := range outputs {
445		if !outputNameRE.MatchString(k) {
446			return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
447		}
448		lk := strings.ToLower(k)
449		if seen[lk] {
450			return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
451		}
452		seen[lk] = true
453		if len(v) > CIMaxOutputBytes {
454			return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
455		}
456		total += len(k) + len(v)
457	}
458	if total > CIMaxOutputBytes {
459		return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
460	}
461	return nil
462}
463
464// JobEndedError is returned for a call that needs a running job when the job
465// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
466// detail.
467type JobEndedError struct {
468	Conclusion CIConclusion
469	Message    string
470}
471
472// Error implements error.
473func (e *JobEndedError) Error() string {
474	return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
475}
476
477// Is makes errors.Is(err, ErrPreconditionFailed) true.
478func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
479
480// IsJobEnded returns the JobEndedError in err.
481func IsJobEnded(err error) (*JobEndedError, bool) {
482	var e *JobEndedError
483	if errors.As(err, &e) {
484		return e, true
485	}
486	return nil, false
487}
488
489// ErrOutOfRange means an offset is beyond what the server holds.
490var ErrOutOfRange = errors.New("out of range")
491
492// CILogLine is one line of a job log.
493type CILogLine struct {
494	Time time.Time
495	// Text has no line feed.
496	Text string
497	// Continued marks a piece of a line that was too long to send whole.
498	Continued bool
499}
500
501// StepRange is where the lines of a step are in the job log.
502type StepRange struct {
503	Start, End int64
504}
505
506// StepRanges returns the range of every step of the job for a log of lines
507// lines: a step with lines ends where the next step with lines begins (the
508// last ends at the end of the log); a step without lines is empty, placed
509// where the previous step ends.
510func StepRanges(steps []CIStep, lines int64) []StepRange {
511	out := make([]StepRange, len(steps))
512	var pos int64
513	for i, s := range steps {
514		if !s.HasLines {
515			out[i] = StepRange{Start: pos, End: pos}
516			continue
517		}
518		end := lines
519		for _, n := range steps[i+1:] {
520			if n.HasLines {
521				end = n.LogStart
522				break
523			}
524		}
525		out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
526		pos = out[i].End
527	}
528	return out
529}
530
531// StepOfLine returns the number of the step a log line belongs to, 0 when no
532// step has that line.
533func StepOfLine(steps []CIStep, line int64) int32 {
534	var found int32
535	for _, s := range steps {
536		if s.HasLines && s.LogStart <= line {
537			found = s.Number
538		}
539	}
540	return found
541}
542
543// RunUpdated is published when a CI run is created or changes. It carries the
544// visibility and the creation time of the repository, like PushEvent.
545type RunUpdated struct {
546	Run        CIRun
547	At         time.Time
548	Visibility Visibility
549	CreatedAt  time.Time
550}
551
552// EventKind implements Event.
553func (RunUpdated) EventKind() string { return "ci.run_updated" }
554
555// JobUpdated is published when a CI job is created or changes (status,
556// conclusion, runner or steps; never per log line). Lines is the number of
557// lines of the job log at that time, for the end of the step in progress.
558type JobUpdated struct {
559	Job        CIJob
560	Lines      int64
561	At         time.Time
562	Visibility Visibility
563	CreatedAt  time.Time
564}
565
566// EventKind implements Event.
567func (JobUpdated) EventKind() string { return "ci.job_updated" }
568
569// CheckState is the combined state of the checks of a commit.
570type CheckState string
571
572// Combined check states.
573const (
574	// CheckStateNone: the commit has no check.
575	CheckStateNone CheckState = ""
576	// CheckStatePending: a check is queued or in progress, none has failed.
577	CheckStatePending CheckState = "pending"
578	// CheckStateFailure: a check failed (or needs an action), whatever the others.
579	CheckStateFailure CheckState = "failure"
580	// CheckStateSuccess: every check passed.
581	CheckStateSuccess CheckState = "success"
582)
583
584// CombineChecks reduces the checks of a commit to one state, the one the
585// merge rule uses too: a failed check makes the commit failing even while
586// others are pending; else a pending check makes it pending; else it is a
587// success.
588func CombineChecks(checks []CICheck) CheckState {
589	if len(checks) == 0 {
590		return CheckStateNone
591	}
592	pending := false
593	for _, c := range checks {
594		switch {
595		case c.Status != CICompleted:
596			pending = true
597		case !c.Conclusion.PassesMerge():
598			return CheckStateFailure
599		}
600	}
601	if pending {
602		return CheckStatePending
603	}
604	return CheckStateSuccess
605}

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