detached fda3c2b

ci_reusable.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"crypto/sha256"
8	"encoding/hex"
9	"encoding/json"
10	"errors"
11	"fmt"
12	"io"
13	"slices"
14	"strconv"
15	"strings"
16
17	"go.yaml.in/yaml/v3"
18
19	"bnema.dev/ermite-actions/expr"
20	"bnema.dev/ermite-actions/template"
21	"bnema.dev/ermite-actions/workflow"
22	"bnema.dev/ermite/internal/domain"
23)
24
25// Reusable workflows (CI-3, D7). ermite owns the job graph, so a job with
26// "uses:" is expanded at planning, not by the runner:
27//
28//   - the job becomes a call record (one per matrix leg) that never runs; it ends
29//     when the jobs of its scope have, with their aggregate conclusion, and its
30//     outputs are the callee's "on.workflow_call.outputs";
31//   - each job of the called workflow becomes a record of that scope, keyed
32//     "<caller key>/<callee id>", named "<caller name> / <callee name>", planned
33//     when the callee's own "needs" have ended (the caller's "needs" ended
34//     before the call was expanded, so they hold for every callee root);
35//   - the called file is kept in the call record, so every job of the call runs
36//     from the file of the commit that was resolved, and the runner is given that
37//     file and the callee job id.
38//
39// Texts are the ones of actions/runner (ReusableWorkflowsLoader.cs,
40// WorkflowTemplateConverter.cs, PermissionsHelper.cs) and languageservices
41// (validate.ts) where they exist.
42
43// maxReusableWorkflowDepth is the nesting limit of reusable workflows
44// (languageservices workflow-parser/src/model/convert.ts:49, 4; the runner's
45// check is ReusableWorkflowsLoader.LoadRecursive).
46const maxReusableWorkflowDepth = 4
47
48// maxJobIndex is the highest job position a check id can hold (checkIndexBits).
49const maxJobIndex = 1<<checkIndexBits - 1
50
51// callRef is a parsed "uses:" of a job.
52type callRef struct {
53	local       bool
54	owner, repo string
55	path        string // .github/workflows/x.yml
56	ref         string
57}
58
59// parseCallRef reads the "uses:" of a job calling a workflow. The texts are
60// languageservices' (validate.ts validateWorkflowUsesFormat).
61func parseCallRef(uses string) (callRef, string) {
62	bad := func(reason string) (callRef, string) {
63		return callRef{}, "Invalid workflow reference '" + uses + "': " + reason
64	}
65	if strings.HasPrefix(uses, "./.github/workflows/") {
66		switch {
67		case strings.Contains(uses, "@"):
68			return bad("cannot specify version when calling local workflows")
69		case !strings.HasSuffix(uses, ".yml") && !strings.HasSuffix(uses, ".yaml"):
70			return bad("workflow file should have either a '.yml' or '.yaml' file extension")
71		case len(strings.Split(uses, "/")) != 4:
72			return bad("workflows must be defined at the top level of the .github/workflows/ directory")
73		case strings.HasSuffix(uses, "/.yml") || strings.HasSuffix(uses, "/.yaml"):
74			return bad("invalid workflow file name")
75		}
76		return callRef{local: true, path: strings.TrimPrefix(uses, "./")}, ""
77	}
78	if strings.HasPrefix(uses, "./") {
79		return bad("local workflow references must be rooted in '.github/workflows'")
80	}
81	parts := strings.Split(uses, "@")
82	switch {
83	case len(parts) == 1 || (len(parts) == 2 && parts[1] == ""):
84		return bad("no version specified")
85	case len(parts) > 2:
86		return bad("too many '@' in workflow reference")
87	}
88	i := strings.Index(parts[0], ".github/workflows/")
89	if i < 0 {
90		return bad("references to workflows must be rooted in '.github/workflows'")
91	}
92	nwo := strings.FieldsFunc(parts[0][:i], func(r rune) bool { return r == '/' })
93	if len(nwo) != 2 {
94		return bad("references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows")
95	}
96	if !validActionName(nwo[0]) || !validActionName(nwo[1]) {
97		return bad("owner name must be a valid repository owner name")
98	}
99	path := parts[0][i:]
100	for _, s := range strings.Split(path, "/") {
101		if s == "" || s == "." || s == ".." {
102			return bad("references to workflows must be rooted in '.github/workflows'")
103		}
104	}
105	return callRef{owner: nwo[0], repo: nwo[1], path: path, ref: parts[1]}, ""
106}
107
108// callFailure is a call that cannot be expanded; text is the job's error.
109type callFailure struct{ text string }
110
111func (e callFailure) Error() string { return e.text }
112
113func isCallFailure(err error) (string, bool) {
114	var cf callFailure
115	if errors.As(err, &cf) {
116		return cf.text, true
117	}
118	return "", false
119}
120
121// calledFile is a workflow file read for a call.
122type calledFile struct {
123	repository, path, ref, sha string
124	yaml                       []byte
125}
126
127// loadCallee finds the file a "uses:" names. home is the call whose file holds
128// the "uses:" (nil for the run's own workflow): a local reference is read in
129// the repository and at the commit of that file.
130func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, rec domain.CIJob, ref callRef) (calledFile, error) {
131	var f calledFile
132	if ref.local {
133		f.path = ref.path
134		if home == nil {
135			f.repository, f.sha = rs.run.Repo.String(), rs.run.Sha
136		} else {
137			f.repository, f.sha = home.Repository, home.Sha
138		}
139		return f, c.readCallee(ctx, &f)
140	}
141	f.path, f.ref = ref.path, ref.ref
142	ar := actionRef{uses: ref.owner + "/" + ref.repo + "@" + ref.ref, owner: ref.owner, repo: ref.repo, ref: ref.ref}
143	by := c.onBehalfOf(*rs.src)
144	// One resolution per reference and attempt: what this planning resolved
145	// already (not stored yet), else what the attempt stored, else a fresh one.
146	var res domain.CIActionResolution
147	found := false
148	for _, r := range rs.resolutions {
149		if r.RunID == rec.RunID && r.RunAttempt == rec.RunAttempt && r.Key == ar.key() {
150			res, found = r, true
151			break
152		}
153	}
154	if !found {
155		stored, err := c.cfg.Store.GetActionResolution(ctx, rec.RunID, rec.RunAttempt, ar.key())
156		switch {
157		case err == nil:
158			res = stored
159		case errors.Is(err, domain.ErrNotFound):
160			var msg string
161			res, msg, err = c.resolveAction(ctx, rec, ar, c.newRemoteRefs(rec.RunAttempt), by)
162			if err != nil {
163				return f, err
164			}
165			if msg != "" {
166				return f, callFailure{msg}
167			}
168			rs.resolutions = append(rs.resolutions, res)
169		default:
170			return f, err
171		}
172	}
173	// The source guard of ResolveActions (answer), whatever the resolution came
174	// from: a repository of this server that the actor cannot read is never
175	// served from anywhere else, and a stored ermite resolution of a repository
176	// that is gone has nothing to serve.
177	repo, onErmite, err := c.ermiteRepoOf(ctx, ar)
178	if err != nil {
179		return f, err
180	}
181	if onErmite {
182		if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
183			return f, err
184		} else if !ok {
185			return f, callFailure{errUnresolvedRepo(ar)}
186		}
187	}
188	f.sha = res.ResolvedSHA
189	switch res.Source {
190	case domain.CIActionErmite:
191		if !onErmite {
192			return f, callFailure{errUnresolvedRepo(ar)}
193		}
194		f.repository = repo.Name.String()
195	default:
196		f.repository = ref.owner + "/" + ref.repo
197	}
198	return f, c.readCallee(ctx, &f)
199}
200
201// readCallee fills f.yaml from the repository of the server or github.com.
202func (c *CI) readCallee(ctx context.Context, f *calledFile) error {
203	notFound := callFailure{"Unable to find reusable workflow '" + f.path + "' in '" + f.repository + "' at '" + f.sha + "'"}
204	if owner, repo, ok := strings.Cut(f.repository, "/"); ok {
205		if c.cfg.Workflows == nil {
206			return fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
207		}
208		data, err := c.cfg.Workflows.FetchWorkflow(ctx, owner, repo, f.sha, f.path, maxCIWorkflowBytes)
209		switch {
210		case errors.Is(err, domain.ErrNotFound):
211			return notFound
212		case errors.Is(err, domain.ErrResourceExhausted):
213			return callFailure{f.path + ": the workflow file is larger than 1 MiB"}
214		case err != nil:
215			return err
216		}
217		f.yaml = data
218		return nil
219	}
220	name, err := domain.ParseRepoName(f.repository)
221	if err != nil {
222		return notFound
223	}
224	id, err := domain.ParseObjectID(f.sha)
225	if err != nil {
226		return notFound
227	}
228	tp, err := domain.ParseTreePath(f.path)
229	if err != nil {
230		return notFound
231	}
232	_, rc, err := c.cfg.Reader.OpenBlob(ctx, name, domain.RevisionOf(id), tp)
233	if errors.Is(err, domain.ErrNotFound) {
234		return notFound
235	}
236	if err != nil {
237		return err
238	}
239	defer func() { _ = rc.Close() }()
240	data, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
241	if err != nil {
242		return err
243	}
244	if len(data) > maxCIWorkflowBytes {
245		return callFailure{f.path + ": the workflow file is larger than 1 MiB"}
246	}
247	f.yaml = data
248	return nil
249}
250
251// callerPrefix is the text GitHub puts before an error of the called workflow
252// (TemplateStrings: CalledWorkflowNotValidWithErrors, LineColumn).
253func callerPrefix(wj *workflow.Job) string {
254	file, pos := "", template.Pos{}
255	if wj.Uses != nil {
256		file, pos = wj.Uses.File(), wj.Uses.Pos()
257	}
258	return fmt.Sprintf("In %s (Line: %d, Col: %d): Error from called workflow", file, pos.Line, pos.Col)
259}
260
261// calleeSpec is what ermite reads of "on.workflow_call" that the workflow model
262// does not keep: the outputs and the secrets (ermite-actions parses the inputs).
263type calleeSpec struct {
264	outputs map[string]string
265	secrets map[string]bool // name (upper case) to required
266}
267
268// readCalleeSpec reads on.workflow_call.outputs and .secrets from the YAML.
269func readCalleeSpec(src []byte) calleeSpec {
270	spec := calleeSpec{outputs: map[string]string{}, secrets: map[string]bool{}}
271	var doc yaml.Node
272	if yaml.Unmarshal(src, &doc) != nil || len(doc.Content) == 0 {
273		return spec
274	}
275	root := derefYAML(doc.Content[0])
276	for i := 0; root.Kind == yaml.MappingNode && i+1 < len(root.Content); i += 2 {
277		if root.Content[i].Value != "on" {
278			continue
279		}
280		on := derefYAML(root.Content[i+1])
281		for j := 0; on.Kind == yaml.MappingNode && j+1 < len(on.Content); j += 2 {
282			if on.Content[j].Value != "workflow_call" {
283				continue
284			}
285			wc := derefYAML(on.Content[j+1])
286			for k := 0; wc.Kind == yaml.MappingNode && k+1 < len(wc.Content); k += 2 {
287				body := derefYAML(wc.Content[k+1])
288				for m := 0; body.Kind == yaml.MappingNode && m+1 < len(body.Content); m += 2 {
289					name, def := body.Content[m].Value, derefYAML(body.Content[m+1])
290					switch wc.Content[k].Value {
291					case "outputs":
292						for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 {
293							if def.Content[q].Value == "value" {
294								spec.outputs[name] = derefYAML(def.Content[q+1]).Value
295							}
296						}
297					case "secrets":
298						req := false
299						for q := 0; def.Kind == yaml.MappingNode && q+1 < len(def.Content); q += 2 {
300							if def.Content[q].Value == "required" {
301								req = derefYAML(def.Content[q+1]).Value == "true"
302							}
303						}
304						spec.secrets[strings.ToUpper(name)] = req
305					}
306				}
307			}
308		}
309	}
310	return spec
311}
312
313func derefYAML(n *yaml.Node) *yaml.Node {
314	for range 16 {
315		if n.Kind != yaml.AliasNode || n.Alias == nil {
316			return n
317		}
318		n = n.Alias
319	}
320	return n
321}
322
323// callInputs checks the "with:" of a call against the inputs of the called
324// workflow and returns the inputs context: the value given, else the default,
325// else the zero of the type. Texts: WorkflowTemplateConverter.cs
326// (ConvertToWorkflowJobInputs, ValidateUndefinedParameters).
327func callInputs(ctx context.Context, defs []workflow.Input, given *expr.Dictionary, defEnv template.Env) (*expr.Dictionary, error) {
328	out := expr.NewDictionary()
329	byName := map[string]bool{}
330	for _, in := range defs {
331		byName[strings.ToLower(in.Name)] = true
332		var v expr.Value
333		have := false
334		if given != nil {
335			for _, p := range given.Pairs() {
336				if strings.EqualFold(p.Key, in.Name) {
337					cv, err := coerceInput(in, p.Value)
338					if err != nil {
339						return nil, err
340					}
341					v, have = cv, true
342				}
343			}
344		}
345		if !have && in.HasDefault {
346			dv, err := evalInterpolated(ctx, in.Default, defEnv)
347			if err != nil {
348				return nil, err
349			}
350			cv, err := coerceInput(in, dv)
351			if err != nil {
352				return nil, err
353			}
354			v, have = cv, true
355		}
356		if !have && in.Required {
357			return nil, callFailure{"Input " + in.Name + " is required, but not provided while calling."}
358		}
359		if !have {
360			switch in.Type {
361			case "boolean":
362				v = expr.Boolean(false)
363			case "number":
364				v = expr.Number(0)
365			default:
366				v = expr.String("")
367			}
368		}
369		out.Add(in.Name, v)
370	}
371	if given != nil {
372		for _, p := range given.Pairs() {
373			if !byName[strings.ToLower(p.Key)] {
374				return nil, callFailure{"Invalid input, " + p.Key + " is not defined in the referenced workflow."}
375			}
376		}
377	}
378	return out, nil
379}
380
381// coerceInput converts a value to the type of an input, as the template engine
382// does for a boolean, number or string (GetDefaultValueByType,
383// ConvertToInputValueDefinedType): a mismatch is "Unexpected value".
384func coerceInput(in workflow.Input, v expr.Value) (expr.Value, error) {
385	unexpected := func() (expr.Value, error) {
386		return nil, callFailure{"Unexpected value '" + expr.CoerceString(v) + "' for input '" + in.Name + "' of type '" + in.Type + "'"}
387	}
388	switch in.Type {
389	case "boolean":
390		switch x := v.(type) {
391		case expr.Boolean:
392			return x, nil
393		case expr.String:
394			switch strings.ToLower(string(x)) {
395			case "true":
396				return expr.Boolean(true), nil
397			case "false":
398				return expr.Boolean(false), nil
399			}
400		}
401		return unexpected()
402	case "number":
403		switch x := v.(type) {
404		case expr.Number:
405			return x, nil
406		case expr.String:
407			if f, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64); err == nil {
408				return expr.Number(f), nil
409			}
410		}
411		return unexpected()
412	}
413	switch v.(type) {
414	case expr.String, expr.Number, expr.Boolean:
415		if s, ok := v.(expr.String); ok {
416			return s, nil
417		}
418		return expr.String(expr.CoerceString(v)), nil
419	}
420	return unexpected()
421}
422
423// evalInterpolated evaluates a string with ${{ }} expressions (a default of an
424// input, the value of an output). A string that is one expression keeps the
425// type of its value.
426func evalInterpolated(ctx context.Context, s string, env template.Env) (expr.Value, error) {
427	if !strings.Contains(s, "${{") {
428		return expr.String(s), nil
429	}
430	ctxNames := make([]string, 0, len(env.Contexts))
431	for k := range env.Contexts {
432		ctxNames = append(ctxNames, k)
433	}
434	eval := func(src string) (expr.Value, error) {
435		node, err := expr.Parse(src, ctxNames, exprFuncs)
436		if err != nil {
437			return nil, err
438		}
439		return expr.Evaluate(ctx, node, expr.EvalContext{Contexts: env.Contexts})
440	}
441	exprs := expressionsIn(s)
442	if t := strings.TrimSpace(s); len(exprs) == 1 && strings.HasPrefix(t, "${{") && strings.HasSuffix(t, "}}") && strings.Count(t, "${{") == 1 {
443		return eval(exprs[0])
444	}
445	var b strings.Builder
446	rest := s
447	for _, src := range exprs {
448		i := strings.Index(rest, "${{")
449		b.WriteString(rest[:i])
450		v, err := eval(src)
451		if err != nil {
452			return nil, err
453		}
454		b.WriteString(expr.CoerceString(v))
455		rest = rest[i+3+len(src)+2:]
456	}
457	b.WriteString(rest)
458	return expr.String(b.String()), nil
459}
460
461// calleeOf parses the workflow a call record kept.
462func (rs *runState) calleeOf(call *domain.CIJob) (*workflow.Workflow, error) {
463	if call == nil || call.Call == nil {
464		return nil, fmt.Errorf("the call of this job is gone")
465	}
466	return rs.c.flows.parseCalled(call.Call.Path, call.Call.YAML)
467}
468
469// scopeOf finds the call record of a scoped job, nil for a job of the run's own
470// workflow.
471func (rs *runState) scopeOf(j domain.CIJob) *domain.CIJob {
472	if j.Scope == "" {
473		return nil
474	}
475	return rs.find(j.Scope)
476}
477
478// inputsOf is the "inputs" context of a job: its call's inputs, empty at the
479// top.
480func (rs *runState) inputsOf(j domain.CIJob) *expr.Dictionary {
481	if sc := rs.scopeOf(j); sc != nil && sc.Call != nil {
482		if v, err := expr.FromJSON([]byte(sc.Call.InputsJSON)); err == nil {
483			if d, ok := v.(*expr.Dictionary); ok {
484				return d
485			}
486		}
487		return expr.NewDictionary()
488	}
489	if j.Scope == "" && rs.run.Event == eventDispatch {
490		// The inputs of a workflow_dispatch run, typed by their declaration.
491		if wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML); err == nil {
492			return eventInputs(wf, rs.src.GithubJSON)
493		}
494	}
495	return expr.NewDictionary()
496}
497
498// instancesIn returns the indexes of the records of job id in a scope.
499func (rs *runState) instancesIn(scope, id string) []int {
500	var out []int
501	for i, j := range rs.jobs {
502		if j.Scope == scope && ((scope == "" && j.Key == id) || (scope != "" && j.CallID == id)) {
503			out = append(out, i)
504		}
505	}
506	return out
507}
508
509// rootKey is the key of the job of the run's own workflow a record descends
510// from.
511func rootKey(key string) string {
512	root, _, _ := strings.Cut(key, "/")
513	return root
514}
515
516// nextIndex is a position for a job added to the run.
517func (rs *runState) nextIndex() int {
518	n := 0
519	for _, j := range rs.jobs {
520		n = max(n, j.Index+1)
521	}
522	return n
523}
524
525// planCall expands a job that calls a reusable workflow. rec (at idx) becomes
526// the first call record, one per matrix leg; the jobs of the called workflow are
527// added as waiting records of the scope of each leg.
528func (rs *runState) planCall(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, env template.Env) error {
529	rec := rs.jobs[idx]
530	parent := rs.scopeOf(rec)
531	var home *domain.CICall
532	depth := 1
533	if parent != nil {
534		home, depth = parent.Call, parent.Call.Depth+1
535	}
536	if depth > maxReusableWorkflowDepth {
537		return callFailure{fmt.Sprintf("Nested reusable workflow depth exceeded %d.", maxReusableWorkflowDepth)}
538	}
539	ref, text := parseCallRef(wj.Uses.Value)
540	if text != "" {
541		return callFailure{text}
542	}
543	file, err := rs.c.loadCallee(ctx, rs, home, rec, ref)
544	if err != nil {
545		return err
546	}
547	prefix := callerPrefix(wj)
548	cw, errs := parseWorkflow(file.path, file.yaml)
549	if len(errs) > 0 {
550		return callFailure{prefix + " " + errs[0].Error()}
551	}
552	wc := cw.On.Event("workflow_call")
553	if wc == nil {
554		return callFailure{prefix + " workflow_call key is not defined in the referenced workflow."}
555	}
556	spec := readCalleeSpec(file.yaml)
557
558	eff, err := rs.callPermissions(wf, wj, rec, parent)
559	if err != nil {
560		return err
561	}
562	plan, err := wj.EvaluateStrategy(ctx, env)
563	if err != nil {
564		return err
565	}
566	vars, _ := env.Contexts["vars"].(*expr.Dictionary)
567	if vars == nil {
568		vars = expr.NewDictionary()
569	}
570	gh := env.Contexts["github"]
571
572	// The records of the run are bounded before anything changes: the calls, and
573	// the jobs of the called workflow for each (a job that is a matrix adds its
574	// legs when it is planned, checked there).
575	if err := rs.checkJobBudget(len(plan.Combos) - 1 + len(plan.Combos)*len(cw.Jobs)); err != nil {
576		return err
577	}
578	legs := make([]domain.CIJob, 0, len(plan.Combos))
579	for i := range plan.Combos {
580		menv, err := plan.MatrixEnv(env, i)
581		if err != nil {
582			return err
583		}
584		var given *expr.Dictionary
585		if wj.With != nil {
586			v, err := template.Evaluate(ctx, wj.With, menv)
587			if err != nil {
588				return err
589			}
590			given, _ = v.(*expr.Dictionary)
591		}
592		inEnv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars}}
593		inputs, err := callInputs(ctx, wc.Inputs, given, inEnv)
594		if err != nil {
595			if text, ok := isCallFailure(err); ok {
596				return callFailure{prefix + " " + text}
597			}
598			return err
599		}
600		// The group of the calling job (job-level, evaluated like the group of
601		// any job) and the workflow-level group of the called workflow
602		// (evaluated with the caller's github context and the call's inputs).
603		// The call record is the entry of both.
604		var jobConc, wfConc domain.CIConcurrency
605		if wj.Concurrency != nil {
606			if jobConc, err = evalConcurrency(ctx, wj.Concurrency, menv); err != nil {
607				return err
608			}
609			jobConc.Evaluated = true
610		}
611		if cw.Concurrency != nil {
612			wenv := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": inputs, "vars": vars}}
613			if wfConc, err = evalConcurrency(ctx, cw.Concurrency, wenv); err != nil {
614				return callFailure{prefix + " " + err.Error()}
615			}
616			wfConc.Evaluated = true
617		}
618		call := domain.CICall{
619			Uses: wj.Uses.Value, Path: file.path, Repository: file.repository, Ref: file.ref, Sha: file.sha, YAML: string(file.yaml),
620			InputsJSON: compactJSON(expr.ToJSON(inputs)), Depth: depth, Expanded: true, Concurrency: wfConc,
621		}
622		if err := rs.callSecrets(wj, menv, spec, &call); err != nil {
623			if text, ok := isCallFailure(err); ok {
624				return callFailure{prefix + " " + text}
625			}
626			return err
627		}
628		name, err := wj.DisplayName(ctx, menv, plan, i)
629		if err != nil {
630			return err
631		}
632		leg := rec
633		leg.Combo = i
634		leg.ID = domain.NewJobID(rs.run.ID, rec.Index, i)
635		if i > 0 {
636			leg.Version = 0
637		}
638		leg.Name = strings.TrimSpace(name)
639		if parent != nil {
640			leg.Name = parent.Name + " / " + leg.Name // "<caller> / <nested caller>"
641		}
642		leg.Call, leg.Planned, leg.Phase = &call, true, domain.CIPhaseWaiting
643		leg.Permissions = eff
644		leg.Concurrency = jobConc
645		leg.MatrixJSON = compactJSON(expr.ToJSON(plan.Combos[i].Values))
646		leg.ContextsJSON, leg.ComboTotal = "{}", len(plan.Combos)
647		leg.FailFast, leg.MaxParallel = plan.FailFast, plan.MaxParallel
648		leg.Labels, leg.TimeoutMinutes = nil, 0
649		legs = append(legs, leg)
650	}
651
652	// The new positions follow the highest one in use.
653	next := rs.nextIndex()
654	if next+len(legs)*len(cw.Jobs) > maxJobIndex+1 {
655		return callFailure{"The workflow has more jobs than the server tracks (" + strconv.Itoa(maxJobIndex+1) + ")."}
656	}
657	rs.jobs[idx] = legs[0]
658	rs.touch(&rs.jobs[idx])
659	for _, l := range legs[1:] {
660		rs.jobs = append(rs.jobs, l)
661		rs.dirty[l.ID] = true
662	}
663	next = rs.nextIndex()
664	now := rs.c.now()
665	for _, l := range legs {
666		for _, cj := range cw.Jobs {
667			needs := make([]string, len(cj.Needs))
668			for i, n := range cj.Needs {
669				needs[i] = l.Key + "/" + n
670			}
671			id := domain.NewJobID(rs.run.ID, next, 0)
672			rs.jobs = append(rs.jobs, domain.CIJob{
673				ID: id, RunID: rs.run.ID, Attempt: rs.attempt, RunAttempt: rs.attempt, Repo: rs.run.Repo, Key: l.Key + "/" + cj.ID,
674				Name: cj.ID, Phase: domain.CIPhaseWaiting, Needs: needs, Index: next, CreatedAt: now, Scope: l.ID, CallID: cj.ID,
675			})
676			rs.dirty[id] = true
677			next++
678		}
679	}
680	rs.sortJobs()
681	return nil
682}
683
684// callPermissions is the permissions a call grants to the jobs of its scope: the
685// "permissions:" of the calling job, else of its workflow, else those the
686// containing call allows. A reusable workflow can only keep or lower them: a
687// request above the allowed is the error of PermissionsHelper.cs.
688func (rs *runState) callPermissions(wf *workflow.Workflow, wj *workflow.Job, rec domain.CIJob, parent *domain.CIJob) (map[string]string, error) {
689	got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
690	if parent == nil {
691		return got, nil
692	}
693	allowed := domain.TokenPermissions(parent.Permissions, true)
694	if got == nil {
695		return allowed, nil
696	}
697	if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
698		return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. The nested job '%s' is requesting '%s', but is only allowed '%s'.",
699			parent.Call.Uses, wj.ID, strings.Join(asked, ", "), strings.Join(ok, ", "))}
700	}
701	return got, nil
702}
703
704// scopedPermissions are the permissions of a job of a called workflow: its own
705// "permissions:", else its workflow's, else what the call allows; asking for
706// more than the call allows is an error.
707func (rs *runState) scopedPermissions(wf *workflow.Workflow, wj *workflow.Job, call *domain.CIJob) (map[string]string, error) {
708	allowed := domain.TokenPermissions(call.Permissions, true)
709	got := domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
710	if got == nil {
711		return call.Permissions, nil
712	}
713	if asked, ok := domain.PermissionViolations(got, allowed); len(asked) > 0 {
714		what := "The nested job '" + wj.ID + "'"
715		if wj.Permissions == nil {
716			what = "The workflow"
717		}
718		return nil, callFailure{fmt.Sprintf("Error calling workflow '%s'. %s is requesting '%s', but is only allowed '%s'.",
719			call.Call.Uses, what, strings.Join(asked, ", "), strings.Join(ok, ", "))}
720	}
721	return got, nil
722}
723
724// callSecrets reads the "secrets:" of a call: "inherit", or a mapping. Planning
725// validates names only (the expressions were checked against the contexts the
726// schema allows when the file was parsed) and keeps no expression and no value:
727// the mapping is read from the pinned workflow file, as a template token, when
728// a job is assigned (callMapping, mapSecrets), and evaluated once there. Only
729// the plain contexts the expressions read (needs, matrix, strategy, inputs,
730// vars: none is secret) are kept, in call.SecretCtx, as they are now.
731func (rs *runState) callSecrets(wj *workflow.Job, env template.Env, spec calleeSpec, call *domain.CICall) error {
732	if wj.InheritSecrets {
733		call.Inherit = true
734		return nil
735	}
736	if wj.Secrets == nil {
737		return rs.checkRequiredSecrets(spec, map[string]bool{})
738	}
739	call.Secrets = map[string]string{}
740	given := map[string]bool{}
741	for _, p := range wj.Secrets.Pairs {
742		name := strings.ToUpper(p.Key.String())
743		if _, ok := spec.secrets[name]; !ok {
744			return callFailure{"Invalid secret, " + p.Key.String() + " is not defined in the referenced workflow."}
745		}
746		given[name] = true
747		call.Secrets[name] = "" // the name only
748	}
749	plain := map[string]json.RawMessage{}
750	for _, k := range []string{"needs", "matrix", "strategy", "inputs", "vars"} {
751		if v, ok := env.Contexts[k]; ok && v != nil {
752			plain[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
753		}
754	}
755	b, err := json.Marshal(plain)
756	if err != nil {
757		return err
758	}
759	call.SecretCtx = string(b)
760	return rs.checkRequiredSecrets(spec, given)
761}
762
763// callMapping returns the "secrets:" mapping of a call as the template tokens of
764// the workflow file that holds the calling job: the run's own workflow, or the
765// called workflow of the call that contains it. The tokens come from the file
766// itself and are never rebuilt from text; names are upper-cased.
767func (rs *runState) callMapping(cr *domain.CIJob) (map[string]template.Token, error) {
768	path, yamlText, id := rs.run.WorkflowPath, rs.src.WorkflowYAML, cr.Key
769	if p := rs.scopeOf(*cr); p != nil && p.Call != nil {
770		path, yamlText, id = p.Call.Path, p.Call.YAML, cr.CallID
771	}
772	wf, err := rs.c.flows.parseCalled(path, yamlText)
773	if err != nil {
774		return nil, err
775	}
776	wj := wf.Job(id)
777	if wj == nil {
778		return nil, fmt.Errorf("the calling job is gone")
779	}
780	out := map[string]template.Token{}
781	if wj.Secrets != nil {
782		for _, p := range wj.Secrets.Pairs {
783			out[strings.ToUpper(p.Key.String())] = p.Value
784		}
785	}
786	return out, nil
787}
788
789// secretsReadBy returns the secrets a template token refers to: the expressions
790// of the token are parsed, not the text they produce.
791func secretsReadBy(t template.Token) secretUse {
792	w := &secretWalk{names: map[string]bool{}}
793	template.Walk(t, func(x template.Token) bool {
794		if be, ok := x.(*template.BasicExpression); ok {
795			w.expression(be.Expression)
796		}
797		return true
798	})
799	use := secretUse{All: w.all || w.tooDeep}
800	for n := range w.names {
801		use.Names = append(use.Names, n)
802	}
803	slices.Sort(use.Names)
804	return use
805}
806
807func (rs *runState) checkRequiredSecrets(spec calleeSpec, given map[string]bool) error {
808	for _, name := range sortedKeys(spec.secrets) {
809		if spec.secrets[name] && !given[name] {
810			return callFailure{"Secret " + name + " is required, but not provided while calling."}
811		}
812	}
813	return nil
814}
815
816// chainOf returns the call records a job runs under, innermost first.
817func (rs *runState) chainOf(j domain.CIJob) []*domain.CIJob {
818	var out []*domain.CIJob
819	for sc := rs.scopeOf(j); sc != nil && len(out) <= maxReusableWorkflowDepth; sc = rs.scopeOf(*sc) {
820		out = append(out, sc)
821	}
822	return out
823}
824
825// scopedSecretUse is the secrets of the run's own workflow that a job of a
826// called workflow reads, through the "secrets:" of each call that leads to it.
827// inner is the use inside the called workflow of the job.
828func (rs *runState) scopedSecretUse(j domain.CIJob) (top, inner secretUse) {
829	chain := rs.chainOf(j)
830	inner = secretsUsedBy(chain[0].Call.YAML, j.CallID)
831	cur := inner
832	for _, c := range chain {
833		cur = rs.translateUse(cur, c)
834	}
835	return cur, inner
836}
837
838// translateUse turns the secrets a called workflow reads into the secrets its
839// caller must hold: with "inherit" the same ones, else the secrets that the
840// expressions of the call's mapping, for the ones it reads, refer to (all of
841// them when an expression is not a plain name). A mapping that cannot be read
842// asks for all of them: the safe side for the job, which then gets none it
843// cannot evaluate.
844func (rs *runState) translateUse(use secretUse, cr *domain.CIJob) secretUse {
845	if cr.Call.Inherit {
846		return use
847	}
848	mapping, err := rs.callMapping(cr)
849	if err != nil {
850		return secretUse{All: true}
851	}
852	var out secretUse
853	add := func(t template.Token) {
854		u := secretsReadBy(t)
855		out.All = out.All || u.All
856		for _, n := range u.Names {
857			if !slices.Contains(out.Names, n) {
858				out.Names = append(out.Names, n)
859			}
860		}
861	}
862	for name, t := range mapping {
863		if use.All || slices.Contains(use.Names, name) {
864			add(t)
865		}
866	}
867	slices.Sort(out.Names)
868	return out
869}
870
871// mapSecrets gives a job of a called workflow the secrets it reads, named as the
872// called workflow names them. top holds the secrets of the run's own workflow
873// that it may use (empty for a run that withholds them: the caller decides who
874// gets any). From the outermost call, each mapping value is the template token
875// of the workflow file, evaluated once with the secrets of its calling scope and
876// the plain contexts the call kept; the result is a final string, never parsed
877// again, and is the secrets of the scope below. A secret not passed (not in
878// "secrets:" and no "inherit") is not there. A value that does not evaluate is
879// empty, and nothing of the error is logged: it may hold the secret.
880func (rs *runState) mapSecrets(ctx context.Context, j domain.CIJob, top map[string]string, inner secretUse) map[string]string {
881	chain := rs.chainOf(j)
882	vals := top
883	gh, ghErr := rs.githubValue("", rs.run.Attempt)
884	for i := len(chain) - 1; i >= 0; i-- {
885		cr := chain[i]
886		if cr.Call.Inherit {
887			continue
888		}
889		mapping, merr := rs.callMapping(cr)
890		sd := expr.NewDictionary()
891		for _, k := range sortedKeys(vals) {
892			sd.Add(k, expr.String(vals[k]))
893		}
894		env := template.Env{Contexts: map[string]expr.Value{"secrets": sd}}
895		if ghErr == nil {
896			env.Contexts["github"] = gh
897		}
898		var plain map[string]json.RawMessage
899		_ = json.Unmarshal([]byte(cr.Call.SecretCtx), &plain)
900		for k, raw := range plain {
901			if v, err := expr.FromJSON(raw); err == nil {
902				env.Contexts[k] = v
903			}
904		}
905		next := map[string]string{}
906		for name := range cr.Call.Secrets {
907			t, ok := mapping[name]
908			if merr != nil || !ok {
909				next[name] = ""
910				continue
911			}
912			v, err := template.EvaluateString(ctx, t, env)
913			if err != nil {
914				// Never the error: a format() error holds its arguments.
915				ctxLog(ctx).Warn().Str("secret", name).Msg("ci: a secret passed to a called workflow could not evaluate: it is empty")
916				v = ""
917			}
918			next[name] = v
919		}
920		vals = next
921	}
922	out := map[string]string{}
923	for n, v := range vals {
924		if inner.All || slices.Contains(inner.Names, n) {
925			out[n] = v
926		}
927	}
928	return out
929}
930
931// callHeld reports whether a call holds the concurrency groups it asked for: the
932// group of the calling job and the workflow-level group of the called workflow.
933// The jobs of a call are not planned before.
934func callHeld(call *domain.CIJob) bool {
935	if call.Call == nil || !call.Call.Admitted {
936		return false
937	}
938	if call.Concurrency.Group != "" && !call.Concurrency.FromRun && call.Concurrency.State != domain.CIConcurrencyHolding {
939		return false
940	}
941	return call.Call == nil || call.Call.Concurrency.Group == "" || call.Call.Concurrency.State == domain.CIConcurrencyHolding
942}
943
944// admitCalls lets the calls of a matrix start while the max-parallel of the
945// calling job's strategy allows it, in the order of the legs, as promote does
946// for the jobs that run. A call that is admitted asks for its groups and has its
947// jobs planned; one that is not waits, and starts nothing.
948func (rs *runState) admitCalls() bool {
949	running := map[string]int{}
950	for _, j := range rs.jobs {
951		if j.Call != nil && j.Call.Admitted && !j.Ended() {
952			running[j.Scope+"\x00"+j.Key]++
953		}
954	}
955	changed := false
956	for i := range rs.jobs {
957		j := &rs.jobs[i]
958		if j.Call == nil || j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
959			continue
960		}
961		k := j.Scope + "\x00" + j.Key
962		if j.MaxParallel > 0 && running[k] >= j.MaxParallel {
963			continue
964		}
965		call := *j.Call
966		call.Admitted = true
967		j.Call, changed = &call, true
968		running[k]++
969		rs.touch(j)
970	}
971	return changed
972}
973
974// checkJobBudget refuses an expansion that would give the run more job records
975// than a commit may have checks (MaxCIChecks: each record has one check, and the
976// merge rule cannot read more). extra is the number of records the expansion
977// adds (the record of the job being expanded becomes the first of its legs).
978// GitHub's text is the runner's (JobCountValidator.cs); its limit applies to the
979// jobs across all referenced files, and the matrix of one job is capped at 256
980// by ermite-actions (ErrTooManyConfigurations).
981func (rs *runState) checkJobBudget(extra int) error {
982	if len(rs.jobs)+extra > MaxCIChecks {
983		return callFailure{fmt.Sprintf("Workflows may not contain more than %d jobs across all referenced files", MaxCIChecks)}
984	}
985	return nil
986}
987
988// holdCalls asks for the turn of the calls that wait for a group: the group of
989// the calling job first, then the one of the called workflow, so that a call
990// waits in one group at a time. The call record is the entry, and holds both
991// until it ends (releaseEnded).
992func (rs *runState) holdCalls(ctx context.Context) (bool, error) {
993	changed := false
994	for i := range rs.jobs {
995		j := &rs.jobs[i]
996		if j.Call == nil || !j.Call.Expanded || !j.Call.Admitted || j.Ended() || j.Phase != domain.CIPhaseWaiting {
997			continue
998		}
999		entry := domain.CIConcurrencyEntry{RunID: j.RunID, Attempt: j.RunAttempt, JobID: j.ID}
1000		if g := j.Concurrency; g.Group != "" && !g.FromRun && g.State != domain.CIConcurrencyHolding {
1001			e := entry
1002			e.Cancel, e.Max = g.Cancel, g.Max
1003			st, err := rs.acquire(ctx, g.Group, e)
1004			if err != nil {
1005				return changed, err
1006			}
1007			if st != g.State {
1008				j.Concurrency.State, changed = st, true
1009				rs.touch(j)
1010			}
1011			if st != domain.CIConcurrencyHolding {
1012				continue
1013			}
1014		}
1015		if g := j.Call.Concurrency; g.Group != "" && g.State != domain.CIConcurrencyHolding {
1016			e := entry
1017			e.Cancel, e.Max = g.Cancel, g.Max
1018			st, err := rs.acquire(ctx, g.Group, e)
1019			if err != nil {
1020				return changed, err
1021			}
1022			if st != g.State {
1023				call := *j.Call
1024				call.Concurrency.State = st
1025				j.Call, changed = &call, true
1026				rs.touch(j)
1027			}
1028		}
1029	}
1030	return changed, nil
1031}
1032
1033// advanceCalls plans the jobs of called workflows whose needs ended and ends
1034// the call records whose jobs have. It returns at the first change, since a
1035// change reorders the records.
1036func (rs *runState) advanceCalls(ctx context.Context) (bool, error) {
1037	if rs.admitCalls() {
1038		return true, nil
1039	}
1040	if changed, err := rs.holdCalls(ctx); err != nil || changed {
1041		return changed, err
1042	}
1043	for i := range rs.jobs {
1044		rec := rs.jobs[i]
1045		if rec.Scope == "" || rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
1046			continue
1047		}
1048		call := rs.scopeOf(rec)
1049		if call == nil {
1050			rs.end(&rs.jobs[i], domain.CIFailure, "the call of this job is gone")
1051			return true, nil
1052		}
1053		if call.Ended() {
1054			rs.end(&rs.jobs[i], domain.CICancelled, "")
1055			return true, nil
1056		}
1057		if call.Call == nil || !call.Call.Admitted {
1058			continue // the max-parallel of the calling job holds the call back
1059		}
1060		if !callHeld(call) {
1061			// Waits for the group of its call: shown as pending, as the jobs
1062			// of a run behind a workflow-level group.
1063			if rs.run.Concurrency.Group == "" && rec.Concurrency.State != domain.CIConcurrencyPending {
1064				g := call.Concurrency
1065				if g.Group == "" || g.State == domain.CIConcurrencyHolding {
1066					g = call.Call.Concurrency
1067				}
1068				rs.jobs[i].Concurrency = domain.CIConcurrency{Group: g.Group, State: domain.CIConcurrencyPending, FromRun: true, Evaluated: true}
1069				rs.touch(&rs.jobs[i])
1070				return true, nil
1071			}
1072			continue
1073		}
1074		if rec.Concurrency.FromRun && rs.run.Concurrency.Group == "" {
1075			rs.jobs[i].Concurrency = domain.CIConcurrency{}
1076			rs.touch(&rs.jobs[i])
1077		}
1078		cw, err := rs.calleeOf(call)
1079		if err != nil {
1080			rs.end(&rs.jobs[i], domain.CIFailure, err.Error())
1081			return true, nil
1082		}
1083		cj := cw.Job(rec.CallID)
1084		if cj == nil {
1085			rs.end(&rs.jobs[i], domain.CIFailure, "the called workflow has no job '"+rec.CallID+"'")
1086			return true, nil
1087		}
1088		if len(rs.instancesIn(rec.Scope, rec.CallID)) != 1 {
1089			continue
1090		}
1091		needs, st, ready := rs.needsOf(cw, cj, rec.Scope)
1092		if !ready {
1093			continue
1094		}
1095		rs.planJob(ctx, cw, cj, i, needs, st)
1096		return true, nil
1097	}
1098	for i := range rs.jobs {
1099		call := &rs.jobs[i]
1100		if call.Call == nil || !call.Call.Expanded {
1101			continue
1102		}
1103		var scoped []int
1104		allEnded := true
1105		for k, j := range rs.jobs {
1106			if j.Scope == call.ID {
1107				scoped = append(scoped, k)
1108				allEnded = allEnded && j.Ended()
1109			}
1110		}
1111		if call.Ended() {
1112			changed := false
1113			for _, k := range scoped {
1114				s := &rs.jobs[k]
1115				switch {
1116				case s.Ended():
1117				case s.Active():
1118					rs.requestCancel(s, domain.CICancelUser, endedCallMessage(*call))
1119				default:
1120					rs.end(s, domain.CICancelled, "")
1121					changed = true
1122				}
1123			}
1124			if changed {
1125				return true, nil
1126			}
1127			continue
1128		}
1129		if allEnded {
1130			rs.finishCall(ctx, i, scoped)
1131			return true, nil
1132		}
1133	}
1134	return false, nil
1135}
1136
1137// finishCall ends a call record with the conclusion of its jobs and the outputs
1138// of the called workflow.
1139func (rs *runState) finishCall(ctx context.Context, idx int, scoped []int) {
1140	call := &rs.jobs[idx]
1141	var top []domain.CIJob
1142	for _, k := range scoped {
1143		top = append(top, rs.jobs[k])
1144	}
1145	conc, msg := domain.CISuccess, ""
1146	switch result(top) {
1147	case "failure":
1148		conc = domain.CIFailure
1149		msg = "A job of the called workflow failed"
1150		for _, j := range top {
1151			if j.Conclusion.IsFailure() && !j.ContinueOnError {
1152				msg = "The job '" + j.Name + "' of the called workflow failed"
1153				break
1154			}
1155		}
1156	case "cancelled":
1157		conc = domain.CICancelled
1158	case "skipped":
1159		conc = domain.CISkipped
1160	}
1161	jobs := expr.NewDictionary()
1162	for _, id := range distinctCallIDs(top) {
1163		var insts []domain.CIJob
1164		for _, j := range top {
1165			if j.CallID == id {
1166				insts = append(insts, j)
1167			}
1168		}
1169		outs := expr.NewDictionary()
1170		for _, in := range insts {
1171			for _, k := range sortedKeys(in.Outputs) {
1172				if v := in.Outputs[k]; v != "" || !hasKey(outs, k) {
1173					setKey(outs, k, expr.String(v))
1174				}
1175			}
1176		}
1177		d := expr.NewDictionary()
1178		d.Add("result", expr.String(result(insts)))
1179		d.Add("outputs", outs)
1180		jobs.Add(id, d)
1181	}
1182	outputs := map[string]string{}
1183	spec := readCalleeSpec([]byte(call.Call.YAML))
1184	if len(spec.outputs) > 0 {
1185		gh, err := rs.githubValue(call.Key, rs.run.Attempt)
1186		vars, verr := rs.c.varsContext(ctx, rs.run.Repo)
1187		if err == nil && verr == nil {
1188			env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": vars, "jobs": jobs}}
1189			// The inputs of the call itself.
1190			if v, err := expr.FromJSON([]byte(call.Call.InputsJSON)); err == nil {
1191				env.Contexts["inputs"] = v
1192			}
1193			for _, name := range sortedKeys(spec.outputs) {
1194				v, err := evalInterpolated(ctx, spec.outputs[name], env)
1195				if err != nil {
1196					ctxLog(ctx).Warn().Str("output", name).Msg("ci: an output of a called workflow could not evaluate")
1197					continue
1198				}
1199				outputs[name] = expr.CoerceString(v)
1200			}
1201		}
1202	}
1203	call.Outputs = outputs
1204	rs.end(call, conc, msg)
1205}
1206
1207func distinctCallIDs(js []domain.CIJob) []string {
1208	var out []string
1209	for _, j := range js {
1210		if !slices.Contains(out, j.CallID) {
1211			out = append(out, j.CallID)
1212		}
1213	}
1214	return out
1215}
1216
1217// parseCalled parses a called workflow, once per distinct file.
1218func (w *wfCache) parseCalled(path, yamlText string) (*workflow.Workflow, error) {
1219	sum := sha256.Sum256([]byte(path + "\x00" + yamlText))
1220	key := hex.EncodeToString(sum[:])
1221	w.mu.Lock()
1222	if wf, ok := w.called[key]; ok {
1223		w.mu.Unlock()
1224		return wf, nil
1225	}
1226	w.mu.Unlock()
1227	wf, errs := workflow.Parse(path, []byte(yamlText))
1228	if len(errs) > 0 {
1229		return nil, fmt.Errorf("the called workflow no longer parses: %s", errs[0].Error())
1230	}
1231	w.mu.Lock()
1232	defer w.mu.Unlock()
1233	if len(w.called) >= w.max {
1234		clear(w.called)
1235	}
1236	w.called[key] = wf
1237	return wf, nil
1238}
1239
1240// jobKeysOf are the keys of the "job" context the server knows. A job of a
1241// called workflow names the called workflow in them (workflow_ref,
1242// workflow_sha, workflow_repository, workflow_file_path); github.workflow_ref
1243// and github.workflow_sha stay the caller's (the github context is the
1244// caller's).
1245func (rs *runState) jobKeysOf(ctx context.Context, base domain.CIJob, combo int) ordered {
1246	keys := rs.c.jobKeys(ctx, rs.run, base.Index, combo)
1247	call := rs.scopeOf(base)
1248	if call == nil || call.Call == nil {
1249		return keys
1250	}
1251	c := call.Call
1252	full := c.Repository
1253	if !strings.Contains(full, "/") {
1254		full = rs.c.cfg.Owner + "/" + full
1255	}
1256	ref := c.Ref
1257	if ref == "" {
1258		ref = rs.run.Ref
1259	}
1260	return ordered{
1261		keys[0], {"workflow_ref", full + "/" + c.Path + "@" + ref}, {"workflow_sha", c.Sha},
1262		{"workflow_repository", full}, {"workflow_file_path", c.Path},
1263	}
1264}
1265
1266// endedCallMessage is what the jobs of a call that ended are told: the reason
1267// the call was cancelled for (a newer call of its concurrency group), else that
1268// the calling job ended.
1269func endedCallMessage(call domain.CIJob) string {
1270	if call.Concurrency.Preempted || call.Error != "" {
1271		return call.Error
1272	}
1273	return "Canceled because the calling job ended"
1274}
1275
1276// callResolutions copies the resolutions of the reusable workflows that the
1277// attempt of rs called to the attempt next.
1278func (c *CI) callResolutions(ctx context.Context, rs *runState, next int32) ([]domain.CIActionResolution, error) {
1279	var out []domain.CIActionResolution
1280	seen := map[string]bool{}
1281	for _, j := range rs.jobs {
1282		if j.Call == nil {
1283			continue
1284		}
1285		ref, text := parseCallRef(j.Call.Uses)
1286		if text != "" || ref.local {
1287			continue
1288		}
1289		key := actionRef{owner: ref.owner, repo: ref.repo, ref: ref.ref}.key()
1290		if seen[key] {
1291			continue
1292		}
1293		seen[key] = true
1294		res, err := c.cfg.Store.GetActionResolution(ctx, rs.run.ID, rs.attempt, key)
1295		if errors.Is(err, domain.ErrNotFound) {
1296			continue
1297		}
1298		if err != nil {
1299			return nil, err
1300		}
1301		res.RunAttempt = next
1302		out = append(out, res)
1303	}
1304	return out, nil
1305}

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