v0.12.1 7328df5

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

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