v0.11.0 0633e44

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

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