v0.13.0 dc42452

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

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