v0.7.0 45bfc5b

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

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 45

↑↓ moveenter openesc close