v0.8.1 b8e893f

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

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