v0.35.0 e86dbd6

ci_dispatch.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"strings"
11
12	"bnema.dev/ermite/internal/domain"
13	"bnema.dev/ermite/internal/ports"
14)
15
16// jobTokenActor is the actor of a run dispatched with a job token, GitHub's name
17// for the account behind a GITHUB_TOKEN.
18const jobTokenActor = "github-actions[bot]"
19
20// DispatchWorkflow starts a run of a workflow file by hand (workflow_dispatch,
21// CI-3 D5), like `gh workflow run`.
22//
23// Who may: a principal with ci:write on the repository, or the holder of a job
24// token whose permissions include "actions: write" (GitHub's one exception to
25// "events made with GITHUB_TOKEN start no run"; a token of a run that is not
26// trusted never has it, TokenPermissions lowers it, so a job token that gets
27// here belongs to a trusted run). A token limited to branch prefixes may only
28// dispatch on a branch under them, and its run is not trusted (D1). Whoever
29// calls, the run is trusted only when the ref it runs on was moved to its commit
30// by a trusted principal (ci_tips.go).
31//
32// The workflow is read at ref (the default branch when empty) and must declare
33// workflow_dispatch; the inputs are checked against its declaration.
34func (c *CI) DispatchWorkflow(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.DispatchParams) (domain.CIRun, error) {
35	repo, actor, pusher, trusted, err := c.dispatchCaller(ctx, p, name)
36	if err != nil {
37		return domain.CIRun{}, err
38	}
39	wfPath, err := dispatchPath(params.WorkflowPath)
40	if err != nil {
41		return domain.CIRun{}, err
42	}
43	if off, err := c.workflowDisabled(ctx, name, wfPath); err != nil {
44		return domain.CIRun{}, err
45	} else if off {
46		return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
47	}
48	pin, err := c.dispatchRef(ctx, name, params.Ref)
49	if err != nil {
50		return domain.CIRun{}, err
51	}
52	ref, commit := pin.ref, pin.commit
53	if c.cfg.DispatchResolved != nil {
54		c.cfg.DispatchResolved()
55	}
56	if !p.AllowsRef(ref) {
57		return domain.CIRun{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, ref)
58	}
59	content, err := c.readWorkflowAt(ctx, name, commit, wfPath)
60	if err != nil {
61		return domain.CIRun{}, err
62	}
63	wf, perrs := parseWorkflow(wfPath, content)
64	if len(perrs) > 0 {
65		lines := make([]string, 0, len(perrs))
66		for _, e := range perrs {
67			lines = append(lines, e.Error())
68		}
69		return domain.CIRun{}, fmt.Errorf("%w: %s is not a valid workflow:\n%s", domain.ErrInvalid, wfPath, strings.Join(lines, "\n"))
70	}
71	ev := wf.On.Event(eventDispatch)
72	if ev == nil {
73		return domain.CIRun{}, fmt.Errorf("%w: %s does not have a %s trigger", domain.ErrPreconditionFailed, wfPath, eventDispatch)
74	}
75	values, err := resolveDispatchInputs(ev.Inputs, params.Inputs)
76	if err != nil {
77		return domain.CIRun{}, err
78	}
79
80	f := pushFacts{
81		update: domain.RefUpdate{Ref: ref, Old: domain.ZeroOID, New: commit.String()}, commit: commit,
82		custom: &customEvent{name: eventDispatch, title: workflowName(wf, wfPath), inputs: values},
83	}
84	if def, _, err := c.cfg.Repos.DefaultBranch(ctx, name); err == nil {
85		f.defaultBranch = def
86	}
87	// The run executes the code of the ref, which the caller did not necessarily
88	// write: it is trusted only if the caller is AND whoever moved the ref to this
89	// commit was (domain.CIRefTip), as a re-run of an agent's run by the owner
90	// does not unlock secrets. A ref with no record is not trusted. This holds for
91	// every caller, the owner and a job token alike.
92	trusted = trusted && c.refTrusted(ctx, name, pin)
93	t := ciTrigger{repo: name, actor: actor, pusher: pusher, trusted: trusted, at: c.now()}
94	if problem := c.matrixProblem(ctx, repo, t, f, wfPath, wf); problem.text != "" {
95		return domain.CIRun{}, fmt.Errorf("%w: %s", domain.ErrInvalid, problem.text)
96	}
97	run, err := c.launchRun(ctx, repo, t, f, wfPath, string(content), wf)
98	if errors.Is(err, domain.ErrWorkflowDisabled) {
99		// Disabled after the check above: the store refused the run.
100		return domain.CIRun{}, fmt.Errorf("%w: %s is disabled (`ermite workflow enable`)", domain.ErrPreconditionFailed, wfPath)
101	}
102	if err != nil {
103		return domain.CIRun{}, err
104	}
105	if run.ID == 0 {
106		return domain.CIRun{}, domain.ErrNotFound
107	}
108	ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", run.ID).Str("workflow", wfPath).Str("ref", ref).
109		Str("principal", p.Label()).Bool("trusted", trusted).Msg("ci workflow dispatched")
110	return run, nil
111}
112
113// dispatchCaller applies the access rule of a dispatch and returns who the run
114// is for.
115func (c *CI) dispatchCaller(ctx context.Context, p domain.Principal, name domain.RepoName) (repo domain.Repo, actor string, pusher domain.CIPusher, trusted bool, err error) {
116	if p.ViaJobToken() {
117		repo, err = lookupRepo(ctx, c.cfg.Meta, name)
118		if err != nil {
119			return repo, "", pusher, false, err
120		}
121		// The permission is judged on its own: a job of this repository with
122		// actions: write may dispatch even when its contents permission is none
123		// (a private repository is then unreadable to it). JobPermits is true only
124		// for the job's own token on its own repository, so any other caller of
125		// a repository it cannot read still gets not found.
126		switch {
127		case p.JobPermits("actions", domain.PermissionWrite, name):
128			return repo, jobTokenActor, domain.PusherOf(p), true, nil
129		case !canRead(p, repo):
130			return repo, "", pusher, false, domain.ErrNotFound
131		}
132		return repo, "", pusher, false, fmt.Errorf("%w: a job token needs the permission actions: write to dispatch a workflow", domain.ErrForbidden)
133	}
134	repo, err = c.writableRepo(ctx, p, name)
135	if err != nil {
136		return repo, "", pusher, false, err
137	}
138	return repo, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p), nil
139}
140
141// dispatchPath checks the workflow file of a dispatch: a file of
142// .github/workflows, named by its path or by its file name, in its canonical
143// spelling (the one the disabled state is keyed by).
144func dispatchPath(s string) (string, error) { return domain.ParseWorkflowPath(s) }
145
146// pinnedRef is what a dispatch resolved its ref to, once: the full name, the
147// commit the run executes and the object the ref points to (the commit for a
148// branch and a lightweight tag, the tag object for an annotated tag). The trust
149// of the run is judged on exactly these, never on the ref as it is read later
150// (ci_tips.go).
151type pinnedRef struct {
152	ref            string
153	commit, object domain.ObjectID
154}
155
156// dispatchRef resolves the ref of a dispatch: a branch or a tag, by short or
157// full name; the default branch when empty. A tag is resolved to its commit and
158// its own object together; a tag that moves between the two reads is refused,
159// so the commit and the object always belong to one state of the ref.
160func (c *CI) dispatchRef(ctx context.Context, name domain.RepoName, given string) (pinnedRef, error) {
161	given = strings.TrimSpace(given)
162	if given == "" {
163		def, exists, err := c.cfg.Repos.DefaultBranch(ctx, name)
164		if err != nil {
165			return pinnedRef{}, err
166		}
167		if !exists {
168			return pinnedRef{}, fmt.Errorf("%w: the default branch %q does not exist", domain.ErrNotFound, def)
169		}
170		given = "refs/heads/" + def
171	}
172	rev, err := domain.ParseRevision(given)
173	if err != nil {
174		return pinnedRef{}, err
175	}
176	res, err := c.cfg.Reader.ResolveRevision(ctx, name, rev)
177	if err != nil {
178		return pinnedRef{}, err
179	}
180	pin := pinnedRef{ref: res.Ref, commit: res.CommitID, object: res.CommitID}
181	switch {
182	case strings.HasPrefix(res.Ref, "refs/heads/"):
183		return pin, nil
184	case !strings.HasPrefix(res.Ref, "refs/tags/"):
185		return pinnedRef{}, fmt.Errorf("%w: %q is not a branch or a tag", domain.ErrInvalid, given)
186	}
187	refs, err := c.cfg.Reader.ListRefs(ctx, name)
188	if err != nil {
189		return pinnedRef{}, err
190	}
191	for _, r := range refs {
192		if r.Name != res.Ref {
193			continue
194		}
195		if r.CommitID != res.CommitID || r.ID.IsZero() {
196			break // moved between the two reads
197		}
198		pin.object = r.ID
199		return pin, nil
200	}
201	return pinnedRef{}, fmt.Errorf("%w: tag %q moved while it was resolved, try again", domain.ErrConflict, res.Ref)
202}
203
204// readWorkflowAt reads a workflow file at a commit, bounded; a missing file is
205// domain.ErrNotFound.
206func (c *CI) readWorkflowAt(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, wfPath string) ([]byte, error) {
207	tp, err := domain.ParseTreePath(wfPath)
208	if err != nil {
209		return nil, err
210	}
211	_, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp)
212	if err != nil {
213		if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotFile) {
214			return nil, fmt.Errorf("%w: workflow %s", domain.ErrNotFound, wfPath)
215		}
216		return nil, fmt.Errorf("read %s: %w", wfPath, err)
217	}
218	content, err := io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
219	_ = rc.Close()
220	if err != nil {
221		return nil, fmt.Errorf("read %s: %w", wfPath, err)
222	}
223	if len(content) > maxCIWorkflowBytes {
224		return nil, fmt.Errorf("%w: %s is larger than 1 MiB", domain.ErrInvalid, wfPath)
225	}
226	return content, nil
227}

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