detached 52acaf4

Merge pull request 'feat(ci): job token, owner git URLs, GitHub REST subset and action resolution' (#22) from feat/ci2-execution into main

commit
52acaf40349af2f8461dfdd741cb1e5a8978af7d
author
bnema · · 2026-10-13 11:49 UTC
merge of
b8e893f + 1dfc7dc · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
58 files · +6273 -114
M .hexcheck.yaml +3 -0
@@ -73,6 +73,9 @@ allow:
73 - rule: no-adapter-to-adapter-imports73 - rule: no-adapter-to-adapter-imports
74 path: internal/adapters/githttp/**74 path: internal/adapters/githttp/**
75 reason: rate-limits Git clients with internal/clientlimit75 reason: rate-limits Git clients with internal/clientlimit
76+ - rule: no-adapter-to-adapter-imports
77+ path: internal/adapters/githubapi/**
78+ reason: rate-limits the clients of the REST subset with internal/clientlimit
76 - rule: no-adapter-to-adapter-imports79 - rule: no-adapter-to-adapter-imports
77 path: internal/adapters/rpc/**80 path: internal/adapters/rpc/**
78 reason: rate-limits API clients with internal/clientlimit81 reason: rate-limits API clients with internal/clientlimit
M README.md +11 -1
@@ -93,6 +93,8 @@ git clone http://git.example.com/<repo>.git # public repositories need
93 git push http://u:<token>@git.example.com/<repo>.git main93 git push http://u:<token>@git.example.com/<repo>.git main
94 ```94 ```
95 95
96+The URL GitHub-shaped clients use, `http://git.example.com/<owner>/<repo>` (`<owner>` is `ERMITE_OWNER`, with or without `.git`), is served too; any other owner is a 404. The token goes as the password of Basic with any user (`x-access-token`), or as `Authorization: Bearer <token>` or `token <token>`.
97+
96 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).98 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
97 99
98 ## Branch-limited tokens100 ## Branch-limited tokens
@@ -116,7 +118,15 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a
116 118
117 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.119 Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
118 120
119-`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature the runner does not support yet (`uses:` steps, `container:`, `services:`, `environment:`, `concurrency`, `continue-on-error`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.121+Every job gets a **repository token** (`github.token`, `ermitegh_…`) when a runner takes it: it reads the repository of the run, even a private one, over Git and the REST subset below, writes nothing, and ends with the job (completion, cancel, lost runner) or five minutes after the job's deadline. It is not a stored token: `ermite token list` never shows it and no scope manages it.
122+
123+An action of `ERMITE_OWNER/<repo>` that exists on this server wins over the same name on github.com. It is resolved **on behalf of whoever pushed the run**: ermite records the pusher with the run (the admin token, the owner's session, or the stored token by its id) and, at every answer, asks whether that pusher can read the action's repository, with the same rule as everywhere else: a public repository, or `repo:read` on a repository of the token's list. The token is looked up again each time, so a token revoked or expired since reads nothing for the run. A repository the pusher cannot read answers like one that does not exist (`repository not found`), whatever the ref, so it does not reveal itself. A job can therefore use an action from another private repository only when its pusher could read it. The runner gets an **archive token** for each such action: it is bound to that repository and that commit and downloads that one archive (`/-/github/api/repos/<owner>/<repo>/tarball/<sha>`) and nothing else. Git over HTTP, the repository endpoint and the rest of the API see it as a caller who can read nothing, not even a public repository. It goes with its job, and with the repository it reads if that is deleted. At most eight archives are written at once for one client address (`429` beyond: a runner host runs several jobs, and all come from one address) and sixteen on the server (`503`); both answers carry `Retry-After: 5`. A client that stops reading is cut after two minutes without progress.
124+
125+The Actions that run here call a small part of GitHub's REST API, served at `github.api_url` (`/-/github/api`): `GET /repos/{owner}/{repo}`, `/repos/{owner}/{repo}/hash-algorithm` and `/repos/{owner}/{repo}/tarball[/{ref}]` (streamed with 200, not redirected; a `HEAD` answers the headers without building the archive). The owner in these paths and in the Git URLs is matched without regard to case, as on GitHub. Any other path is GitHub's `404 {"message":"Not Found",…}`, so an action that needs more fails as on a GitHub instance without that feature.
126+
127+Actions from github.com are resolved with `git ls-remote https://github.com/<owner>/<repo>` (no credentials, 20 s, 16 MiB of output, at most four at once, 60 s for a whole call). ermite hands git only the proxy settings of its own environment (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lower-case forms), so a server that reaches the Internet through a proxy sets them for `ermite serve`; nothing else of its environment, no credential, reaches git.
128+
129+`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature ermite does not support yet (reusable workflows, `environment:`, `concurrency`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
120 130
121 ## Web sign-in131 ## Web sign-in
122 132
M gen/ermite/storage/ci.pb.go +277 -32 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d5036215 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362
16- bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf616+ bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
A internal/adapters/gitcli/archive.go +93 -0
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bytes"
7+ "compress/gzip"
8+ "context"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "strings"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// archiveTimeout bounds one archive when ctx has no deadline: a large
21+// repository takes minutes, a stalled client must not hold a process forever.
22+const archiveTimeout = 15 * time.Minute
23+
24+var _ ports.GitArchive = (*Git)(nil)
25+
26+// Archive streams the tree of the commit as a gzip-compressed tar, every path
27+// below prefix ("<prefix>/..."), with `git archive`. The tar is compressed here,
28+// not by a gzip binary. A client that goes away ends ctx, which kills git.
29+// Nothing is written to w when the commit does not exist
30+// (domain.ErrNotFound).
31+//
32+//hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's
33+func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
34+ dir, err := g.repoDir(repo)
35+ if err != nil {
36+ return err
37+ }
38+ if _, ok := ctx.Deadline(); !ok {
39+ var cancel context.CancelFunc
40+ ctx, cancel = context.WithTimeout(ctx, archiveTimeout)
41+ defer cancel()
42+ }
43+ release, err := g.acquireExec(ctx)
44+ if err != nil {
45+ return fmt.Errorf("git archive: %w", err)
46+ }
47+ defer release()
48+
49+ cmd := exec.CommandContext(ctx, g.bin, readArgs(dir, "archive", "--format=tar", "--prefix="+prefix+"/", commit.String(), "--")...)
50+ cmd.Env = g.env
51+ cmd.Dir = g.dataDir
52+ cmd.WaitDelay = 5 * time.Second
53+ stderr := &tailBuffer{max: 4096}
54+ cmd.Stderr = stderr
55+ stdout, err := cmd.StdoutPipe()
56+ if err != nil {
57+ return fmt.Errorf("git archive: %w", err)
58+ }
59+ if err := cmd.Start(); err != nil {
60+ return fmt.Errorf("git archive: %w", err)
61+ }
62+ // The first byte decides whether git found the commit: wait for it before
63+ // writing to w, so that a missing commit can still be answered.
64+ first := make([]byte, 1)
65+ if n, _ := io.ReadFull(stdout, first); n == 0 {
66+ werr := cmd.Wait()
67+ switch {
68+ case ctx.Err() != nil:
69+ return ctx.Err()
70+ case werr != nil:
71+ return fmt.Errorf("git archive %s %s: %w: %s", repo, commit, domain.ErrNotFound, strings.TrimSpace(stderr.String()))
72+ }
73+ return errors.New("git archive: empty output")
74+ }
75+ zw := gzip.NewWriter(w)
76+ _, cerr := io.Copy(zw, io.MultiReader(bytes.NewReader(first), stdout))
77+ if cerr == nil {
78+ cerr = zw.Close()
79+ }
80+ if cerr != nil {
81+ _ = cmd.Process.Kill()
82+ }
83+ werr := cmd.Wait()
84+ switch {
85+ case ctx.Err() != nil:
86+ return ctx.Err()
87+ case cerr != nil:
88+ return fmt.Errorf("git archive %s: %w", repo, cerr)
89+ case werr != nil:
90+ return fmt.Errorf("git archive %s: %w: %s", repo, werr, strings.TrimSpace(stderr.String()))
91+ }
92+ return nil
93+}
A internal/adapters/gitcli/archive_test.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "io"
11+ "strings"
12+ "testing"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func TestArchiveWritesAGzipTarUnderThePrefix(t *testing.T) {
21+ g, data := newGit(t)
22+ ctx := context.Background()
23+ m := newMergeFixture(t, data)
24+ t.Cleanup(func() { _ = g.Close(ctx) })
25+
26+ var buf bytes.Buffer
27+ require.NoError(t, g.Archive(ctx, m.f.Name, m.d, "owner-proj-abcdef0", &buf))
28+ zr, err := gzip.NewReader(&buf)
29+ require.NoError(t, err)
30+ tr := tar.NewReader(zr)
31+ files := map[string]string{}
32+ for {
33+ h, err := tr.Next()
34+ if err == io.EOF {
35+ break
36+ }
37+ require.NoError(t, err)
38+ if h.Typeflag == tar.TypeXGlobalHeader {
39+ // git archive records the commit id here, as GitHub's tarballs do.
40+ assert.Equal(t, m.d.String(), h.PAXRecords["comment"])
41+ continue
42+ }
43+ require.True(t, strings.HasPrefix(h.Name, "owner-proj-abcdef0/"), h.Name)
44+ if h.Typeflag == tar.TypeReg {
45+ b, _ := io.ReadAll(tr)
46+ files[h.Name] = string(b)
47+ }
48+ }
49+ assert.Equal(t, "one\n2\nthree\n", files["owner-proj-abcdef0/a.txt"], "the tree of the commit asked, not of HEAD")
50+ assert.Equal(t, "see\n", files["owner-proj-abcdef0/c.txt"])
51+ assert.NotContains(t, files, "owner-proj-abcdef0/.git")
52+}
53+
54+func TestArchiveOfAMissingCommitWritesNothing(t *testing.T) {
55+ g, data := newGit(t)
56+ ctx := context.Background()
57+ m := newMergeFixture(t, data)
58+ t.Cleanup(func() { _ = g.Close(ctx) })
59+
60+ var buf bytes.Buffer
61+ missing, err := domain.ParseObjectID(strings.Repeat("1", 40))
62+ require.NoError(t, err)
63+ err = g.Archive(ctx, m.f.Name, missing, "p", &buf)
64+ assert.ErrorIs(t, err, domain.ErrNotFound)
65+ assert.Zero(t, buf.Len(), "nothing was written, so the caller can still answer 404")
66+
67+ err = g.Archive(ctx, name(t, "nope"), m.d, "p", &buf)
68+ assert.ErrorIs(t, err, domain.ErrNotFound)
69+}
70+
71+func TestArchiveStopsWithItsContext(t *testing.T) {
72+ g, data := newGit(t)
73+ m := newMergeFixture(t, data)
74+ t.Cleanup(func() { _ = g.Close(context.Background()) })
75+
76+ ctx, cancel := context.WithCancel(context.Background())
77+ cancel()
78+ err := g.Archive(ctx, m.f.Name, m.d, "p", io.Discard)
79+ assert.ErrorIs(t, err, context.Canceled)
80+}
M internal/adapters/githttp/handler.go +53 -10
@@ -38,6 +38,23 @@ var Patterns = []string{
38 "POST /{repo}/git-receive-pack",38 "POST /{repo}/git-receive-pack",
39 }39 }
40 40
41+// OwnerPatterns are the patterns of Patterns under /<owner>/, where owner is
42+// ERMITE_OWNER: the URL github.repository gives (actions/checkout clones
43+// <server_url>/<owner>/<repo>, with no .git suffix). The owner is a literal
44+// segment so that the patterns never overlap the /-/ routes, and other owners
45+// fall through to the web, which answers 404. The handler matches the owner
46+// without regard to case, as GitHub does, but the mux only the exact spelling
47+// of ERMITE_OWNER (lower case, like a repository name); register them only for
48+// a valid owner and set Config.Owner to the same.
49+func OwnerPatterns(owner string) []string {
50+ out := make([]string, len(Patterns))
51+ for i, p := range Patterns {
52+ method, path, _ := strings.Cut(p, " ")
53+ out[i] = method + " /" + owner + path
54+ }
55+ return out
56+}
57+
41 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes58 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
42 // is zero.59 // is zero.
43 const DefaultMaxBodyBytes int64 = 4 << 3060 const DefaultMaxBodyBytes int64 = 4 << 30
@@ -67,6 +84,9 @@ type Config struct {
67 Repos ports.RepoLookup84 Repos ports.RepoLookup
68 Pushes ports.PushService85 Pushes ports.PushService
69 Backend ports.GitBackend86 Backend ports.GitBackend
87+ // Owner is ERMITE_OWNER: /<owner>/<repo>/... is served like /<repo>/...
88+ // (see OwnerPatterns). Empty serves the second form nowhere.
89+ Owner string
70 // Hooks provides hook tokens for pushes. It is required.90 // Hooks provides hook tokens for pushes. It is required.
71 Hooks ports.HookTokens91 Hooks ports.HookTokens
72 // TrustedProxies are the networks allowed to set X-Forwarded-For.92 // TrustedProxies are the networks allowed to set X-Forwarded-For.
@@ -146,12 +166,36 @@ type route struct {
146 166
147 func (rt route) push() bool { return rt.service == serviceReceive }167 func (rt route) push() bool { return rt.service == serviceReceive }
148 168
169+// gitSuffixes are the ends of the paths the handler serves, after the
170+// repository.
171+var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive}
172+
149 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack173 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
150-// and /<repo>/git-receive-pack. info/refs without a service is the dumb174+// and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
151-// protocol, which ermite does not serve.175+// the forge. info/refs without a service is the dumb protocol, which ermite
152-func parseRoute(r *http.Request) (route, int) {176+// does not serve.
153- seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")177+func (h *Handler) parseRoute(r *http.Request) (route, int) {
154- if !ok || seg == "" {178+ p := strings.TrimPrefix(r.URL.Path, "/")
179+ var head, rest string
180+ for _, suffix := range gitSuffixes {
181+ if trimmed, ok := strings.CutSuffix(p, suffix); ok {
182+ head, rest = trimmed, suffix[1:]
183+ break
184+ }
185+ }
186+ if rest == "" {
187+ return route{}, http.StatusNotFound
188+ }
189+ seg := head
190+ if owner, repo, ok := strings.Cut(head, "/"); ok {
191+ // The owner is matched as GitHub does, without regard to case, like the
192+ // REST subset and the references of actions (domain.OwnerMatches).
193+ if !domain.OwnerMatches(h.cfg.Owner, owner) {
194+ return route{}, http.StatusNotFound
195+ }
196+ seg = repo
197+ }
198+ if seg == "" || strings.Contains(seg, "/") {
155 return route{}, http.StatusNotFound199 return route{}, http.StatusNotFound
156 }200 }
157 rt := route{repo: seg, suffix: rest}201 rt := route{repo: seg, suffix: rest}
@@ -175,8 +219,6 @@ func parseRoute(r *http.Request) (route, int) {
175 return route{}, http.StatusMethodNotAllowed219 return route{}, http.StatusMethodNotAllowed
176 }220 }
177 rt.service = rest221 rt.service = rest
178- default:
179- return route{}, http.StatusNotFound
180 }222 }
181 return rt, 0223 return rt, 0
182 }224 }
@@ -198,7 +240,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
198 Msg("git request")240 Msg("git request")
199 }()241 }()
200 242
201- rt, status := parseRoute(r)243+ rt, status := h.parseRoute(r)
202 if status != 0 {244 if status != 0 {
203 http.Error(w, http.StatusText(status), status)245 http.Error(w, http.StatusText(status), status)
204 return246 return
@@ -265,7 +307,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
265 }307 }
266 308
267 // credentials extracts the token from an Authorization header: the password of309 // credentials extracts the token from an Authorization header: the password of
268-// Basic (the username is ignored) or a Bearer token. present is true when the310+// Basic (the username is ignored, actions/checkout sends x-access-token), or
311+// the value of "Bearer" or "token". present is true when the
269 // header exists, even if it carries nothing usable, which then fails312 // header exists, even if it carries nothing usable, which then fails
270 // authentication.313 // authentication.
271 func credentials(r *http.Request) (secret string, present bool) {314 func credentials(r *http.Request) (secret string, present bool) {
@@ -276,7 +319,7 @@ func credentials(r *http.Request) (secret string, present bool) {
276 if _, pass, ok := r.BasicAuth(); ok {319 if _, pass, ok := r.BasicAuth(); ok {
277 return pass, true320 return pass, true
278 }321 }
279- if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {322+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
280 return strings.TrimSpace(tok), true323 return strings.TrimSpace(tok), true
281 }324 }
282 return "", true325 return "", true
M internal/adapters/githttp/handler_test.go +53 -2
@@ -11,6 +11,7 @@ import (
11 "net/netip"11 "net/netip"
12 "os"12 "os"
13 "path/filepath"13 "path/filepath"
14+ "slices"
14 "strings"15 "strings"
15 "sync"16 "sync"
16 "testing"17 "testing"
@@ -172,7 +173,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
172 repos := mocks.NewRepoLookup(t)173 repos := mocks.NewRepoLookup(t)
173 r.stubUseCases(repos)174 r.stubUseCases(repos)
174 cfg := githttp.Config{175 cfg := githttp.Config{
175- Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,176+ Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, Owner: "acme",
176 }177 }
177 script := cgiScript(t)178 script := cgiScript(t)
178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(179 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188 }189 }
189 r.h = githttp.New(cfg)190 r.h = githttp.New(cfg)
190 mux := http.NewServeMux()191 mux := http.NewServeMux()
191- for _, p := range githttp.Patterns {192+ for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) {
192 mux.Handle(p, r.h)193 mux.Handle(p, r.h)
193 }194 }
194 r.srv = httptest.NewServer(mux)195 r.srv = httptest.NewServer(mux)
@@ -506,6 +507,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
506 assert.Equal(t, 200, code)507 assert.Equal(t, 200, code)
507 }508 }
508 509
510+// The clone URL of GitHub-shaped clients, /<owner>/<repo>, with or without .git:
511+// served for the owner of the forge, 404 for anyone else and for what is not a
512+// Git path.
513+func TestHandlerServesTheOwnerForm(t *testing.T) {
514+ t.Parallel()
515+ tests := []struct {
516+ name, method, path, pass string
517+ want int
518+ }{
519+ {"owner, no suffix", "GET", "/acme/pub/info/refs?service=git-upload-pack", "", 200},
520+ {"owner, .git", "GET", "/acme/pub.git/info/refs?service=git-upload-pack", "", 200},
521+ {"owner, private, reader", "GET", "/acme/priv/info/refs?service=git-upload-pack", "reader-secret", 200},
522+ {"owner, private, anonymous", "GET", "/acme/priv/info/refs?service=git-upload-pack", "", 401},
523+ {"owner, push, root", "GET", "/acme/pub/info/refs?service=git-receive-pack", "root-secret", 200},
524+ {"upload-pack", "POST", "/acme/pub/git-upload-pack", "", 200},
525+ {"other owner", "GET", "/other/pub/info/refs?service=git-upload-pack", "root-secret", 404},
526+ {"owner case does not matter", "GET", "/ACME/pub/info/refs?service=git-upload-pack", "root-secret", 200},
527+ {"too deep", "GET", "/acme/pub/x/info/refs?service=git-upload-pack", "root-secret", 404},
528+ {"no repository", "GET", "/acme/info/refs?service=git-upload-pack", "root-secret", 404},
529+ {"empty repository", "GET", "/acme//info/refs?service=git-upload-pack", "root-secret", 404},
530+ {"invalid repository", "GET", "/acme/Bad/info/refs?service=git-upload-pack", "root-secret", 404},
531+ {"owner, wrong method", "POST", "/acme/pub/info/refs?service=git-upload-pack", "", 405},
532+ {"owner, not a git path", "GET", "/acme/pub/HEAD", "", 404},
533+ }
534+ for _, tt := range tests {
535+ t.Run(tt.name, func(t *testing.T) {
536+ t.Parallel()
537+ r := newRig(t, nil)
538+ user := ""
539+ if tt.pass != "" {
540+ user = "x-access-token"
541+ }
542+ code, _, _ := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
543+ assert.Equal(t, tt.want, code)
544+ })
545+ }
546+}
547+
548+// Without an owner configured only the plain form is served.
549+func TestHandlerWithoutOwnerServesThePlainFormOnly(t *testing.T) {
550+ t.Parallel()
551+ r := newRig(t, func(c *githttp.Config) { c.Owner = "" })
552+ code, _, _ := r.do(t, "GET", "/acme/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
553+ assert.Equal(t, 404, code)
554+ code, _, _ = r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
555+ assert.Equal(t, 200, code)
556+}
557+
509 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {558 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510 t.Parallel()559 t.Parallel()
511 r := newRig(t, nil)560 r := newRig(t, nil)
@@ -514,6 +563,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
514 assert.Equal(t, 200, code)563 assert.Equal(t, 200, code)
515 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})564 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
516 assert.Equal(t, 200, code, "the scheme is case-insensitive")565 assert.Equal(t, 200, code, "the scheme is case-insensitive")
566+ code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "token reader-secret"})
567+ assert.Equal(t, 200, code, "GitHub's own scheme")
517 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {568 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
518 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})569 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
519 assert.Equal(t, 401, code, h)570 assert.Equal(t, 401, code, h)
A internal/adapters/githubapi/handler.go +345 -0
@@ -0,0 +1,345 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package githubapi serves the subset of GitHub's REST API that the actions
4+// running on ermite call under github.api_url (/-/github/api, ADR 0025 CI-2):
5+//
6+// GET /repos/{owner}/{repo}
7+// GET /repos/{owner}/{repo}/hash-algorithm
8+// GET /repos/{owner}/{repo}/tarball[/{ref}]
9+//
10+// Everything else under the prefix is GitHub's 404 JSON, so that an action
11+// calling an endpoint of a feature ermite lacks fails as it does on a GitHub
12+// instance without it, never silently succeeds. The auth is the forge's: a
13+// token (stored, or the repository token of a job) as the password of Basic with
14+// any user, or as "Bearer" or "token" credentials; no credentials is anonymous,
15+// which reads public repositories.
16+package githubapi
17+
18+import (
19+ "cmp"
20+ "context"
21+ "errors"
22+ "io"
23+ "net/http"
24+ "net/netip"
25+ "strconv"
26+ "strings"
27+ "sync"
28+ "time"
29+
30+ "github.com/bnema/zerowrap"
31+
32+ "bnema.dev/ermite/internal/clientlimit"
33+ "bnema.dev/ermite/internal/domain"
34+ "bnema.dev/ermite/internal/ports"
35+)
36+
37+// Prefix is where the API is mounted.
38+const Prefix = "/-/github/api"
39+
40+// Patterns are the ServeMux patterns the handler serves: its whole prefix.
41+var Patterns = []string{Prefix + "/"}
42+
43+// notFoundBody is GitHub's body for an unknown endpoint or repository.
44+const notFoundBody = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
45+
46+// retryAfter is the Retry-After, in seconds, of an answer that asks the client to
47+// come back (429 and 503).
48+const retryAfter = "5"
49+
50+// Bounds on the archives written at once.
51+const (
52+ // DefaultMaxArchives is the archives written at once on the server.
53+ DefaultMaxArchives = 16
54+ // DefaultMaxArchivesPerClient is the archives written at once for one client
55+ // (see clientlimit.Key). A runner host runs several jobs at once, each
56+ // downloading its actions, and all come from one address; eight is half of
57+ // the server's slots, so one host cannot take them all. Beyond it the answer
58+ // is 429 with Retry-After: the runner waits and tries again.
59+ DefaultMaxArchivesPerClient = 8
60+ // DefaultWriteIdleTimeout is how long a write of the archive may wait for the
61+ // client. The deadline moves forward with every chunk: an archive is bounded
62+ // by its size, not by its duration, but a client that stops reading is cut.
63+ DefaultWriteIdleTimeout = 2 * time.Minute
64+)
65+
66+// Config configures a Handler.
67+type Config struct {
68+ Auth ports.AuthService
69+ API ports.GitHubAPIService
70+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
71+ TrustedProxies []netip.Prefix
72+ // Limiter counts failed authentications; nil builds a default one.
73+ Limiter ports.FailureLimiter
74+ // MaxArchives bounds the archives written at once (each runs a git
75+ // process); zero means DefaultMaxArchives. MaxArchivesPerClient bounds them
76+ // per client; zero means DefaultMaxArchivesPerClient. WriteIdleTimeout is
77+ // the idle write deadline; zero means DefaultWriteIdleTimeout.
78+ MaxArchives int
79+ MaxArchivesPerClient int
80+ WriteIdleTimeout time.Duration
81+}
82+
83+// Handler serves the REST subset.
84+type Handler struct {
85+ cfg Config
86+ limiter ports.FailureLimiter
87+ archives chan struct{}
88+ perCli int
89+ idle time.Duration
90+
91+ mu sync.Mutex // guards clients
92+ clients map[string]int // archives being written, per clientlimit.Key
93+}
94+
95+// New builds a Handler.
96+func New(cfg Config) *Handler {
97+ h := &Handler{cfg: cfg, limiter: cfg.Limiter}
98+ if h.limiter == nil {
99+ h.limiter = clientlimit.NewLimiter(nil)
100+ }
101+ n := cfg.MaxArchives
102+ if n <= 0 {
103+ n = DefaultMaxArchives
104+ }
105+ h.archives = make(chan struct{}, n)
106+ h.perCli = cmp.Or(cfg.MaxArchivesPerClient, DefaultMaxArchivesPerClient)
107+ h.idle = cmp.Or(cfg.WriteIdleTimeout, DefaultWriteIdleTimeout)
108+ h.clients = make(map[string]int)
109+ return h
110+}
111+
112+// ServeHTTP implements http.Handler.
113+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114+ w.Header().Set("Cache-Control", "no-store")
115+ w.Header().Set("X-Content-Type-Options", "nosniff")
116+ rest := strings.TrimPrefix(r.URL.Path, Prefix)
117+ kind, owner, repo, ref, ok := route(rest)
118+ if !ok {
119+ notFound(w)
120+ return
121+ }
122+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
123+ w.Header().Set("Allow", "GET, HEAD")
124+ jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
125+ return
126+ }
127+
128+ ctx := r.Context()
129+ addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
130+ if blocked, _ := h.limiter.Blocked(ctx, addr); blocked {
131+ jsonError(w, http.StatusTooManyRequests, "API rate limit exceeded")
132+ return
133+ }
134+ p, err := h.authenticate(r, addr)
135+ if err != nil {
136+ if errors.Is(err, domain.ErrUnauthenticated) {
137+ jsonError(w, http.StatusUnauthorized, "Bad credentials")
138+ return
139+ }
140+ log := zerowrap.FromCtx(ctx)
141+ log.Error().Err(err).Msg("authenticate github api request")
142+ jsonError(w, http.StatusInternalServerError, "Server Error")
143+ return
144+ }
145+
146+ switch kind {
147+ case "repo":
148+ body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
149+ if err != nil {
150+ h.fail(ctx, w, err)
151+ return
152+ }
153+ writeJSON(w, r, body)
154+ case "hash":
155+ alg, err := h.cfg.API.HashAlgorithm(ctx, p, owner, repo)
156+ if err != nil {
157+ h.fail(ctx, w, err)
158+ return
159+ }
160+ writeJSON(w, r, []byte(`{"hash_algorithm":`+strconv.Quote(alg)+`}`))
161+ case "tarball":
162+ h.tarball(w, r, p, owner, repo, ref, clientlimit.Key(addr))
163+ }
164+}
165+
166+// route recognises the three endpoints. ref is "" for /tarball.
167+func route(rest string) (kind, owner, repo, ref string, ok bool) {
168+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
169+ if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
170+ return "", "", "", "", false
171+ }
172+ owner, repo = parts[1], parts[2]
173+ switch {
174+ case len(parts) == 3:
175+ return "repo", owner, repo, "", true
176+ case len(parts) == 4 && parts[3] == "hash-algorithm":
177+ return "hash", owner, repo, "", true
178+ case len(parts) >= 4 && parts[3] == "tarball":
179+ // The ref may hold slashes (feature/x): everything after tarball/.
180+ return "tarball", owner, repo, strings.Join(parts[4:], "/"), true
181+ }
182+ return "", "", "", "", false
183+}
184+
185+// tarball answers an archive request. A HEAD resolves the ref and sends the
186+// headers of the archive, and runs no git. A GET streams the archive; it is not
187+// a 302 to a codeload: ermite serves the bytes itself, which the runner follows
188+// without a second credential.
189+func (h *Handler) tarball(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo, ref, client string) {
190+ ctx := r.Context()
191+ if r.Method == http.MethodHead {
192+ name, err := h.cfg.API.TarballName(ctx, p, owner, repo, ref)
193+ if err != nil {
194+ h.fail(ctx, w, err)
195+ return
196+ }
197+ setArchiveHeaders(w, name)
198+ w.WriteHeader(http.StatusOK)
199+ return
200+ }
201+ release, ok := h.acquire(w, client)
202+ if !ok {
203+ return
204+ }
205+ defer release()
206+ rc := http.NewResponseController(w)
207+ started := false
208+ err := h.cfg.API.Tarball(ctx, p, owner, repo, ref, func(name string) io.Writer {
209+ started = true
210+ setArchiveHeaders(w, name)
211+ return &idleWriter{w: w, rc: rc, idle: h.idle}
212+ })
213+ switch {
214+ case err == nil:
215+ case started:
216+ // The status is out: the client sees a truncated archive. Log it.
217+ log := zerowrap.FromCtx(ctx)
218+ log.Warn().Err(err).Msg("github api archive cut short")
219+ default:
220+ h.fail(ctx, w, err)
221+ }
222+}
223+
224+// acquire takes an archive slot for the client, or answers 429 (the client
225+// already has its share) or 503 (the server writes all it can) and reports
226+// false. It never waits: an archive can take minutes.
227+func (h *Handler) acquire(w http.ResponseWriter, client string) (release func(), ok bool) {
228+ h.mu.Lock()
229+ if h.clients[client] >= h.perCli {
230+ h.mu.Unlock()
231+ w.Header().Set("Retry-After", retryAfter)
232+ jsonError(w, http.StatusTooManyRequests, "Too many archives in progress")
233+ return nil, false
234+ }
235+ h.clients[client]++
236+ h.mu.Unlock()
237+ releaseClient := func() {
238+ h.mu.Lock()
239+ if h.clients[client]--; h.clients[client] <= 0 {
240+ delete(h.clients, client)
241+ }
242+ h.mu.Unlock()
243+ }
244+ select {
245+ case h.archives <- struct{}{}:
246+ return func() { <-h.archives; releaseClient() }, true
247+ default:
248+ releaseClient()
249+ w.Header().Set("Retry-After", retryAfter)
250+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
251+ return nil, false
252+ }
253+}
254+
255+// idleWriter moves the write deadline of the connection forward before each
256+// chunk, so that only a client that stops reading is cut. Where the response
257+// writer cannot set a deadline, the writes are not bounded here.
258+type idleWriter struct {
259+ w io.Writer
260+ rc *http.ResponseController
261+ idle time.Duration
262+}
263+
264+func (i *idleWriter) Write(b []byte) (int, error) {
265+ _ = i.rc.SetWriteDeadline(time.Now().Add(i.idle))
266+ return i.w.Write(b)
267+}
268+
269+func setArchiveHeaders(w http.ResponseWriter, name string) {
270+ w.Header().Set("Content-Type", "application/x-gzip")
271+ w.Header().Set("Content-Disposition", "attachment; filename="+name)
272+}
273+
274+// authenticate reads the credentials of the request. None is the anonymous
275+// principal; credentials that are not valid count as a failure of the client
276+// address.
277+func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
278+ secret, present := credentials(r)
279+ if !present {
280+ return domain.Anonymous(), nil
281+ }
282+ p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
283+ if err != nil {
284+ if errors.Is(err, domain.ErrUnauthenticated) {
285+ h.limiter.Fail(r.Context(), addr)
286+ }
287+ return domain.Anonymous(), err
288+ }
289+ return p, nil
290+}
291+
292+// credentials extracts the token of an Authorization header: the password of
293+// Basic (the user is ignored, checkout sends x-access-token), or the value of
294+// "Bearer" or "token".
295+func credentials(r *http.Request) (secret string, present bool) {
296+ auth := r.Header.Get("Authorization")
297+ if auth == "" {
298+ return "", false
299+ }
300+ if _, pass, ok := r.BasicAuth(); ok {
301+ return pass, true
302+ }
303+ if scheme, tok, ok := strings.Cut(auth, " "); ok && (strings.EqualFold(scheme, "Bearer") || strings.EqualFold(scheme, "token")) {
304+ return strings.TrimSpace(tok), true
305+ }
306+ return "", true
307+}
308+
309+// fail answers an error of a use case. A repository the caller cannot read is
310+// as not found as one that does not exist.
311+func (h *Handler) fail(ctx context.Context, w http.ResponseWriter, err error) {
312+ switch {
313+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
314+ notFound(w)
315+ case errors.Is(err, domain.ErrResourceExhausted):
316+ w.Header().Set("Retry-After", retryAfter)
317+ jsonError(w, http.StatusServiceUnavailable, "Server busy")
318+ case errors.Is(err, context.Canceled):
319+ default:
320+ log := zerowrap.FromCtx(ctx)
321+ log.Error().Err(err).Msg("github api request failed")
322+ jsonError(w, http.StatusInternalServerError, "Server Error")
323+ }
324+}
325+
326+func notFound(w http.ResponseWriter) {
327+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
328+ w.WriteHeader(http.StatusNotFound)
329+ _, _ = io.WriteString(w, notFoundBody)
330+}
331+
332+func jsonError(w http.ResponseWriter, status int, msg string) {
333+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
334+ w.WriteHeader(status)
335+ _, _ = io.WriteString(w, `{"message":`+strconv.Quote(msg)+`,"documentation_url":"https://docs.github.com/rest"}`)
336+}
337+
338+func writeJSON(w http.ResponseWriter, r *http.Request, body []byte) {
339+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
340+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
341+ w.WriteHeader(http.StatusOK)
342+ if r.Method != http.MethodHead {
343+ _, _ = w.Write(body)
344+ }
345+}
A internal/adapters/githubapi/handler_test.go +370 -0
@@ -0,0 +1,370 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "net"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+)
24+
25+type rig struct {
26+ h *Handler
27+ srv *httptest.Server
28+ api *mocks.GitHubAPIService
29+ auth *mocks.AuthService
30+}
31+
32+func newRig(t *testing.T, mut func(*Config)) *rig {
33+ t.Helper()
34+ r := &rig{api: mocks.NewGitHubAPIService(t), auth: mocks.NewAuthService(t)}
35+ cfg := Config{Auth: r.auth, API: r.api}
36+ if mut != nil {
37+ mut(&cfg)
38+ }
39+ r.h = New(cfg)
40+ mux := http.NewServeMux()
41+ mux.Handle(Prefix+"/", r.h)
42+ r.srv = httptest.NewServer(mux)
43+ t.Cleanup(r.srv.Close)
44+ return r
45+}
46+
47+func (r *rig) do(t *testing.T, method, path, authorization string) (*http.Response, string) {
48+ t.Helper()
49+ req, err := http.NewRequest(method, r.srv.URL+Prefix+path, nil)
50+ require.NoError(t, err)
51+ if authorization != "" {
52+ req.Header.Set("Authorization", authorization)
53+ }
54+ resp, err := http.DefaultClient.Do(req)
55+ require.NoError(t, err)
56+ defer func() { _ = resp.Body.Close() }()
57+ b, err := io.ReadAll(resp.Body)
58+ require.NoError(t, err)
59+ return resp, string(b)
60+}
61+
62+const tarballPath = "/repos/acme/proj/tarball/main"
63+
64+// blockingArchive makes Tarball write one chunk and then wait for release, so
65+// that the archive stays in progress while the test looks at the others.
66+type blockingArchive struct {
67+ started chan struct{}
68+ release chan struct{}
69+}
70+
71+func newBlockingArchive() *blockingArchive {
72+ return &blockingArchive{started: make(chan struct{}, 32), release: make(chan struct{})}
73+}
74+
75+func (b *blockingArchive) run(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
76+ w := begin("acme-proj-abcdef0.tar.gz")
77+ _, _ = w.Write([]byte("chunk"))
78+ b.started <- struct{}{}
79+ select {
80+ case <-b.release:
81+ case <-ctx.Done():
82+ }
83+ return nil
84+}
85+
86+// A HEAD resolves the ref and answers the headers of the archive: git archive is
87+// not run (the mock fails on a call to Tarball), no slot is taken, and an error
88+// is answered as for a GET.
89+func TestHeadOfTheTarballRunsNoArchive(t *testing.T) {
90+ t.Parallel()
91+ r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 })
92+ r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "main").Return("acme-proj-abcdef0.tar.gz", nil)
93+ r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "nope").Return("", domain.ErrNotFound)
94+ r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "").Return("", errors.New("disk"))
95+
96+ resp, body := r.do(t, http.MethodHead, tarballPath, "")
97+ assert.Equal(t, http.StatusOK, resp.StatusCode)
98+ assert.Empty(t, body)
99+ assert.Equal(t, "attachment; filename=acme-proj-abcdef0.tar.gz", resp.Header.Get("Content-Disposition"))
100+ assert.Equal(t, "application/x-gzip", resp.Header.Get("Content-Type"))
101+
102+ resp, body = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/nope", "")
103+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
104+ assert.Empty(t, body, "a HEAD has no body, the status says it")
105+ resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball", "")
106+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
107+
108+ // The one slot is free for a GET after any number of HEADs.
109+ for range 5 {
110+ resp, _ := r.do(t, http.MethodHead, tarballPath, "")
111+ assert.Equal(t, http.StatusOK, resp.StatusCode)
112+ }
113+}
114+
115+// The per-client cap answers 429 with Retry-After, the global cap 503 with
116+// Retry-After, and a finished archive gives its slot back.
117+func TestArchiveSlots(t *testing.T) {
118+ t.Parallel()
119+ t.Run("per client", func(t *testing.T) {
120+ t.Parallel()
121+ arch := newBlockingArchive()
122+ r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 8, 2 })
123+ r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run)
124+
125+ var wg sync.WaitGroup
126+ for range 2 {
127+ wg.Add(1)
128+ go func() { defer wg.Done(); r.do(t, http.MethodGet, tarballPath, "") }()
129+ }
130+ for range 2 {
131+ <-arch.started
132+ }
133+ resp, body := r.do(t, http.MethodGet, tarballPath, "")
134+ assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
135+ assert.Equal(t, "5", resp.Header.Get("Retry-After"))
136+ assert.Contains(t, body, "Too many archives")
137+ assert.Contains(t, resp.Header.Get("Content-Type"), "application/json")
138+
139+ // Other endpoints and HEAD are not archives.
140+ r.api.EXPECT().HashAlgorithm(mock.Anything, mock.Anything, "acme", "proj").Return("sha1", nil)
141+ resp, _ = r.do(t, http.MethodGet, "/repos/acme/proj/hash-algorithm", "")
142+ assert.Equal(t, http.StatusOK, resp.StatusCode)
143+
144+ close(arch.release)
145+ wg.Wait()
146+ // The slots are given back: two more go through.
147+ arch2 := newBlockingArchive()
148+ close(arch2.release)
149+ r.api.ExpectedCalls = nil
150+ r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run)
151+ for range 4 {
152+ resp, _ := r.do(t, http.MethodGet, tarballPath, "")
153+ assert.Equal(t, http.StatusOK, resp.StatusCode)
154+ }
155+ })
156+
157+ t.Run("global", func(t *testing.T) {
158+ t.Parallel()
159+ arch := newBlockingArchive()
160+ r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 8 })
161+ r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run)
162+
163+ done := make(chan struct{})
164+ go func() { defer close(done); r.do(t, http.MethodGet, tarballPath, "") }()
165+ <-arch.started
166+ resp, body := r.do(t, http.MethodGet, tarballPath, "")
167+ assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
168+ assert.Equal(t, "5", resp.Header.Get("Retry-After"))
169+ assert.Contains(t, body, "Server busy")
170+ close(arch.release)
171+ <-done
172+
173+ // The client's own count was given back when the global slot refused it:
174+ // with a cap of one per client, the next one still gets a slot.
175+ r2 := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 })
176+ arch2 := newBlockingArchive()
177+ r2.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run)
178+ done2 := make(chan struct{})
179+ go func() { defer close(done2); r2.do(t, http.MethodGet, tarballPath, "") }()
180+ <-arch2.started
181+ resp, _ = r2.do(t, http.MethodGet, tarballPath, "")
182+ assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode, "the client cap answers first")
183+ close(arch2.release)
184+ <-done2
185+ close(arch.started)
186+ })
187+
188+ t.Run("a server busy answer does not leak the client slot", func(t *testing.T) {
189+ t.Parallel()
190+ h := New(Config{MaxArchives: 1, MaxArchivesPerClient: 3, Auth: mocks.NewAuthService(t), API: mocks.NewGitHubAPIService(t)})
191+ h.archives <- struct{}{} // the server is full
192+ for range 10 {
193+ rec := httptest.NewRecorder()
194+ _, ok := h.acquire(rec, "client")
195+ require.False(t, ok)
196+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
197+ assert.Equal(t, "5", rec.Header().Get("Retry-After"))
198+ }
199+ assert.Empty(t, h.clients, "no client count was left behind")
200+ <-h.archives
201+ release, ok := h.acquire(httptest.NewRecorder(), "client")
202+ require.True(t, ok)
203+ release()
204+ assert.Empty(t, h.clients)
205+ assert.Empty(t, h.archives)
206+ })
207+}
208+
209+// fakeRW is a response writer that can set a write deadline, like the one of
210+// net/http, and records the deadlines and the writes.
211+type fakeRW struct {
212+ header http.Header
213+ body strings.Builder
214+ deadlines []time.Time
215+ failWith error
216+}
217+
218+func (f *fakeRW) Header() http.Header { return f.header }
219+func (f *fakeRW) WriteHeader(int) {}
220+func (f *fakeRW) Write(b []byte) (int, error) {
221+ if f.failWith != nil {
222+ return 0, f.failWith
223+ }
224+ return f.body.Write(b)
225+}
226+func (f *fakeRW) SetWriteDeadline(t time.Time) error {
227+ f.deadlines = append(f.deadlines, t)
228+ return nil
229+}
230+
231+// The deadline of the connection moves forward before every chunk, by the idle
232+// timeout, so that only a client that stops reading is cut.
233+func TestIdleWriterMovesTheDeadlineForEveryChunk(t *testing.T) {
234+ t.Parallel()
235+ rw := &fakeRW{header: http.Header{}}
236+ w := &idleWriter{w: rw, rc: http.NewResponseController(rw), idle: 90 * time.Second}
237+
238+ before := time.Now()
239+ for i := range 3 {
240+ n, err := w.Write([]byte("chunk"))
241+ require.NoError(t, err, i)
242+ assert.Equal(t, 5, n)
243+ time.Sleep(5 * time.Millisecond)
244+ }
245+ assert.Equal(t, "chunkchunkchunk", rw.body.String())
246+ require.Len(t, rw.deadlines, 3, "one deadline per chunk")
247+ for i, d := range rw.deadlines {
248+ assert.WithinDuration(t, before.Add(90*time.Second), d, 2*time.Second, i)
249+ if i > 0 {
250+ assert.True(t, d.After(rw.deadlines[i-1]), "it moves forward: %d", i)
251+ }
252+ }
253+
254+ // A write error is returned as it is.
255+ rw.failWith = errors.New("broken pipe")
256+ _, err := w.Write([]byte("x"))
257+ assert.EqualError(t, err, "broken pipe")
258+
259+ // A writer that cannot set a deadline still writes: the bound is then not
260+ // applied here, and nothing fails.
261+ plain := httptest.NewRecorder()
262+ pw := &idleWriter{w: plain, rc: http.NewResponseController(plain), idle: time.Second}
263+ _, err = pw.Write([]byte("ok"))
264+ require.NoError(t, err)
265+ assert.Equal(t, "ok", plain.Body.String())
266+}
267+
268+// A client that stops reading is cut after the idle timeout, with a real
269+// connection: the archive is cut short, the slot is given back, and the next
270+// download goes through.
271+func TestAClientThatStopsReadingIsCut(t *testing.T) {
272+ t.Parallel()
273+ var writeErr = make(chan error, 1)
274+ r := newRig(t, func(c *Config) {
275+ c.MaxArchives, c.MaxArchivesPerClient, c.WriteIdleTimeout = 2, 1, 300*time.Millisecond
276+ })
277+ chunk := make([]byte, 1<<20)
278+ r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(
279+ func(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
280+ w := begin("a.tar.gz")
281+ // More than the socket buffers hold: the write blocks once the client
282+ // reads nothing, and fails when the idle deadline passes.
283+ for range 1024 {
284+ if _, err := w.Write(chunk); err != nil {
285+ writeErr <- err
286+ return err
287+ }
288+ }
289+ writeErr <- nil
290+ return nil
291+ }).Once()
292+
293+ // The client takes the headers and reads no body.
294+ conn, err := net.Dial("tcp", strings.TrimPrefix(r.srv.URL, "http://"))
295+ require.NoError(t, err)
296+ t.Cleanup(func() { _ = conn.Close() })
297+ _, err = io.WriteString(conn, "GET "+Prefix+tarballPath+" HTTP/1.1\r\nHost: x\r\n\r\n")
298+ require.NoError(t, err)
299+
300+ select {
301+ case err := <-writeErr:
302+ require.Error(t, err, "the server gave up on a client that reads nothing")
303+ assert.Contains(t, err.Error(), "i/o timeout")
304+ case <-time.After(15 * time.Second):
305+ t.Fatal("the stalled client was never cut")
306+ }
307+
308+ // The slot came back: with a cap of one per client, another download starts.
309+ r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(
310+ func(_ context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
311+ _, err := begin("a.tar.gz").Write([]byte("fine"))
312+ return err
313+ }).Once()
314+ require.Eventually(t, func() bool {
315+ resp, body := r.do(t, http.MethodGet, tarballPath, "")
316+ return resp.StatusCode == http.StatusOK && body == "fine"
317+ }, 5*time.Second, 50*time.Millisecond)
318+}
319+
320+// The credentials of a request are the forge's: Basic with any user, Bearer or
321+// token; none is anonymous and does not touch the failure limiter.
322+func TestCredentialsAndNotFound(t *testing.T) {
323+ t.Parallel()
324+ r := newRig(t, nil)
325+ p := domain.Root()
326+ r.auth.EXPECT().Authenticate(mock.Anything, "good").Return(p, nil)
327+ r.auth.EXPECT().Authenticate(mock.Anything, "bad").Return(domain.Principal{}, domain.ErrUnauthenticated)
328+ // A header that carries no usable token authenticates the empty secret.
329+ r.auth.EXPECT().Authenticate(mock.Anything, "").Return(domain.Principal{}, domain.ErrUnauthenticated)
330+ r.api.EXPECT().HashAlgorithm(mock.Anything, p, "acme", "proj").Return("sha1", nil)
331+ r.api.EXPECT().HashAlgorithm(mock.Anything, domain.Anonymous(), "acme", "proj").Return("sha1", nil)
332+
333+ path := "/repos/acme/proj/hash-algorithm"
334+ for _, h := range []string{"Basic eC1hY2Nlc3MtdG9rZW46Z29vZA==", "Bearer good", "token good", "bearer good"} {
335+ resp, body := r.do(t, http.MethodGet, path, h)
336+ assert.Equal(t, http.StatusOK, resp.StatusCode, h)
337+ assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
338+ }
339+ resp, _ := r.do(t, http.MethodGet, path, "")
340+ assert.Equal(t, http.StatusOK, resp.StatusCode, "anonymous")
341+ for _, h := range []string{"Bearer bad", "Digest x", "Basic !!!", "Bearer"} {
342+ resp, body := r.do(t, http.MethodGet, path, h)
343+ assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, h)
344+ assert.Contains(t, body, "Bad credentials")
345+ }
346+
347+ // Not found is GitHub's body, for an unknown endpoint and for a use case that
348+ // says so, and a method other than GET and HEAD is 405.
349+ const gh = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
350+ for _, p := range []string{"", "/", "/user", "/repos", "/repos/acme", "/repos//proj", "/repos/acme/proj/issues", "/repos/acme/proj/hash-algorithm/x"} {
351+ resp, body := r.do(t, http.MethodGet, p, "")
352+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, p)
353+ assert.JSONEq(t, gh, body, p)
354+ }
355+ r.api.EXPECT().GetRepo(mock.Anything, domain.Anonymous(), "acme", "gone").Return(nil, domain.ErrNotFound)
356+ resp, body := r.do(t, http.MethodGet, "/repos/acme/gone", "")
357+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
358+ assert.JSONEq(t, gh, body)
359+ req, _ := http.NewRequest(http.MethodPost, r.srv.URL+Prefix+path, nil)
360+ post, err := http.DefaultClient.Do(req)
361+ require.NoError(t, err)
362+ _ = post.Body.Close()
363+ assert.Equal(t, http.StatusMethodNotAllowed, post.StatusCode)
364+ assert.Equal(t, "GET, HEAD", post.Header.Get("Allow"))
365+
366+ // A ref with slashes is passed whole.
367+ r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "feature/x/y").Return("n.tar.gz", nil)
368+ resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/feature/x/y", "")
369+ assert.Equal(t, http.StatusOK, resp.StatusCode)
370+}
A internal/adapters/gitremote/gitremote.go +222 -0
@@ -0,0 +1,222 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package gitremote lists the refs of repositories on github.com with
4+// `git ls-remote`, for the resolution of actions (ports.GitRemote). It sends no
5+// credentials, never prompts, and bounds the time and the output of git.
6+package gitremote
7+
8+import (
9+ "bytes"
10+ "context"
11+ "errors"
12+ "fmt"
13+ "os/exec"
14+ "strings"
15+ "time"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// Defaults of the bounds.
22+const (
23+ // DefaultTimeout is how long one ls-remote may take.
24+ DefaultTimeout = 20 * time.Second
25+ // DefaultMaxOutput is the most bytes of ls-remote output read: a repository
26+ // with 100,000 tags lists about 8 MB. More is refused as unavailable.
27+ DefaultMaxOutput = 16 << 20
28+ // DefaultBaseURL is where the repositories are.
29+ DefaultBaseURL = "https://github.com"
30+)
31+
32+// Config configures a Remote.
33+type Config struct {
34+ // Bin is the git executable. Required.
35+ Bin string
36+ // Path is the PATH given to git, which it uses to find its helpers.
37+ Path string
38+ // Home is the HOME of git; it should be an empty directory the process
39+ // owns, so that no user configuration or credential is read.
40+ Home string
41+ // BaseURL, Timeout and MaxOutput default to the constants above; tests
42+ // replace them.
43+ BaseURL string
44+ Timeout time.Duration
45+ MaxOutput int
46+ // AllowedProtocols is GIT_ALLOW_PROTOCOL: "https" by default.
47+ AllowedProtocols string
48+ // Proxy is the proxy environment forwarded to git, as NAME=value pairs: see
49+ // ProxyEnv. Nothing else of the environment of ermite reaches git.
50+ Proxy []string
51+}
52+
53+// proxyVars are the variables git (through libcurl) reads to reach github.com
54+// through a proxy. They are the only ones of the environment of ermite that
55+// the ls-remote processes get.
56+var proxyVars = []string{
57+ "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "ALL_PROXY",
58+ "https_proxy", "http_proxy", "no_proxy", "all_proxy",
59+}
60+
61+// ProxyEnv picks the proxy variables from an environment lookup (os.Getenv),
62+// for Config.Proxy. Empty ones are left out.
63+func ProxyEnv(get func(string) string) []string {
64+ var out []string
65+ for _, name := range proxyVars {
66+ if v := get(name); v != "" {
67+ out = append(out, name+"="+v)
68+ }
69+ }
70+ return out
71+}
72+
73+// Remote implements ports.GitRemote over the git binary.
74+type Remote struct{ cfg Config }
75+
76+var _ ports.GitRemote = (*Remote)(nil)
77+
78+// New builds a Remote.
79+func New(cfg Config) *Remote {
80+ if cfg.BaseURL == "" {
81+ cfg.BaseURL = DefaultBaseURL
82+ }
83+ if cfg.Timeout <= 0 {
84+ cfg.Timeout = DefaultTimeout
85+ }
86+ if cfg.MaxOutput <= 0 {
87+ cfg.MaxOutput = DefaultMaxOutput
88+ }
89+ if cfg.AllowedProtocols == "" {
90+ cfg.AllowedProtocols = "https"
91+ }
92+ return &Remote{cfg: cfg}
93+}
94+
95+// validSegment reports whether s can be an owner or a repository name on
96+// GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'.
97+func validSegment(s string) bool {
98+ if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' {
99+ return false
100+ }
101+ for _, r := range s {
102+ switch {
103+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
104+ default:
105+ return false
106+ }
107+ }
108+ return true
109+}
110+
111+// limitWriter keeps at most max bytes and records that more came.
112+type limitWriter struct {
113+ buf bytes.Buffer
114+ max int
115+ exceeded bool
116+ cancel context.CancelFunc
117+}
118+
119+func (w *limitWriter) Write(p []byte) (int, error) {
120+ if w.buf.Len()+len(p) > w.max {
121+ w.exceeded = true
122+ w.cancel()
123+ return 0, errors.New("output limit reached")
124+ }
125+ return w.buf.Write(p)
126+}
127+
128+// ListRefs lists the branches and tags of owner/repo, with the peeled commit
129+// of the annotated tags.
130+func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
131+ if !validSegment(owner) || !validSegment(repo) {
132+ return nil, domain.ErrNotFound
133+ }
134+ ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
135+ defer cancel()
136+ out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel}
137+ var stderr bytes.Buffer
138+ url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo
139+ cmd := exec.CommandContext(ctx, r.cfg.Bin,
140+ "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false",
141+ "ls-remote", "--", url, "refs/heads/*", "refs/tags/*")
142+ cmd.Env = []string{
143+ "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home,
144+ "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
145+ "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
146+ }
147+ cmd.Env = append(cmd.Env, r.cfg.Proxy...)
148+ cmd.Dir = r.cfg.Home
149+ cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
150+ cmd.WaitDelay = 2 * time.Second
151+ err := cmd.Run()
152+ switch {
153+ case out.exceeded:
154+ return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput)
155+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
156+ return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout)
157+ case ctx.Err() != nil:
158+ return nil, ctx.Err()
159+ case err != nil:
160+ msg := stderr.String()
161+ if notFound(msg) {
162+ return nil, domain.ErrNotFound
163+ }
164+ return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg))
165+ }
166+ return parse(out.buf.String()), nil
167+}
168+
169+// notFound recognises what git says when github.com answers 404 or asks for
170+// credentials, which it does for a repository that does not exist as for a
171+// private one, and the prompt being disabled.
172+func notFound(stderr string) bool {
173+ for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} {
174+ if strings.Contains(stderr, s) {
175+ return true
176+ }
177+ }
178+ return false
179+}
180+
181+func firstLine(s string) string {
182+ s = strings.TrimSpace(s)
183+ if i := strings.IndexByte(s, '\n'); i >= 0 {
184+ s = s[:i]
185+ }
186+ return s
187+}
188+
189+// limitedBuffer keeps the first max bytes written and drops the rest.
190+type limitedBuffer struct {
191+ b *bytes.Buffer
192+ max int
193+}
194+
195+func (l *limitedBuffer) Write(p []byte) (int, error) {
196+ if room := l.max - l.b.Len(); room > 0 {
197+ l.b.Write(p[:min(room, len(p))])
198+ }
199+ return len(p), nil
200+}
201+
202+// parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives
203+// the peeled commit of the annotated tag listed before it.
204+func parse(out string) []ports.RemoteRef {
205+ var refs []ports.RemoteRef
206+ index := map[string]int{}
207+ for line := range strings.SplitSeq(out, "\n") {
208+ id, name, ok := strings.Cut(line, "\t")
209+ if !ok || len(id) != domain.ObjectIDLen {
210+ continue
211+ }
212+ if base, peeled := strings.CutSuffix(name, "^{}"); peeled {
213+ if i, ok := index[base]; ok {
214+ refs[i].Peeled = id
215+ }
216+ continue
217+ }
218+ index[name] = len(refs)
219+ refs = append(refs, ports.RemoteRef{Name: name, ID: id})
220+ }
221+ return refs
222+}
A internal/adapters/gitremote/gitremote_test.go +179 -0
@@ -0,0 +1,179 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitremote_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/gitremote"
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+func gitBin(t *testing.T) string {
22+ t.Helper()
23+ bin, err := exec.LookPath("git")
24+ if err != nil {
25+ t.Skip("git is not installed")
26+ }
27+ return bin
28+}
29+
30+func run(t *testing.T, dir string, args ...string) string {
31+ t.Helper()
32+ cmd := exec.Command("git", args...)
33+ cmd.Dir = dir
34+ cmd.Env = []string{
35+ "PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
36+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x",
37+ }
38+ out, err := cmd.CombinedOutput()
39+ require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out)
40+ return strings.TrimSpace(string(out))
41+}
42+
43+// fixture builds <base>/o/r with a branch, a lightweight tag and an annotated
44+// tag, and returns the base as a file:// URL.
45+func fixture(t *testing.T) (base, commit, tagObj string) {
46+ t.Helper()
47+ root := t.TempDir()
48+ repo := filepath.Join(root, "o", "r")
49+ require.NoError(t, os.MkdirAll(repo, 0o755))
50+ run(t, repo, "init", "-q", "--initial-branch=main")
51+ require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644))
52+ run(t, repo, "add", "a")
53+ run(t, repo, "commit", "-q", "-m", "a")
54+ commit = run(t, repo, "rev-parse", "HEAD")
55+ run(t, repo, "tag", "light")
56+ run(t, repo, "tag", "-a", "-m", "annotated", "v1")
57+ tagObj = run(t, repo, "rev-parse", "refs/tags/v1")
58+ return "file://" + root, commit, tagObj
59+}
60+
61+func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote {
62+ t.Helper()
63+ cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"}
64+ if mut != nil {
65+ mut(&cfg)
66+ }
67+ return gitremote.New(cfg)
68+}
69+
70+func TestListRefsPeelsAnnotatedTags(t *testing.T) {
71+ base, commit, tagObj := fixture(t)
72+ refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r")
73+ require.NoError(t, err)
74+ byName := map[string][2]string{}
75+ for _, r := range refs {
76+ byName[r.Name] = [2]string{r.ID, r.Peeled}
77+ }
78+ assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"])
79+ assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"])
80+ assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled")
81+ assert.Len(t, refs, 3)
82+}
83+
84+// github.com answers a repository that does not exist, or that is private, with
85+// a 404 that git reports as a missing repository or as a prompt it may not
86+// show; both are "repository not found". Any other failure is unavailable.
87+func TestListRefsMapsWhatGitSays(t *testing.T) {
88+ fake := func(stderr string) string {
89+ bin := filepath.Join(t.TempDir(), "git")
90+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755))
91+ return bin
92+ }
93+ for _, tc := range []struct {
94+ stderr string
95+ want error
96+ }{
97+ {"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound},
98+ {"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound},
99+ {"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable},
100+ {"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable},
101+ } {
102+ _, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r")
103+ assert.ErrorIs(t, err, tc.want, tc.stderr)
104+ }
105+}
106+
107+func TestListRefsRefusesBadNames(t *testing.T) {
108+ r := newRemote(t, "file:///nonexistent", nil)
109+ for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} {
110+ _, err := r.ListRefs(context.Background(), bad[0], bad[1])
111+ assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1])
112+ }
113+}
114+
115+func TestListRefsBoundsTheOutput(t *testing.T) {
116+ base, _, _ := fixture(t)
117+ _, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r")
118+ assert.ErrorIs(t, err, domain.ErrUnavailable)
119+}
120+
121+func TestListRefsTimesOut(t *testing.T) {
122+ bin := filepath.Join(t.TempDir(), "git")
123+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
124+ start := time.Now()
125+ _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 200*time.Millisecond }).ListRefs(context.Background(), "o", "r")
126+ assert.ErrorIs(t, err, domain.ErrUnavailable)
127+ assert.Less(t, time.Since(start), 10*time.Second)
128+}
129+
130+// git asks for no credentials and runs with no configuration of the machine:
131+// the environment it gets is only what the adapter builds.
132+func TestListRefsGitEnvironment(t *testing.T) {
133+ out := filepath.Join(t.TempDir(), "env")
134+ bin := filepath.Join(t.TempDir(), "git")
135+ script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n"
136+ require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
137+ t.Setenv("GITHUB_TOKEN", "secret-from-the-process")
138+ _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r")
139+ require.NoError(t, err)
140+ b, err := os.ReadFile(out)
141+ require.NoError(t, err)
142+ env := string(b)
143+ assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0")
144+ assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https")
145+ assert.NotContains(t, env, "secret-from-the-process")
146+ assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
147+ assert.Contains(t, env, "credential.helper=")
148+}
149+
150+// GO-008: the proxy variables of ermite reach git, and nothing else of its
151+// environment does.
152+func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
153+ out := filepath.Join(t.TempDir(), "env")
154+ bin := filepath.Join(t.TempDir(), "git")
155+ require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
156+ t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
157+ t.Setenv("no_proxy", "localhost,.internal")
158+ t.Setenv("HTTP_PROXY", "")
159+ t.Setenv("GITHUB_TOKEN", "not-for-git")
160+
161+ proxy := gitremote.ProxyEnv(os.Getenv)
162+ assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
163+
164+ _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
165+ require.NoError(t, err)
166+ b, err := os.ReadFile(out)
167+ require.NoError(t, err)
168+ env := string(b)
169+ assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
170+ assert.Contains(t, env, "no_proxy=localhost,.internal")
171+ assert.NotContains(t, env, "not-for-git")
172+ assert.NotContains(t, env, "HTTP_PROXY=")
173+
174+ // Without Config.Proxy nothing is forwarded, whatever the process has.
175+ _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
176+ require.NoError(t, err)
177+ b, _ = os.ReadFile(out)
178+ assert.NotContains(t, string(b), "proxy.internal")
179+}
M internal/adapters/kvstore/ci.go +10 -1
@@ -37,6 +37,8 @@ import (
37 // ciready/<ready_at:020d>/<job key> job waiting for a runner37 // ciready/<ready_at:020d>/<job key> job waiting for a runner
38 // ciactive/<job key> job assigned or running38 // ciactive/<job key> job assigned or running
39 // jobtoken/<hash> job token (value: job key)39 // jobtoken/<hash> job token (value: job key)
40+//
41+// The job repository tokens and the action resolutions are in ci_token.go.
40 const (42 const (
41 runPrefix = "run/"43 runPrefix = "run/"
42 runSrcPrefix = "runsrc/"44 runSrcPrefix = "runsrc/"
@@ -507,6 +509,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
507 guarded[j.Repo] = true509 guarded[j.Repo] = true
508 }510 }
509 }511 }
512+ c.stageRepoTokens(ctx, b, w, guarded)
510 // Checks are derived from the jobs, written in the same batch and under513 // Checks are derived from the jobs, written in the same batch and under
511 // the lock of their run: they are upserted without a precondition.514 // the lock of their run: they are upserted without a precondition.
512 for _, ck := range w.Checks {515 for _, ck := range w.Checks {
@@ -587,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
587 }590 }
588 src := mustMarshal(&storagepb.CIRunSource{591 src := mustMarshal(&storagepb.CIRunSource{
589 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,592 RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
593+ PusherKind: string(draft.Source.Pusher.Kind), PusherTokenId: draft.Source.Pusher.TokenID,
590 })594 })
591 b.RequireAbsent(runSrcKey(draft.Run.ID))595 b.RequireAbsent(runSrcKey(draft.Run.ID))
592 b.Put(runSrcKey(draft.Run.ID), src)596 b.Put(runSrcKey(draft.Run.ID), src)
@@ -639,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
639 if err != nil {643 if err != nil {
640 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)644 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
641 }645 }
642- return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil646+ return domain.CIRunSource{
647+ RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson(),
648+ Pusher: domain.CIPusher{Kind: domain.CIPusherKind(pb.GetPusherKind()), TokenID: pb.GetPusherTokenId()},
649+ }, nil
643 }650 }
644 651
645 // ListRuns returns the runs of the repository, newest first.652 // ListRuns returns the runs of the repository, newest first.
@@ -828,6 +835,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
828 // runKeys lists every key of a run except its record: source, jobs, indexes.835 // runKeys lists every key of a run except its record: source, jobs, indexes.
829 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {836 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
830 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}837 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
838+ keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
831 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {839 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
832 keys = append(keys, slices.Clone(key))840 keys = append(keys, slices.Clone(key))
833 if j, err := decodeJob(item); err == nil {841 if j, err := decodeJob(item); err == nil {
@@ -852,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
852 }860 }
853 keys = append(keys, slices.Clone(key))861 keys = append(keys, slices.Clone(key))
854 }862 }
863+ keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
855 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {864 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
856 for k := range snap.Prefix(ctx, []byte(p)) {865 for k := range snap.Prefix(ctx, []byte(p)) {
857 keys = append(keys, slices.Clone(k))866 keys = append(keys, slices.Clone(k))
A internal/adapters/kvstore/ci_token.go +208 -0
@@ -0,0 +1,208 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "fmt"
10+ "strings"
11+
12+ kv "bnema.dev/ermite-kv"
13+ "google.golang.org/protobuf/proto"
14+
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Key layout of the job repository tokens and of the action resolutions,
21+// written by CI.Apply under the lock of their run:
22+//
23+// ghtoken/<id> CIRepoToken
24+// ghtokenhash/<hash> token id
25+// ghtokenjob/<run:020d>/<attempt:010d>/<job>/<id> the id: the tokens of a job
26+// ghtokenrepo/<repo>/<id> the job key: the tokens that read a repository
27+// actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution
28+//
29+// Stored tokens use token/ and tokenhash/: the two families never meet, so a
30+// job token is never listed, revoked or managed as a stored token.
31+const (
32+ repoTokenPrefix = "ghtoken/"
33+ repoTokenHashPrefix = "ghtokenhash/"
34+ repoTokenJobPrefix = "ghtokenjob/"
35+ repoTokenRepoPrefix = "ghtokenrepo/"
36+ resolutionPrefix = "actres/"
37+)
38+
39+var _ ports.CIRepoTokenStore = (*CI)(nil)
40+
41+func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) }
42+func repoTokenHashKey(h domain.TokenHash) []byte {
43+ return []byte(repoTokenHashPrefix + h.String())
44+}
45+func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string {
46+ return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID)
47+}
48+func repoTokenJobKey(t domain.CIRepoToken) []byte {
49+ return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)
50+}
51+func repoTokenRepoPrefixOf(repo domain.RepoName) string {
52+ return repoTokenRepoPrefix + repo.String() + "/"
53+}
54+func repoTokenRepoKey(repo domain.RepoName, id string) []byte {
55+ return []byte(repoTokenRepoPrefixOf(repo) + id)
56+}
57+func resolutionKey(runID int64, attempt int32, key string) []byte {
58+ sum := sha256.Sum256([]byte(key))
59+ return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))
60+}
61+
62+func encodeRepoToken(t domain.CIRepoToken) []byte {
63+ return mustMarshal(&storagepb.CIRepoToken{
64+ Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha,
66+ })
67+}
68+
69+func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
70+ var pb storagepb.CIRepoToken
71+ if err := proto.Unmarshal(val, &pb); err != nil {
72+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
73+ }
74+ repo, err := domain.ParseRepoName(pb.GetRepo())
75+ if err != nil {
76+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
77+ }
78+ t := domain.CIRepoToken{
79+ ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
80+ Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81+ Sha: pb.GetSha(),
82+ }
83+ copy(t.Hash[:], pb.GetHash())
84+ return t, nil
85+}
86+
87+// stageRepoTokens stages the creation of the tokens, the deletion of the
88+// tokens of the jobs to revoke, and the resolutions to keep.
89+func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) {
90+ snap := c.db.Snapshot(ctx)
91+ for _, ref := range w.RevokeJobs {
92+ for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) {
93+ b.Delete(append([]byte(nil), key...))
94+ id := string(item.Value)
95+ if id == "" {
96+ id = string(key[strings.LastIndexByte(string(key), '/')+1:])
97+ }
98+ b.Delete(repoTokenKey(id))
99+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
100+ if t, err := decodeRepoToken(it.Value); err == nil {
101+ b.Delete(repoTokenHashKey(t.Hash))
102+ b.Delete(repoTokenRepoKey(t.Repo, id))
103+ }
104+ }
105+ }
106+ }
107+ for _, t := range w.RepoTokens {
108+ b.RequireAbsent(repoTokenKey(t.ID))
109+ b.RequireAbsent(repoTokenHashKey(t.Hash))
110+ b.Put(repoTokenKey(t.ID), encodeRepoToken(t))
111+ b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))
112+ b.Put(repoTokenJobKey(t), []byte(t.ID))
113+ b.Put(repoTokenRepoKey(t.Repo, t.ID), []byte(t.ID))
114+ guarded[t.Repo] = true
115+ }
116+ for _, r := range w.Resolutions {
117+ b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{
118+ Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2
119+ }))
120+ }
121+}
122+
123+// FindRepoTokenByHash resolves the hash index, then reads the token.
124+func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) {
125+ snap := c.db.Snapshot(ctx)
126+ idx, ok := snap.Get(ctx, repoTokenHashKey(hash))
127+ if !ok {
128+ return domain.CIRepoToken{}, domain.ErrNotFound
129+ }
130+ it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value)))
131+ if !ok {
132+ return domain.CIRepoToken{}, domain.ErrNotFound
133+ }
134+ t, err := decodeRepoToken(it.Value)
135+ if err != nil {
136+ return domain.CIRepoToken{}, err
137+ }
138+ if t.Hash != hash {
139+ return domain.CIRepoToken{}, domain.ErrNotFound
140+ }
141+ return t, nil
142+}
143+
144+// GetRepoToken reads ghtoken/<id>.
145+func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) {
146+ if !validTokenID(id) {
147+ return domain.CIRepoToken{}, domain.ErrNotFound
148+ }
149+ it, ok := c.db.Get(ctx, repoTokenKey(id))
150+ if !ok {
151+ return domain.CIRepoToken{}, domain.ErrNotFound
152+ }
153+ return decodeRepoToken(it.Value)
154+}
155+
156+// GetActionResolution reads the resolution kept for the key.
157+func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) {
158+ it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key))
159+ if !ok {
160+ return domain.CIActionResolution{}, domain.ErrNotFound
161+ }
162+ var pb storagepb.CIActionResolution
163+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
164+ return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err)
165+ }
166+ return domain.CIActionResolution{
167+ RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(),
168+ ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()),
169+ }, nil
170+}
171+
172+// tokenKeys lists the keys of the token with that id: the record, its hash
173+// index, its entry in the list of its job and in the list of its repository.
174+func tokenKeys(ctx context.Context, snap *kv.Snapshot, id string) [][]byte {
175+ keys := [][]byte{repoTokenKey(id)}
176+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
177+ if t, err := decodeRepoToken(it.Value); err == nil {
178+ keys = append(keys, repoTokenHashKey(t.Hash), repoTokenJobKey(t), repoTokenRepoKey(t.Repo, id))
179+ }
180+ }
181+ return keys
182+}
183+
184+// repoTokenKeys lists the keys of the repository tokens and action
185+// resolutions of a run, for its deletion.
186+func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
187+ var keys [][]byte
188+ for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {
189+ keys = append(keys, append([]byte(nil), key...))
190+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
191+ }
192+ for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {
193+ keys = append(keys, append([]byte(nil), key...))
194+ }
195+ return keys
196+}
197+
198+// repoReaderKeys lists the keys of every repository token that reads the
199+// repository, whichever job holds it: the archive tokens given to the jobs of
200+// other repositories go with the repository they read.
201+func repoReaderKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
202+ var keys [][]byte
203+ for key, item := range snap.Prefix(ctx, []byte(repoTokenRepoPrefixOf(repo))) {
204+ keys = append(keys, append([]byte(nil), key...))
205+ keys = append(keys, tokenKeys(ctx, snap, string(item.Value))...)
206+ }
207+ return keys
208+}
A internal/adapters/kvstore/ci_token_test.go +195 -0
@@ -0,0 +1,195 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+func repoToken(id, secret string, run jobRefT, repo domain.RepoName, archive bool) domain.CIRepoToken {
18+ return domain.CIRepoToken{
19+ ID: id, Hash: domain.HashTokenSecret(secret), Repo: repo, RunID: run.RunID, RunAttempt: 1, JobID: run.JobID,
20+ Archive: archive, ExpiresAt: created.Add(time.Hour), CreatedAt: created,
21+ }
22+}
23+
24+func TestCIRepoTokensLifecycle(t *testing.T) {
25+ ctx := context.Background()
26+ s, ci, n := ciRepo(t)
27+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
28+ require.NoError(t, err)
29+ a, b := d.Jobs[0], d.Jobs[1]
30+ refA := jobRefT{RunID: d.Run.ID, JobID: a.ID}
31+ refB := jobRefT{RunID: d.Run.ID, JobID: b.ID}
32+
33+ own := repoToken("t1", "ermitegh_own", refA, n, false)
34+ arch := repoToken("t2", "ermitegh_arch", refA, n, true)
35+ other := repoToken("t3", "ermitegh_other", refB, n, false)
36+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch, other}})
37+ require.NoError(t, err)
38+
39+ got, err := ci.FindRepoTokenByHash(ctx, own.Hash)
40+ require.NoError(t, err)
41+ assert.Equal(t, own.ID, got.ID)
42+ assert.Equal(t, n, got.Repo)
43+ assert.False(t, got.Archive)
44+ assert.True(t, got.ExpiresAt.Equal(own.ExpiresAt))
45+ byID, err := ci.GetRepoToken(ctx, "t2")
46+ require.NoError(t, err)
47+ assert.True(t, byID.Archive)
48+ _, err = ci.FindRepoTokenByHash(ctx, domain.HashTokenSecret("ermitegh_nope"))
49+ assert.ErrorIs(t, err, domain.ErrNotFound)
50+ _, err = ci.GetRepoToken(ctx, "../token/x")
51+ assert.ErrorIs(t, err, domain.ErrNotFound)
52+
53+ // They live apart from the stored tokens: ListTokens-style reads see none.
54+ stored, err := s.Tokens().List(ctx)
55+ require.NoError(t, err)
56+ assert.Empty(t, stored)
57+ _, err = s.Tokens().FindByHash(ctx, own.Hash)
58+ assert.ErrorIs(t, err, domain.ErrNotFound)
59+ _, err = s.Tokens().Get(ctx, "t1")
60+ assert.ErrorIs(t, err, domain.ErrNotFound)
61+
62+ // A token that exists is not created twice.
63+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own}})
64+ assert.Error(t, err)
65+
66+ // Revoking a job deletes its tokens, and only its tokens.
67+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}}})
68+ require.NoError(t, err)
69+ for _, tok := range []domain.CIRepoToken{own, arch} {
70+ _, err = ci.FindRepoTokenByHash(ctx, tok.Hash)
71+ assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID)
72+ _, err = ci.GetRepoToken(ctx, tok.ID)
73+ assert.ErrorIs(t, err, domain.ErrNotFound, tok.ID)
74+ }
75+ _, err = ci.FindRepoTokenByHash(ctx, other.Hash)
76+ assert.NoError(t, err, "the other job keeps its token")
77+
78+ // Revoking again, or a job without tokens, changes nothing.
79+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: a.ID}, {RunID: 99, RunAttempt: 1, JobID: "99-0-0"}}})
80+ require.NoError(t, err)
81+
82+ // A token can be revoked and created again in one batch (a new assignment).
83+ again := repoToken("t4", "ermitegh_again", refB, n, false)
84+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: b.ID}}, RepoTokens: []domain.CIRepoToken{again}})
85+ require.NoError(t, err)
86+ _, err = ci.FindRepoTokenByHash(ctx, other.Hash)
87+ assert.ErrorIs(t, err, domain.ErrNotFound)
88+ _, err = ci.FindRepoTokenByHash(ctx, again.Hash)
89+ assert.NoError(t, err)
90+
91+ // A token of a repository that no longer exists cannot be created.
92+ require.NoError(t, s.Delete(ctx, n))
93+ _, err = ci.FindRepoTokenByHash(ctx, again.Hash)
94+ assert.ErrorIs(t, err, domain.ErrNotFound, "deleting the repository deletes the tokens of its runs")
95+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{repoToken("t5", "ermitegh_late", refA, n, false)}})
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+}
98+
99+func TestCIActionResolutionsAreKeptPerAttemptAndSwept(t *testing.T) {
100+ ctx := context.Background()
101+ _, ci, n := ciRepo(t)
102+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
103+ require.NoError(t, err)
104+ r := domain.CIActionResolution{
105+ RunID: d.Run.ID, RunAttempt: 1, Key: "actions/checkout@v4", NameWithOwner: "actions/checkout",
106+ ResolvedSHA: "1111111111111111111111111111111111111111", Source: domain.CIActionGitHub,
107+ }
108+ _, err = ci.Apply(ctx, ports.CIWrite{Resolutions: []domain.CIActionResolution{r}})
109+ require.NoError(t, err)
110+
111+ got, err := ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4")
112+ require.NoError(t, err)
113+ assert.Equal(t, r, got)
114+ _, err = ci.GetActionResolution(ctx, d.Run.ID, 2, "actions/checkout@v4")
115+ assert.ErrorIs(t, err, domain.ErrNotFound, "another attempt resolves again")
116+ _, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v5")
117+ assert.ErrorIs(t, err, domain.ErrNotFound)
118+
119+ // With a token of the run, both go when the run is swept.
120+ tok := repoToken("t1", "ermitegh_x", jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}, n, false)
121+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{tok}})
122+ require.NoError(t, err)
123+ run, _ := ci.GetRun(ctx, n, d.Run.ID)
124+ run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, created
125+ job, _ := ci.GetJob(ctx, run.ID, 1, d.Jobs[0].ID)
126+ job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
127+ _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
128+ require.NoError(t, err)
129+ ids, err := ci.Sweep(ctx, created.Add(time.Hour))
130+ require.NoError(t, err)
131+ assert.Equal(t, []int64{d.Run.ID}, ids)
132+ _, err = ci.GetActionResolution(ctx, d.Run.ID, 1, "actions/checkout@v4")
133+ assert.ErrorIs(t, err, domain.ErrNotFound)
134+ _, err = ci.FindRepoTokenByHash(ctx, tok.Hash)
135+ assert.ErrorIs(t, err, domain.ErrNotFound)
136+}
137+
138+// jobRefT names a job of the first attempt of a run in these tests.
139+type jobRefT struct {
140+ RunID int64
141+ JobID string
142+}
143+
144+// GO-006: an archive token is indexed by the repository it reads, so deleting
145+// that repository deletes it, though it belongs to a job of another one.
146+func TestCIArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
147+ ctx := context.Background()
148+ s, ci, run := ciRepo(t)
149+ target := name(t, "action")
150+ require.NoError(t, s.Create(ctx, domain.Repo{Name: target, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151+ d, err := ci.CreateRun(ctx, run, "w.yml", draftOf(run, "w.yml", 1))
152+ require.NoError(t, err)
153+ ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
154+
155+ own := repoToken("own", "ermitegh_own", ref, run, false)
156+ arch := repoToken("arch", "ermitegh_arch", ref, target, true)
157+ arch.Sha = "1111111111111111111111111111111111111111"
158+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{own, arch}})
159+ require.NoError(t, err)
160+ got, err := ci.FindRepoTokenByHash(ctx, arch.Hash)
161+ require.NoError(t, err)
162+ assert.Equal(t, arch.Sha, got.Sha, "the commit is stored")
163+ assert.True(t, got.Archive)
164+
165+ require.NoError(t, s.Delete(ctx, target))
166+ _, err = ci.FindRepoTokenByHash(ctx, arch.Hash)
167+ assert.ErrorIs(t, err, domain.ErrNotFound, "gone with the repository it reads")
168+ _, err = ci.GetRepoToken(ctx, "arch")
169+ assert.ErrorIs(t, err, domain.ErrNotFound)
170+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
171+ assert.NoError(t, err, "the token of the run's own repository is untouched")
172+
173+ // Revoking the job afterwards finds no stale entry to trip on.
174+ _, err = ci.Apply(ctx, ports.CIWrite{RevokeJobs: []ports.CIJobRef{{RunID: d.Run.ID, RunAttempt: 1, JobID: ref.JobID}}})
175+ require.NoError(t, err)
176+ _, err = ci.FindRepoTokenByHash(ctx, own.Hash)
177+ assert.ErrorIs(t, err, domain.ErrNotFound)
178+}
179+
180+// GO-001: the pusher recorded with a run comes back with its source.
181+func TestCIRunSourceKeepsThePusher(t *testing.T) {
182+ ctx := context.Background()
183+ _, ci, n := ciRepo(t)
184+ for i, p := range []domain.CIPusher{{Kind: domain.CIPusherRoot}, {Kind: domain.CIPusherOwner}, {Kind: domain.CIPusherToken, TokenID: "abc123"}, {}} {
185+ d, err := ci.CreateRun(ctx, n, "w.yml", func(id, number int64) ports.CIRunDraft {
186+ dr := draftOf(n, "w.yml", 0)(id, number)
187+ dr.Source.Pusher = p
188+ return dr
189+ })
190+ require.NoError(t, err, i)
191+ src, err := ci.GetRunSource(ctx, d.Run.ID)
192+ require.NoError(t, err)
193+ assert.Equal(t, p, src.Pusher)
194+ }
195+}
M internal/adapters/rpc/api.go +1 -1
@@ -28,7 +28,7 @@ const MaxRequestBytes = 1 << 20
28 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
29 // always implements, as GetVersion lists them. FeaturePasskeys comes on top29 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
30 // when Config.Passkeys is set.30 // when Config.Passkeys is set.
31-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}31+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}
32 32
33 // FeaturePasskeys is listed by GetVersion when passkeys are available.33 // FeaturePasskeys is listed by GetVersion when passkeys are available.
34 const FeaturePasskeys = "passkeys"34 const FeaturePasskeys = "passkeys"
M internal/adapters/rpc/ci.go +38 -0
@@ -33,6 +33,9 @@ var runnerSecretProcedures = map[string]bool{
33 ermiteconnect.RunnerServiceHeartbeatProcedure: true,33 ermiteconnect.RunnerServiceHeartbeatProcedure: true,
34 ermiteconnect.RunnerServiceWatchJobProcedure: true,34 ermiteconnect.RunnerServiceWatchJobProcedure: true,
35 ermiteconnect.RunnerServiceCompleteJobProcedure: true,35 ermiteconnect.RunnerServiceCompleteJobProcedure: true,
36+ // ResolveActions and UploadSummary are job calls too.
37+ ermiteconnect.RunnerServiceResolveActionsProcedure: true,
38+ ermiteconnect.RunnerServiceUploadSummaryProcedure: true,
36 }39 }
37 40
38 // ciError maps an error of the CI use cases. A call on a job that ended gets41 // ciError maps an error of the CI use cases. A call on a job that ended gets
@@ -169,6 +172,30 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb
169 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil172 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
170 }173 }
171 174
175+//
176+//hexcheck:ignore suspicious-business-logic-in-adapter maps the references of the request to strings and the answers back to the contract; the rules are the use case's
177+func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) {
178+ uses := make([]string, 0, len(req.Msg.GetRefs()))
179+ for _, r := range req.Msg.GetRefs() {
180+ uses = append(uses, r.GetUses())
181+ }
182+ res, err := s.runners.ResolveActions(ctx, secretFrom(ctx), req.Msg.GetJobId(), uses)
183+ if err != nil {
184+ return nil, s.failed(ctx, err)
185+ }
186+ out := &pb.ResolveActionsResponse{Actions: make([]*pb.ResolvedAction, 0, len(res))}
187+ for _, a := range res {
188+ out.Actions = append(out.Actions, resolvedActionToPB(a))
189+ }
190+ return connect.NewResponse(out), nil
191+}
192+
193+// UploadSummary is the E2 work (summaries storage): until the server lists
194+// "ci.summaries" it answers unimplemented, as runner.proto says.
195+func (s *runnerService) UploadSummary(context.Context, *connect.Request[pb.UploadSummaryRequest]) (*connect.Response[pb.UploadSummaryResponse], error) {
196+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.UploadSummary is not implemented"))
197+}
198+
172 // runService implements ermiteconnect.RunServiceHandler.199 // runService implements ermiteconnect.RunServiceHandler.
173 type runService struct{ runs ports.RunService }200 type runService struct{ runs ports.RunService }
174 201
@@ -306,3 +333,14 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R
306 }333 }
307 return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil334 return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
308 }335 }
336+
337+// ListAnnotations and GetStepSummaries are the E2 work (annotations and
338+// summaries): unimplemented until the server lists "ci.annotations" and
339+// "ci.summaries".
340+func (s *runService) ListAnnotations(context.Context, *connect.Request[pb.ListAnnotationsRequest]) (*connect.Response[pb.ListAnnotationsResponse], error) {
341+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.ListAnnotations is not implemented"))
342+}
343+
344+func (s *runService) GetStepSummaries(context.Context, *connect.Request[pb.GetStepSummariesRequest]) (*connect.Response[pb.GetStepSummariesResponse], error) {
345+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunService.GetStepSummaries is not implemented"))
346+}
M internal/adapters/rpc/ci_convert.go +16 -1
@@ -205,10 +205,25 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
205 JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,205 JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
206 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,206 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
207 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,207 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
208- Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref,208+ Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
209 }209 }
210 }210 }
211 211
212+func resolvedActionToPB(a ports.ResolvedAction) *pb.ResolvedAction {
213+ out := &pb.ResolvedAction{Ref: a.Ref, Error: a.Error}
214+ if a.Error != "" {
215+ return out
216+ }
217+ out.NameWithOwner, out.Path, out.ResolvedSha, out.TarballUrl, out.Token = a.NameWithOwner, a.Path, a.ResolvedSHA, a.TarballURL, a.Token
218+ switch a.Source {
219+ case domain.CIActionGitHub:
220+ out.Source = pb.ActionSource_ACTION_SOURCE_GITHUB
221+ case domain.CIActionErmite:
222+ out.Source = pb.ActionSource_ACTION_SOURCE_ERMITE
223+ }
224+ return out
225+}
226+
212 func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {227 func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
213 out := make([]ports.StepUpdate, 0, len(steps))228 out := make([]ports.StepUpdate, 0, len(steps))
214 for _, s := range steps {229 for _, s := range steps {
M internal/adapters/rpc/errors.go +2 -0
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
55 return connect.CodeAborted, true55 return connect.CodeAborted, true
56 case errors.Is(err, domain.ErrResourceExhausted):56 case errors.Is(err, domain.ErrResourceExhausted):
57 return connect.CodeResourceExhausted, true57 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrUnavailable):
59+ return connect.CodeUnavailable, true
58 }60 }
59 if domain.IsInvalid(err) {61 if domain.IsInvalid(err) {
60 return connect.CodeInvalidArgument, true62 return connect.CodeInvalidArgument, true
M internal/adapters/web/web.go +1 -1
@@ -235,7 +235,7 @@ func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
235 return "", false235 return "", false
236 }236 }
237 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")237 first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
238- if !ok || first != s.cfg.Owner || rest == "" {238+ if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" {
239 return "", false239 return "", false
240 }240 }
241 repo, _, _ := strings.Cut(rest, "/")241 repo, _, _ := strings.Cut(rest, "/")
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
A internal/app/ci_execution_test.go +1541 -0
@@ -0,0 +1,1541 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "archive/tar"
7+ "bytes"
8+ "compress/gzip"
9+ "context"
10+ "encoding/base64"
11+ "encoding/json"
12+ "io"
13+ "net/http"
14+ "net/http/httptest"
15+ "path/filepath"
16+ "slices"
17+ "strings"
18+ "sync"
19+ "sync/atomic"
20+ "testing"
21+ "time"
22+
23+ "connectrpc.com/connect"
24+ "github.com/stretchr/testify/assert"
25+ "github.com/stretchr/testify/mock"
26+ "github.com/stretchr/testify/require"
27+
28+ pb "bnema.dev/ermite-api/gen/ermite"
29+ "bnema.dev/ermite/internal/domain"
30+ "bnema.dev/ermite/internal/ports"
31+ "bnema.dev/ermite/internal/ports/mocks"
32+ "bnema.dev/ermite/internal/usecase"
33+)
34+
35+// CI-2 execution (phase E1): the job repository token, the git URLs of the
36+// owner, the REST subset and the resolution of actions.
37+
38+// fakeRemote stands in for github.com: no test reaches the network. It is the
39+// generated mock of ports.GitRemote answering from a table the test edits.
40+type fakeRemote struct {
41+ m *mocks.GitRemote
42+ mu sync.Mutex
43+ refs map[string][]ports.RemoteRef
44+ err map[string]error
45+ calls []string
46+}
47+
48+func newFakeRemote(t *testing.T) *fakeRemote {
49+ t.Helper()
50+ f := &fakeRemote{m: mocks.NewGitRemote(t), refs: map[string][]ports.RemoteRef{}, err: map[string]error{}}
51+ f.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
52+ func(_ context.Context, owner, repo string) ([]ports.RemoteRef, error) {
53+ f.mu.Lock()
54+ defer f.mu.Unlock()
55+ key := strings.ToLower(owner + "/" + repo)
56+ f.calls = append(f.calls, key)
57+ if err := f.err[key]; err != nil {
58+ return nil, err
59+ }
60+ refs, ok := f.refs[key]
61+ if !ok {
62+ return nil, domain.ErrNotFound
63+ }
64+ return refs, nil
65+ }).Maybe()
66+ return f
67+}
68+
69+func (f *fakeRemote) set(repo string, refs ...ports.RemoteRef) {
70+ f.mu.Lock()
71+ defer f.mu.Unlock()
72+ f.refs[repo] = refs
73+}
74+
75+func (f *fakeRemote) asked(key string) bool {
76+ f.mu.Lock()
77+ defer f.mu.Unlock()
78+ return slices.Contains(f.calls, key)
79+}
80+
81+func (f *fakeRemote) count() int {
82+ f.mu.Lock()
83+ defer f.mu.Unlock()
84+ return len(f.calls)
85+}
86+
87+// newExecHarness is newCIHarness with a fake github.com.
88+func newExecHarness(t *testing.T, remote *fakeRemote, tweak func(*usecase.CIConfig)) *ciHarness {
89+ t.Helper()
90+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci, o.remoteLister = tweak, remote.m })
91+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
92+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
93+ require.NoError(t, err)
94+ c.wt = h.workTree("main")
95+ return c
96+}
97+
98+// startJob pushes a workflow and returns the job given to a runner.
99+func (c *ciHarness) startJob(runner *fakeRunner) *pb.Job {
100+ c.t.Helper()
101+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0], "README": "hello\n"}, "")
102+ return runner.await()
103+}
104+
105+func (c *ciHarness) heartbeat(runner *fakeRunner, job *pb.Job) {
106+ c.t.Helper()
107+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
108+ require.NoError(c.t, err)
109+}
110+
111+// checkoutHeader is what actions/checkout sets in http.extraheader.
112+func checkoutHeader(token string) string {
113+ return "AUTHORIZATION: basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
114+}
115+
116+// clone clones url as actions/checkout does: the token in http.extraheader.
117+func (c *ciHarness) clone(token, url string, args ...string) (string, error) {
118+ dest := filepath.Join(c.t.TempDir(), "w")
119+ a := []string{}
120+ if token != "" {
121+ a = append(a, "-c", "http.extraheader="+checkoutHeader(token))
122+ }
123+ a = append(a, "clone", "-q")
124+ a = append(a, args...)
125+ a = append(a, url, dest)
126+ return c.git(c.t.TempDir(), a...)
127+}
128+
129+func (c *ciHarness) get(path, authorization string) (int, string, http.Header) {
130+ c.t.Helper()
131+ req, err := http.NewRequest(http.MethodGet, c.url(path), nil)
132+ require.NoError(c.t, err)
133+ if authorization != "" {
134+ req.Header.Set("Authorization", authorization)
135+ }
136+ resp, err := http.DefaultClient.Do(req)
137+ require.NoError(c.t, err)
138+ defer func() { _ = resp.Body.Close() }()
139+ b, err := io.ReadAll(resp.Body)
140+ require.NoError(c.t, err)
141+ return resp.StatusCode, string(b), resp.Header
142+}
143+
144+func basic(token string) string {
145+ return "Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token))
146+}
147+
148+// AC3: the job gets a repository token at its assignment, in the Job and not in
149+// the contexts; it clones its repository, public or private, over the URL
150+// github.repository gives, with and without .git, the way checkout does.
151+func TestJobTokenClonesTheRepositoryOfTheRun(t *testing.T) {
152+ c := newExecHarness(t, newFakeRemote(t), nil)
153+ runner := c.registerRunner("alpha", "ubuntu-latest")
154+ job := c.startJob(runner)
155+ tok := job.GetGithubToken()
156+ require.True(t, domain.IsRepoToken(tok), "ermitegh_ and 43 base64url characters")
157+ assert.NotEqual(t, job.GetJobToken(), tok)
158+ assert.NotContains(t, job.GetContexts(), tok, "the token does not sit in the contexts the runner stores")
159+ assert.NotContains(t, job.GetWorkflowYaml(), tok)
160+ c.heartbeat(runner, job)
161+
162+ // Private: checkout's clone, shallow, without .git.
163+ out, err := c.clone(tok, c.url("/owner/proj"), "--depth", "1")
164+ require.NoError(t, err, out)
165+ out, err = c.clone(tok, c.url("/owner/proj.git"))
166+ require.NoError(t, err, out)
167+ out, err = c.clone(tok, c.url("/proj.git"))
168+ require.NoError(t, err, "the plain URL works too: "+out)
169+ // Without a token a private repository asks for credentials.
170+ _, err = c.clone("", c.url("/owner/proj"))
171+ require.Error(t, err)
172+ // Another owner has no repositories.
173+ out, err = c.clone(tok, c.url("/someone/proj"))
174+ require.Error(t, err, out)
175+
176+ // The same token and the other schemes, on the wire.
177+ for _, h := range []string{basic(tok), "Bearer " + tok, "token " + tok} {
178+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", h)
179+ assert.Equal(t, http.StatusOK, status, h[:12])
180+ }
181+
182+ // Public: anyone clones, the token too.
183+ vis := domain.VisibilityPublic
184+ _, err = c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
185+ require.NoError(t, err)
186+ out, err = c.clone("", c.url("/owner/proj"))
187+ require.NoError(t, err, out)
188+ out, err = c.clone(tok, c.url("/owner/proj"))
189+ require.NoError(t, err, out)
190+}
191+
192+func (c *ciHarness) h(name string) domain.RepoName { return c.name(name) }
193+
194+// AC3: refused on another private repository, for push, and for the REST
195+// subset of another repository; never a stored token for the API.
196+func TestJobTokenReadsItsRepositoryOnly(t *testing.T) {
197+ c := newExecHarness(t, newFakeRemote(t), nil)
198+ c.seed("secret", domain.VisibilityPrivate, "a.txt")
199+ runner := c.registerRunner("alpha", "ubuntu-latest")
200+ job := c.startJob(runner)
201+ tok := job.GetGithubToken()
202+ c.heartbeat(runner, job)
203+
204+ out, err := c.clone(tok, c.url("/owner/secret"))
205+ require.Error(t, err)
206+ assert.Contains(t, out, "not found", "the other private repository does not show itself")
207+ status, _, _ := c.get("/owner/secret/info/refs?service=git-upload-pack", basic(tok))
208+ assert.Equal(t, http.StatusNotFound, status)
209+ status, _, _ = c.get("/-/github/api/repos/owner/secret", basic(tok))
210+ assert.Equal(t, http.StatusNotFound, status)
211+
212+ // Push: the receive-pack advertisement and the push itself are 403.
213+ status, _, _ = c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
214+ assert.Equal(t, http.StatusForbidden, status)
215+ c.mustGit(c.wt, "checkout", "-q", "-b", "x")
216+ out, err = c.git(c.wt, "-c", "http.extraheader="+checkoutHeader(tok), "push", c.url("/owner/proj"), "x:refs/heads/x")
217+ require.Error(t, err)
218+ assert.Contains(t, out, "403")
219+ assert.Empty(t, c.bareGit("proj", "branch", "--list", "x"), "nothing was pushed")
220+
221+ // The API takes it as the principal it is (repo:read on one repository): it
222+ // lists that repository and no other, and writes nothing.
223+ list, err := c.api(tok).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
224+ require.NoError(t, err)
225+ require.Len(t, list.Msg.GetRepos(), 1)
226+ assert.Equal(t, "proj", list.Msg.GetRepos()[0].GetName())
227+ _, err = c.api(tok).repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
228+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
229+ _, err = c.api(tok).token.CreateToken(c.ctx, connect.NewRequest(&pb.CreateTokenRequest{Label: "x", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}}))
230+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
231+ _, err = c.api(tok).token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
232+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
233+ _, err = c.api(tok).run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
234+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no ci:write")
235+
236+ // A made-up token of the right shape is refused, and so are wrong ones.
237+ for _, bad := range []string{"ermitegh_" + strings.Repeat("A", 43), "ermitegh_short", tok + "x"} {
238+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(bad))
239+ assert.Equal(t, http.StatusUnauthorized, status, bad)
240+ }
241+}
242+
243+// The token is not a stored token: ListTokens does not show it, token:admin
244+// does not manage it, and the principal it gives is read-only on one repository.
245+func TestJobTokenIsNotAStoredToken(t *testing.T) {
246+ c := newExecHarness(t, newFakeRemote(t), nil)
247+ runner := c.registerRunner("alpha", "ubuntu-latest")
248+ job := c.startJob(runner)
249+ tok := job.GetGithubToken()
250+
251+ before, err := c.root.token.ListTokens(c.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
252+ require.NoError(t, err)
253+ assert.Empty(t, before.Msg.GetTokens(), "no stored token exists")
254+
255+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
256+ require.NoError(t, err)
257+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, p.Scopes())
258+ assert.Equal(t, []domain.RepoName{c.h("proj")}, p.Repos())
259+ assert.False(t, p.ExpiresAt().IsZero())
260+ assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
261+ assert.False(t, p.IsRoot())
262+
263+ // Revoking by its id (the "job:" id the principal shows) finds nothing.
264+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: strings.TrimPrefix(p.TokenID(), "job:")}))
265+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
266+ _, err = c.root.token.RevokeToken(c.ctx, connect.NewRequest(&pb.RevokeTokenRequest{Id: p.TokenID()}))
267+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
268+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
269+ require.NoError(t, err, "still valid")
270+ require.NoError(t, c.forge.Auth.Revalidate(c.ctx, p))
271+}
272+
273+// AC3: refused after the job ends, however it ends.
274+func TestJobTokenEndsWithTheJob(t *testing.T) {
275+ cloneStatus := func(c *ciHarness, tok string) int {
276+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
277+ return status
278+ }
279+
280+ t.Run("CompleteJob", func(t *testing.T) {
281+ c := newExecHarness(t, newFakeRemote(t), nil)
282+ runner := c.registerRunner("alpha", "ubuntu-latest")
283+ job := c.startJob(runner)
284+ tok := job.GetGithubToken()
285+ p, err := c.forge.Auth.Authenticate(c.ctx, tok)
286+ require.NoError(t, err)
287+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
288+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
289+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
290+ assert.ErrorIs(t, c.forge.Auth.Revalidate(c.ctx, p), domain.ErrUnauthenticated, "a long operation stops")
291+ out, err := c.clone(tok, c.url("/owner/proj"))
292+ require.Error(t, err, out)
293+ })
294+
295+ t.Run("cancel", func(t *testing.T) {
296+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
297+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
298+ })
299+ runner := c.registerRunner("alpha", "ubuntu-latest")
300+ job := c.startJob(runner)
301+ c.heartbeat(runner, job)
302+ tok := job.GetGithubToken()
303+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
304+ _, err := c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
305+ require.NoError(t, err)
306+ // The runner is told to stop, and the job concludes when it answers or
307+ // when the grace ends: either way the token goes with it.
308+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
309+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, c.runs()[0].GetStatus())
310+ })
311+
312+ t.Run("lost runner", func(t *testing.T) {
313+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
314+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
315+ })
316+ runner := c.registerRunner("alpha", "ubuntu-latest")
317+ job := c.startJob(runner)
318+ c.heartbeat(runner, job)
319+ tok := job.GetGithubToken()
320+ assert.Equal(t, http.StatusOK, cloneStatus(c, tok))
321+ c.waitFor("the token is revoked", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
322+ assert.Contains(t, c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError(), "lost communication")
323+ })
324+
325+ t.Run("assignment not acknowledged", func(t *testing.T) {
326+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
327+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 200*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
328+ })
329+ runner := c.registerRunner("alpha", "ubuntu-latest")
330+ job := c.startJob(runner)
331+ tok := job.GetGithubToken()
332+ c.waitFor("the assignment is taken back", func() bool { return cloneStatus(c, tok) == http.StatusUnauthorized })
333+ again := runner.await()
334+ assert.NotEqual(t, tok, again.GetGithubToken(), "the next assignment has its own token")
335+ assert.Equal(t, http.StatusOK, cloneStatus(c, again.GetGithubToken()))
336+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok), "the old one stays revoked")
337+ })
338+
339+ t.Run("repository deleted", func(t *testing.T) {
340+ c := newExecHarness(t, newFakeRemote(t), nil)
341+ runner := c.registerRunner("alpha", "ubuntu-latest")
342+ job := c.startJob(runner)
343+ tok := job.GetGithubToken()
344+ _, err := c.forge.Auth.Authenticate(c.ctx, tok)
345+ require.NoError(t, err)
346+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("proj")))
347+ _, err = c.forge.Auth.Authenticate(c.ctx, tok)
348+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the repository, its runs and their tokens are gone")
349+ assert.Equal(t, http.StatusUnauthorized, cloneStatus(c, tok))
350+ })
351+
352+ t.Run("expiry", func(t *testing.T) {
353+ // The deadline of the job plus five minutes: a job of one minute.
354+ c := newExecHarness(t, newFakeRemote(t), nil)
355+ runner := c.registerRunner("alpha", "ubuntu-latest")
356+ c.push("main", map[string]string{".github/workflows/t.yml": "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: echo\n"}, "")
357+ job := runner.await()
358+ p, err := c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
359+ require.NoError(t, err)
360+ want := time.Now().Add(time.Minute + domain.CITimeoutSlack + domain.RepoTokenGrace)
361+ assert.WithinDuration(t, want, p.ExpiresAt(), 5*time.Second)
362+ })
363+}
364+
365+// The retry of a poll gets the token of the first call, and a job token of the
366+// protocol never doubles as the repository token.
367+func TestJobTokenSurvivesAPollRetry(t *testing.T) {
368+ c := newExecHarness(t, newFakeRemote(t), nil)
369+ runner := c.registerRunner("alpha", "ubuntu-latest")
370+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
371+ var first *pb.Job
372+ c.waitFor("a job", func() bool {
373+ j, err := runner.fetch("poll-1")
374+ require.NoError(t, err)
375+ first = j
376+ return j != nil
377+ })
378+ again, err := runner.fetch("poll-1")
379+ require.NoError(t, err)
380+ require.NotNil(t, again)
381+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
382+ assert.Equal(t, first.GetGithubToken(), again.GetGithubToken())
383+ status, _, _ := c.get("/owner/proj/info/refs?service=git-upload-pack", basic(first.GetJobToken()))
384+ assert.Equal(t, http.StatusUnauthorized, status, "the runner's job token reads nothing")
385+}
386+
387+// The REST subset: GitHub's shapes for what checkout and github-script call,
388+// GitHub's 404 for everything else.
389+func TestGitHubRESTSubset(t *testing.T) {
390+ c := newExecHarness(t, newFakeRemote(t), nil)
391+ runner := c.registerRunner("alpha", "ubuntu-latest")
392+ job := c.startJob(runner)
393+ tok := job.GetGithubToken()
394+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
395+ notFound := `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
396+
397+ // Private: the token reads it, anonymous sees nothing.
398+ status, body, hdr := c.get("/-/github/api/repos/owner/proj", basic(tok))
399+ require.Equal(t, http.StatusOK, status, body)
400+ assert.Contains(t, hdr.Get("Content-Type"), "application/json")
401+ var repo map[string]any
402+ require.NoError(t, json.Unmarshal([]byte(body), &repo))
403+ assert.Equal(t, "proj", repo["name"])
404+ assert.Equal(t, "owner/proj", repo["full_name"])
405+ assert.Equal(t, true, repo["private"])
406+ assert.Equal(t, "private", repo["visibility"])
407+ assert.Equal(t, "main", repo["default_branch"])
408+ assert.Equal(t, "http://example.test/owner/proj", repo["html_url"])
409+ assert.Equal(t, "http://example.test/owner/proj.git", repo["clone_url"])
410+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/proj", repo["url"])
411+ assert.NotZero(t, repo["id"])
412+ assert.NotEmpty(t, repo["node_id"])
413+ owner, _ := repo["owner"].(map[string]any)
414+ assert.Equal(t, "owner", owner["login"])
415+ assert.Equal(t, "User", owner["type"])
416+ assert.Equal(t, false, repo["fork"])
417+ assert.Equal(t, false, repo["archived"])
418+ assert.Equal(t, false, repo["has_issues"] == nil, "present")
419+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["created_at"], "REST dates are ISO 8601")
420+ assert.Regexp(t, `^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$`, repo["pushed_at"])
421+ perms, _ := repo["permissions"].(map[string]any)
422+ assert.Equal(t, true, perms["pull"])
423+ assert.Equal(t, false, perms["push"], "the job token cannot write")
424+ assert.Equal(t, false, perms["admin"])
425+ assert.NotContains(t, repo, "master_branch", "the push event has it, the REST object does not")
426+
427+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
428+ assert.Equal(t, http.StatusNotFound, status, "a private repository does not show itself")
429+ assert.JSONEq(t, notFound, body)
430+ status, _, _ = c.get("/-/github/api/repos/Owner/PROJ", basic(tok))
431+ assert.Equal(t, http.StatusOK, status, "names are case-insensitive as on GitHub")
432+ status, _, _ = c.get("/-/github/api/repos/other/proj", basic(tok))
433+ assert.Equal(t, http.StatusNotFound, status, "another owner")
434+
435+ status, body, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", basic(tok))
436+ assert.Equal(t, http.StatusOK, status)
437+ assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
438+ status, _, _ = c.get("/-/github/api/repos/owner/proj/hash-algorithm", "")
439+ assert.Equal(t, http.StatusNotFound, status)
440+
441+ // The tarball: a gzip tar with one top-level directory, the contents of the
442+ // commit, for a branch, a tag and a full id alike.
443+ c.mustGit(c.wt, "tag", "v1")
444+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
445+ for _, ref := range []string{"main", "v1", sha, ""} {
446+ status, body, hdr := c.get("/-/github/api/repos/owner/proj/tarball/"+ref, basic(tok))
447+ require.Equal(t, http.StatusOK, status, ref)
448+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
449+ files := untar(t, body)
450+ assert.Equal(t, "hello\n", files["owner-proj-"+sha[:7]+"/README"], ref)
451+ }
452+ status, body, _ = c.get("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
453+ assert.Equal(t, http.StatusNotFound, status)
454+ assert.JSONEq(t, notFound, body)
455+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
456+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
457+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
458+ assert.Equal(t, http.StatusOK, status, "any principal that reads the repository")
459+
460+ // A public repository answers anonymous callers.
461+ vis := domain.VisibilityPublic
462+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("proj"), ports.UpdateRepoParams{Visibility: &vis})
463+ require.NoError(t, err)
464+ status, body, _ = c.get("/-/github/api/repos/owner/proj", "")
465+ require.Equal(t, http.StatusOK, status)
466+ assert.Contains(t, body, `"visibility":"public"`)
467+ assert.NotContains(t, body, `"permissions"`, "an anonymous caller has none")
468+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "")
469+ assert.Equal(t, http.StatusOK, status)
470+
471+ // Everything else is GitHub's 404, whatever the method.
472+ for _, p := range []string{
473+ "/-/github/api", "/-/github/api/", "/-/github/api/user", "/-/github/api/graphql",
474+ "/-/github/api/repos/owner/proj/issues", "/-/github/api/repos/owner/proj/git/refs/heads/main",
475+ "/-/github/api/repos/owner/proj/zipball/main", "/-/github/api/repos/owner/proj/actions/runs",
476+ "/-/github/api/repos/owner", "/-/github/api/rate_limit", "/-/github/api/repos/owner/proj/contents/README",
477+ } {
478+ status, body, hdr := c.get(p, basic(tok))
479+ assert.Equal(t, http.StatusNotFound, status, p)
480+ assert.JSONEq(t, notFound, body, p)
481+ assert.Contains(t, hdr.Get("Content-Type"), "application/json", p)
482+ }
483+ req, err := http.NewRequest(http.MethodPost, c.url("/-/github/api/repos/owner/proj"), strings.NewReader("{}"))
484+ require.NoError(t, err)
485+ resp, err := http.DefaultClient.Do(req)
486+ require.NoError(t, err)
487+ _ = resp.Body.Close()
488+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
489+
490+ // A bad credential is refused as GitHub does.
491+ status, body, _ = c.get("/-/github/api/repos/owner/proj", basic("ermitegh_"+strings.Repeat("A", 43)))
492+ assert.Equal(t, http.StatusUnauthorized, status)
493+ assert.Contains(t, body, "Bad credentials")
494+}
495+
496+func untar(t *testing.T, gz string) map[string]string {
497+ t.Helper()
498+ zr, err := gzip.NewReader(strings.NewReader(gz))
499+ require.NoError(t, err)
500+ tr := tar.NewReader(zr)
501+ files := map[string]string{}
502+ for {
503+ h, err := tr.Next()
504+ if err == io.EOF {
505+ break
506+ }
507+ require.NoError(t, err)
508+ if h.Typeflag != tar.TypeReg {
509+ continue
510+ }
511+ var b bytes.Buffer
512+ _, err = io.Copy(&b, tr)
513+ require.NoError(t, err)
514+ files[h.Name] = b.String()
515+ }
516+ return files
517+}
518+
519+func (c *ciHarness) resolve(runner *fakeRunner, job *pb.Job, uses ...string) []*pb.ResolvedAction {
520+ c.t.Helper()
521+ res, err := c.resolveErr(runner, job, uses...)
522+ require.NoError(c.t, err)
523+ return res
524+}
525+
526+func (c *ciHarness) resolveErr(runner *fakeRunner, job *pb.Job, uses ...string) ([]*pb.ResolvedAction, error) {
527+ refs := make([]*pb.ActionRef, 0, len(uses))
528+ for _, u := range uses {
529+ refs = append(refs, &pb.ActionRef{Uses: u})
530+ }
531+ res, err := runner.jobAPI(job).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: job.GetJobId(), Refs: refs}))
532+ if err != nil {
533+ return nil, err
534+ }
535+ return res.Msg.GetActions(), nil
536+}
537+
538+const (
539+ shaMain = "1111111111111111111111111111111111111111"
540+ shaTag = "2222222222222222222222222222222222222222"
541+ shaTagObj = "3333333333333333333333333333333333333333"
542+ shaPeeled = "4444444444444444444444444444444444444444"
543+ shaOther = "5555555555555555555555555555555555555555"
544+)
545+
546+// AC2, AC4: github.com actions are resolved with ls-remote (tag before branch,
547+// the peeled commit of an annotated tag, a full id as it is), ermite
548+// repositories win, and nothing reaches the network but through the fake.
549+func TestResolveActionsFromGitHub(t *testing.T) {
550+ remote := newFakeRemote(t)
551+ remote.set("actions/checkout",
552+ ports.RemoteRef{Name: "refs/heads/main", ID: shaMain},
553+ ports.RemoteRef{Name: "refs/tags/v4", ID: shaTagObj, Peeled: shaPeeled},
554+ ports.RemoteRef{Name: "refs/tags/v3", ID: shaTag},
555+ ports.RemoteRef{Name: "refs/heads/v3", ID: shaOther}, // a tag and a branch of the same name: the tag
556+ )
557+ c := newExecHarness(t, remote, nil)
558+ runner := c.registerRunner("alpha", "ubuntu-latest")
559+ job := c.startJob(runner)
560+ c.heartbeat(runner, job)
561+
562+ got := c.resolve(runner, job,
563+ "actions/checkout@v4", "actions/checkout@main", "actions/checkout@v3", "Actions/Checkout/sub/dir@v4",
564+ "actions/checkout@"+strings.ToUpper(shaOther), "nobody/unknown-action@"+shaOther, "actions/checkout@v4",
565+ )
566+ require.Len(t, got, 7)
567+ assert.Equal(t, "actions/checkout@v4", got[0].GetRef())
568+ assert.Equal(t, "actions/checkout", got[0].GetNameWithOwner())
569+ assert.Equal(t, shaPeeled, got[0].GetResolvedSha(), "an annotated tag gives its commit")
570+ assert.Equal(t, "https://codeload.github.com/actions/checkout/tar.gz/"+shaPeeled, got[0].GetTarballUrl())
571+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got[0].GetSource())
572+ assert.Empty(t, got[0].GetToken(), "no token goes to github.com")
573+ assert.Empty(t, got[0].GetError())
574+ assert.Equal(t, shaMain, got[1].GetResolvedSha(), "a branch")
575+ assert.Equal(t, shaTag, got[2].GetResolvedSha(), "a tag wins over a branch of the same name")
576+ assert.Equal(t, "Actions/Checkout/sub/dir@v4", got[3].GetRef(), "the reference is returned as written")
577+ assert.Equal(t, "Actions/Checkout", got[3].GetNameWithOwner())
578+ assert.Equal(t, "sub/dir", got[3].GetPath())
579+ assert.Equal(t, shaPeeled, got[3].GetResolvedSha(), "owner and repository are case-insensitive")
580+ assert.Equal(t, strings.ToLower(shaOther), got[4].GetResolvedSha(), "a full commit id is not checked")
581+ assert.Equal(t, strings.ToLower(shaOther), got[5].GetResolvedSha(), "not even for a repository ls-remote does not know")
582+ assert.Equal(t, got[0].GetResolvedSha(), got[6].GetResolvedSha(), "duplicates are answered one by one")
583+ assert.Equal(t, 1, remote.count(), "one ls-remote per repository and call")
584+
585+ // GitHub's words for what cannot be resolved; the call itself succeeds.
586+ bad := c.resolve(runner, job, "actions/checkout@nope", "nobody/missing@v1", "noslash@v1", "a/b", "a/b@", "a/b@c@d")
587+ assert.Equal(t, "Unable to resolve action `actions/checkout@nope`, unable to find version `nope`", bad[0].GetError())
588+ assert.Equal(t, "Unable to resolve action `nobody/missing@v1`, repository not found", bad[1].GetError())
589+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'", bad[2].GetError())
590+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual 'a/b'", bad[3].GetError())
591+ assert.NotEmpty(t, bad[4].GetError())
592+ assert.NotEmpty(t, bad[5].GetError())
593+ for _, b := range bad {
594+ assert.Empty(t, b.GetResolvedSha(), b.GetRef())
595+ assert.Empty(t, b.GetTarballUrl(), b.GetRef())
596+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, b.GetSource(), b.GetRef())
597+ }
598+
599+ // github.com unreachable is an error of the call, not of one action.
600+ remote.err["flaky/action"] = domain.ErrUnavailable
601+ _, err := c.resolveErr(runner, job, "flaky/action@v1")
602+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err))
603+}
604+
605+func TestResolveActionsRejectsWhatTheRunnerHandles(t *testing.T) {
606+ c := newExecHarness(t, newFakeRemote(t), nil)
607+ runner := c.registerRunner("alpha", "ubuntu-latest")
608+ job := c.startJob(runner)
609+ c.heartbeat(runner, job)
610+ for _, u := range []string{"./local", "./.github/actions/x", "docker://alpine:3", "actions/checkout@v4"} {
611+ _, err := c.resolveErr(runner, job, "actions/checkout@"+shaMain, u)
612+ if u == "actions/checkout@v4" {
613+ assert.NoError(t, err)
614+ continue
615+ }
616+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), u)
617+ }
618+ many := make([]string, 101)
619+ for i := range many {
620+ many[i] = "a/b@" + shaMain
621+ }
622+ _, err := c.resolveErr(runner, job, many...)
623+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "at most 100")
624+ many = many[:100]
625+ got, err := c.resolveErr(runner, job, many...)
626+ require.NoError(t, err)
627+ assert.Len(t, got, 100)
628+ got, err = c.resolveErr(runner, job)
629+ require.NoError(t, err)
630+ assert.Empty(t, got)
631+}
632+
633+// AC2: another job's token, a repository token, an ended job and a bad token
634+// are refused.
635+func TestResolveActionsAuthentication(t *testing.T) {
636+ c := newExecHarness(t, newFakeRemote(t), nil)
637+ runner := c.registerRunner("alpha", "ubuntu-latest")
638+ job := c.startJob(runner)
639+ c.heartbeat(runner, job)
640+ call := func(secret, id string) error {
641+ _, err := c.api(secret).runner.ResolveActions(c.ctx, connect.NewRequest(&pb.ResolveActionsRequest{JobId: id, Refs: []*pb.ActionRef{{Uses: "a/b@" + shaMain}}}))
642+ return err
643+ }
644+ assert.NoError(t, call(job.GetJobToken(), job.GetJobId()))
645+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(job.GetGithubToken(), job.GetJobId())), "the repository token is not a job token")
646+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call(runner.secret, job.GetJobId())), "nor the runner token")
647+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("ermitejob_"+strings.Repeat("A", 43), job.GetJobId())))
648+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(call("", job.GetJobId())))
649+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(call(job.GetJobToken(), "7-0-0")), "a token of another job")
650+
651+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
652+ err := call(job.GetJobToken(), job.GetJobId())
653+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "an ended job")
654+ var ce *connect.Error
655+ require.ErrorAs(t, err, &ce)
656+ assert.NotEmpty(t, ce.Details(), "with a JobEnded detail")
657+}
658+
659+// AC4: an action of a repository of ermite wins over github.com, is downloaded
660+// with a per-job token bound to that commit of that repository, and the same ref gives the
661+// same commit to every job of the attempt (and a new one to a re-run).
662+func TestResolveActionsFromErmite(t *testing.T) {
663+ remote := newFakeRemote(t)
664+ remote.set("owner/myaction", ptrRef("refs/heads/main", shaMain)) // shadowed by ermite
665+ c := newExecHarness(t, remote, nil)
666+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
667+ tag := c.bareGit("myaction", "rev-parse", "HEAD")
668+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
669+ c.mustGit(c.repoDir("myaction"), "tag", "-a", "-m", "annotated", "v2")
670+ c.mustGit(c.repoDir("myaction"), "branch", "dev")
671+ c.seed("hidden", domain.VisibilityPrivate, "secret.txt")
672+
673+ runner := c.registerRunner("alpha", "ubuntu-latest")
674+ c.push("main", map[string]string{".github/workflows/m.yml": "on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"}, "")
675+ a, b := runner.await(), runner.await()
676+ c.heartbeat(runner, a)
677+ c.heartbeat(runner, b)
678+
679+ got := c.resolve(runner, a, "owner/myaction@v1", "owner/MyAction/sub@v2", "owner/myaction@dev", "owner/myaction@"+tag, "owner/myaction@gone", "owner/nothere@v1")
680+ require.Len(t, got, 6)
681+ for i := range 4 {
682+ assert.Empty(t, got[i].GetError(), got[i].GetRef())
683+ assert.Equal(t, tag, got[i].GetResolvedSha(), got[i].GetRef())
684+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[i].GetSource(), got[i].GetRef())
685+ assert.True(t, domain.IsRepoToken(got[i].GetToken()), got[i].GetRef())
686+ assert.Equal(t, "http://example.test/-/github/api/repos/owner/myaction/tarball/"+tag, got[i].GetTarballUrl())
687+ }
688+ assert.Equal(t, "owner/myaction", got[0].GetNameWithOwner())
689+ assert.Equal(t, "sub", got[1].GetPath())
690+ assert.Equal(t, got[0].GetToken(), got[2].GetToken(), "one token per commit and job")
691+ assert.Contains(t, got[4].GetError(), "unable to find version `gone`")
692+ assert.False(t, remote.asked("owner/myaction"), "ermite is asked first, github.com not at all")
693+ assert.Contains(t, got[5].GetError(), "Unable to resolve action `owner/nothere@v1`")
694+ assert.True(t, remote.asked("owner/nothere"), "an owner repository that ermite lacks falls through to github.com")
695+
696+ // The archive token downloads that repository, whatever the private
697+ // repository of the run's token reaches, and nothing else.
698+ arch := got[0].GetToken()
699+ status, body, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
700+ require.Equal(t, http.StatusOK, status)
701+ assert.Equal(t, "content of dist/index.js\n", untar(t, body)["owner-myaction-"+tag[:7]+"/dist/index.js"])
702+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-upload-pack", basic(arch))
703+ assert.Equal(t, http.StatusNotFound, status, "it does not clone: the archive of one commit is all it gets")
704+ status, _, _ = c.get("/owner/myaction/info/refs?service=git-receive-pack", basic(arch))
705+ assert.Equal(t, http.StatusNotFound, status, "and never writes")
706+ status, _, _ = c.get("/owner/hidden/info/refs?service=git-upload-pack", basic(arch))
707+ assert.Equal(t, http.StatusNotFound, status, "another private repository")
708+ status, _, _ = c.get("/owner/proj/info/refs?service=git-upload-pack", basic(arch))
709+ assert.Equal(t, http.StatusNotFound, status, "not even the repository of the run")
710+ status, _, _ = c.get("/-/github/api/repos/owner/hidden", basic(arch))
711+ assert.Equal(t, http.StatusNotFound, status)
712+ // The job's own token does not read the action repository: this is why the
713+ // archive has a token of its own.
714+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(a.GetGithubToken()))
715+ assert.Equal(t, http.StatusNotFound, status)
716+
717+ // Per job: the same commit, another token.
718+ other := c.resolve(runner, b, "owner/myaction@v1")
719+ assert.Equal(t, tag, other[0].GetResolvedSha())
720+ assert.NotEqual(t, arch, other[0].GetToken(), "tokens are not shared between jobs")
721+
722+ // Stable for the attempt: the branch moves, the answer does not.
723+ c.mustGit(c.wt, "checkout", "-q", "-b", "scratch")
724+ c.writeFile(c.wt, "later.txt", []byte("later"))
725+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/myaction.git"), "scratch:refs/heads/dev", "-f")
726+ moved := c.bareGit("myaction", "rev-parse", "dev")
727+ require.NotEqual(t, tag, moved)
728+ again := c.resolve(runner, b, "owner/myaction@dev")
729+ assert.Equal(t, tag, again[0].GetResolvedSha(), "the first resolution of the attempt stands")
730+
731+ // The token goes with its job.
732+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
733+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(arch))
734+ assert.Equal(t, http.StatusUnauthorized, status, "revoked with the job")
735+ status, _, _ = c.get("/-/github/api/repos/owner/myaction/tarball/"+tag, basic(other[0].GetToken()))
736+ assert.Equal(t, http.StatusOK, status, "the other job's token lives on")
737+}
738+
739+func ptrRef(name, id string) ports.RemoteRef { return ports.RemoteRef{Name: name, ID: id} }
740+
741+// A re-run resolves again, and github.com is asked once for a repository in
742+// each attempt.
743+func TestResolveActionsAreCachedPerAttempt(t *testing.T) {
744+ remote := newFakeRemote(t)
745+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaMain))
746+ c := newExecHarness(t, remote, nil)
747+ runner := c.registerRunner("alpha", "ubuntu-latest")
748+ job := c.startJob(runner)
749+ c.heartbeat(runner, job)
750+
751+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha())
752+ remote.set("actions/setup-go", ptrRef("refs/tags/v5", shaOther))
753+ assert.Equal(t, shaMain, c.resolve(runner, job, "actions/setup-go@v5")[0].GetResolvedSha(), "stable within the attempt")
754+ assert.Equal(t, 1, remote.count(), "the second call is answered from the attempt's resolution")
755+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
756+
757+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: c.runs()[0].GetId()}))
758+ require.NoError(t, err)
759+ job2 := runner.await()
760+ assert.Equal(t, int32(2), job2.GetAttempt())
761+ c.heartbeat(runner, job2)
762+ assert.Equal(t, shaOther, c.resolve(runner, job2, "actions/setup-go@v5")[0].GetResolvedSha(), "a re-run resolves again")
763+ assert.NotEqual(t, job.GetGithubToken(), job2.GetGithubToken())
764+}
765+
766+// AC2: no secret reaches the logs of the server: the repository token, the
767+// archive tokens and the job token, whatever happened with them.
768+func TestRepositoryTokensAreNeverLogged(t *testing.T) {
769+ remote := newFakeRemote(t)
770+ c := newExecHarness(t, remote, nil)
771+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
772+ runner := c.registerRunner("alpha", "ubuntu-latest")
773+ job := c.startJob(runner)
774+ c.heartbeat(runner, job)
775+ tok := job.GetGithubToken()
776+
777+ arch := c.resolve(runner, job, "owner/myaction@main")[0]
778+ require.NotEmpty(t, arch.GetToken())
779+ _, err := c.clone(tok, c.url("/owner/proj"))
780+ require.NoError(t, err)
781+ c.get("/-/github/api/repos/owner/proj", basic(tok))
782+ c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+tok)
783+ c.get("/owner/proj/info/refs?service=git-receive-pack", basic(tok))
784+ c.get("/owner/secret/info/refs?service=git-upload-pack", basic("ermitegh_"+strings.Repeat("Z", 43))) // a wrong one
785+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
786+ c.get("/owner/proj/info/refs?service=git-upload-pack", basic(tok))
787+
788+ logs := c.logs.String()
789+ require.NotEmpty(t, logs)
790+ for _, secret := range []string{tok, arch.GetToken(), job.GetJobToken(), base64.StdEncoding.EncodeToString([]byte("x-access-token:" + tok))} {
791+ assert.NotContains(t, logs, secret)
792+ }
793+ assert.NotContains(t, logs, strings.Repeat("Z", 43))
794+}
795+
796+// pushAs pushes a commit to branch of the repository with the token and returns
797+// its sha.
798+func (c *ciHarness) pushAs(token, branch string, files map[string]string) string {
799+ c.t.Helper()
800+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
801+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
802+ }
803+ for name, content := range files {
804+ c.writeFile(c.wt, name, []byte(content))
805+ }
806+ c.mustGit(c.wt, "push", "-q", c.urlAuth(token, "/"+c.repo+".git"), branch)
807+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
808+}
809+
810+const oneJobWorkflow = ".github/workflows/ci.yml"
811+
812+func oneJobYAML() string { return strings.Split(ciWorkflow, " test:")[0] }
813+
814+// GO-001a: an action of another repository of ermite is resolved on behalf of
815+// the pusher of the run, with the rule of the forge: a pusher that cannot read
816+// the repository gets "repository not found", exactly as for one that does not
817+// exist, and the check is made again at each answer with the live token.
818+func TestResolveActionsOfAnotherRepositoryNeedsAPusherWhoReadsIt(t *testing.T) {
819+ notFound := "Unable to resolve action `owner/private-action@v1`, repository not found"
820+ missing := "Unable to resolve action `owner/does-not-exist@v1`, repository not found"
821+
822+ type scenario struct {
823+ name string
824+ // token makes the stored token that pushes the run, nil for the root token.
825+ token func(c *ciHarness) (secret, id string)
826+ reads bool
827+ }
828+ scenarios := []scenario{
829+ {"root", nil, true},
830+ {"a token without the repository in its list", func(c *ciHarness) (string, string) {
831+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
832+ }, false},
833+ {"a token without repo:read", func(c *ciHarness) (string, string) {
834+ return c.branchToken([]domain.Scope{domain.ScopeRepoWrite}, nil)
835+ }, false},
836+ {"a token that lists both repositories", func(c *ciHarness) (string, string) {
837+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj", "private-action")
838+ }, true},
839+ {"a token for every repository", func(c *ciHarness) (string, string) {
840+ return c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
841+ }, true},
842+ }
843+ for _, sc := range scenarios {
844+ t.Run(sc.name, func(t *testing.T) {
845+ c := newExecHarness(t, newFakeRemote(t), nil)
846+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
847+ secret := adminToken
848+ if sc.token != nil {
849+ secret, _ = sc.token(c)
850+ }
851+ runner := c.registerRunner("alpha", "ubuntu-latest")
852+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
853+ job := runner.await()
854+ c.heartbeat(runner, job)
855+
856+ got := c.resolve(runner, job, "owner/private-action@v1", "owner/does-not-exist@v1")
857+ assert.Equal(t, missing, got[1].GetError())
858+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
859+ got = c.resolve(runner, job, "owner/private-action@v1")
860+ if sc.reads {
861+ require.Empty(t, got[0].GetError())
862+ assert.True(t, domain.IsRepoToken(got[0].GetToken()))
863+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
864+ } else {
865+ assert.Equal(t, notFound, got[0].GetError(), "as GitHub says for a repository that is not there")
866+ assert.Empty(t, got[0].GetToken())
867+ assert.Empty(t, got[0].GetResolvedSha())
868+ assert.Empty(t, got[0].GetTarballUrl())
869+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_UNSPECIFIED, got[0].GetSource())
870+ }
871+ // A wrong ref of a repository that cannot be read leaks nothing either:
872+ // the same text as for a missing repository, not "unable to find version".
873+ bad := c.resolve(runner, job, "owner/private-action@nope")
874+ if sc.reads {
875+ assert.Contains(t, bad[0].GetError(), "unable to find version `nope`")
876+ } else {
877+ assert.Equal(t, "Unable to resolve action `owner/private-action@nope`, repository not found", bad[0].GetError())
878+ }
879+ })
880+ }
881+
882+ t.Run("the token is looked up again, so revoking it stops the answers", func(t *testing.T) {
883+ c := newExecHarness(t, newFakeRemote(t), nil)
884+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
885+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
886+ secret, id := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
887+ runner := c.registerRunner("alpha", "ubuntu-latest")
888+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
889+ job := runner.await()
890+ c.heartbeat(runner, job)
891+
892+ first := c.resolve(runner, job, "owner/private-action@v1")
893+ require.Empty(t, first[0].GetError())
894+
895+ require.NoError(t, c.forge.Tokens.Revoke(c.ctx, domain.Root(), id))
896+ again := c.resolve(runner, job, "owner/private-action@v1")
897+ assert.Equal(t, notFound, again[0].GetError(), "the commit is resolved already, the pusher no longer reads")
898+ assert.Empty(t, again[0].GetToken())
899+ })
900+
901+ t.Run("a repository made private since", func(t *testing.T) {
902+ c := newExecHarness(t, newFakeRemote(t), nil)
903+ c.seed("private-action", domain.VisibilityPublic, "action.yml")
904+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
905+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
906+ runner := c.registerRunner("alpha", "ubuntu-latest")
907+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
908+ job := runner.await()
909+ c.heartbeat(runner, job)
910+
911+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError(), "public: anyone reads it")
912+ priv := domain.VisibilityPrivate
913+ _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.h("private-action"), ports.UpdateRepoParams{Visibility: &priv})
914+ require.NoError(t, err)
915+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
916+ })
917+
918+ t.Run("an expired token", func(t *testing.T) {
919+ c := newExecHarness(t, newFakeRemote(t), nil)
920+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
921+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
922+ exp := time.Now().Add(1500 * time.Millisecond)
923+ got, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
924+ Label: "short", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, ExpiresAt: exp,
925+ })
926+ require.NoError(t, err)
927+ runner := c.registerRunner("alpha", "ubuntu-latest")
928+ c.pushAs(got.Secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
929+ job := runner.await()
930+ c.heartbeat(runner, job)
931+ assert.Empty(t, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
932+ time.Sleep(time.Until(exp) + 200*time.Millisecond)
933+ assert.Equal(t, notFound, c.resolve(runner, job, "owner/private-action@v1")[0].GetError())
934+ })
935+
936+ t.Run("the repository of the run is always readable by its job", func(t *testing.T) {
937+ c := newExecHarness(t, newFakeRemote(t), nil)
938+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
939+ runner := c.registerRunner("alpha", "ubuntu-latest")
940+ c.pushAs(secret, "main", map[string]string{oneJobWorkflow: oneJobYAML()})
941+ job := runner.await()
942+ c.heartbeat(runner, job)
943+ got := c.resolve(runner, job, "owner/proj@main")
944+ require.Empty(t, got[0].GetError())
945+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
946+ })
947+}
948+
949+// GO-001b: the archive token is bound to (repository, commit). Only the archive
950+// endpoint reads it, only for that commit; it is refused by Git over HTTP, the
951+// repository and hash-algorithm endpoints, and for any other commit, ref or
952+// repository, public or not.
953+func TestArchiveTokenIsBoundToItsCommit(t *testing.T) {
954+ c := newExecHarness(t, newFakeRemote(t), nil)
955+ c.seed("myaction", domain.VisibilityPrivate, "action.yml", "dist/index.js")
956+ first := c.bareGit("myaction", "rev-parse", "HEAD")
957+ c.mustGit(c.repoDir("myaction"), "tag", "v1")
958+ // A second commit, to have a sha the token was not issued for.
959+ other := c.workTree("main", "action.yml", "dist/index.js", "more.js")
960+ c.mustGit(other, "push", "-q", "-f", c.urlAuth(adminToken, "/myaction.git"), "main")
961+ second := c.bareGit("myaction", "rev-parse", "HEAD")
962+ require.NotEqual(t, first, second)
963+ c.seed("openrepo", domain.VisibilityPublic, "a.txt")
964+ openSha := c.bareGit("openrepo", "rev-parse", "HEAD")
965+
966+ runner := c.registerRunner("alpha", "ubuntu-latest")
967+ job := c.startJob(runner)
968+ c.heartbeat(runner, job)
969+ got := c.resolve(runner, job, "owner/myaction@v1", "owner/myaction@"+second)
970+ require.Empty(t, got[0].GetError())
971+ require.Empty(t, got[1].GetError())
972+ assert.Equal(t, first, got[0].GetResolvedSha())
973+ assert.Equal(t, second, got[1].GetResolvedSha())
974+ assert.NotEqual(t, got[0].GetToken(), got[1].GetToken(), "one token per commit")
975+ arch := got[0].GetToken()
976+ tarball := func(repo, ref, tok string) int {
977+ status, _, _ := c.get("/-/github/api/repos/owner/"+repo+"/tarball/"+ref, basic(tok))
978+ return status
979+ }
980+
981+ // What it is for: that commit, named by its sha or by a ref that resolves to it.
982+ assert.Equal(t, http.StatusOK, tarball("myaction", first, arch))
983+ assert.Equal(t, http.StatusOK, tarball("myaction", "v1", arch), "any ref that resolves to the commit")
984+ assert.Equal(t, http.StatusOK, tarball("MyAction", first, arch), "names are case-insensitive")
985+ status, _, hdr := c.head("/-/github/api/repos/owner/myaction/tarball/"+first, basic(arch))
986+ assert.Equal(t, http.StatusOK, status)
987+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-myaction-"+first[:7])
988+
989+ // Nothing else.
990+ assert.Equal(t, http.StatusNotFound, tarball("myaction", second, arch), "another commit of the repository")
991+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "main", arch), "a ref that now points elsewhere")
992+ assert.Equal(t, http.StatusNotFound, tarball("myaction", "", arch), "the default branch")
993+ assert.Equal(t, http.StatusNotFound, tarball("proj", "main", arch), "the repository of the run")
994+ assert.Equal(t, http.StatusNotFound, tarball("openrepo", openSha, arch), "not even a public repository")
995+ assert.Equal(t, http.StatusUnauthorized, tarball("myaction", first, arch+"x"), "a token that is not one")
996+ status, _, _ = c.head("/-/github/api/repos/owner/myaction/tarball/"+second, basic(arch))
997+ assert.Equal(t, http.StatusNotFound, status, "HEAD is checked like GET")
998+ for _, p := range []string{
999+ "/owner/myaction/info/refs?service=git-upload-pack",
1000+ "/owner/myaction.git/info/refs?service=git-upload-pack",
1001+ "/myaction/info/refs?service=git-upload-pack",
1002+ "/owner/myaction/info/refs?service=git-receive-pack",
1003+ "/owner/openrepo/info/refs?service=git-upload-pack",
1004+ "/-/github/api/repos/owner/myaction",
1005+ "/-/github/api/repos/owner/myaction/hash-algorithm",
1006+ "/-/github/api/repos/owner/openrepo",
1007+ "/-/github/api/repos/owner/openrepo/hash-algorithm",
1008+ } {
1009+ status, _, _ := c.get(p, basic(arch))
1010+ assert.Contains(t, []int{http.StatusNotFound, http.StatusUnauthorized}, status, p)
1011+ }
1012+ // It lists no repository over the API either, and holds no scope there.
1013+ list, err := c.api(arch).repo.ListRepos(c.ctx, connect.NewRequest(&pb.ListReposRequest{}))
1014+ require.NoError(t, err)
1015+ names := []string{}
1016+ for _, r := range list.Msg.GetRepos() {
1017+ names = append(names, r.GetName())
1018+ }
1019+ assert.Empty(t, names, "it reads nothing, not even the public repositories an anonymous caller reads")
1020+ _, err = c.api(arch).repo.GetRepo(c.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "myaction"}))
1021+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
1022+
1023+ // The job's own token is the opposite: it reads its repository whole, and
1024+ // the archive of nothing else.
1025+ own := job.GetGithubToken()
1026+ assert.Equal(t, http.StatusNotFound, tarball("myaction", first, own))
1027+ assert.Equal(t, http.StatusOK, tarball("proj", "main", own))
1028+
1029+ // Principals.
1030+ p, err := c.forge.Auth.Authenticate(c.ctx, arch)
1031+ require.NoError(t, err)
1032+ repo, sha, ok := p.ArchiveGrant()
1033+ require.True(t, ok)
1034+ assert.Equal(t, "myaction", repo.String())
1035+ assert.Equal(t, first, sha)
1036+ assert.Empty(t, p.Scopes())
1037+ assert.False(t, p.HasScope(domain.ScopeRepoRead))
1038+ assert.False(t, p.Allows(domain.ScopeRepoRead, repo))
1039+ assert.False(t, p.IsRoot())
1040+ ownP, err := c.forge.Auth.Authenticate(c.ctx, own)
1041+ require.NoError(t, err)
1042+ _, _, ok = ownP.ArchiveGrant()
1043+ assert.False(t, ok)
1044+}
1045+
1046+func (c *ciHarness) head(path, authorization string) (int, string, http.Header) {
1047+ c.t.Helper()
1048+ req, err := http.NewRequest(http.MethodHead, c.url(path), nil)
1049+ require.NoError(c.t, err)
1050+ if authorization != "" {
1051+ req.Header.Set("Authorization", authorization)
1052+ }
1053+ resp, err := http.DefaultClient.Do(req)
1054+ require.NoError(c.t, err)
1055+ defer func() { _ = resp.Body.Close() }()
1056+ b, _ := io.ReadAll(resp.Body)
1057+ return resp.StatusCode, string(b), resp.Header
1058+}
1059+
1060+// GO-002: a HEAD of the tarball answers the headers of the archive of the ref it
1061+// resolves, runs no git archive, and 404s a ref that does not resolve.
1062+func TestTarballHeadRunsNoArchive(t *testing.T) {
1063+ c := newExecHarness(t, newFakeRemote(t), nil)
1064+ runner := c.registerRunner("alpha", "ubuntu-latest")
1065+ job := c.startJob(runner)
1066+ tok := job.GetGithubToken()
1067+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
1068+
1069+ status, body, hdr := c.head("/-/github/api/repos/owner/proj/tarball/main", basic(tok))
1070+ assert.Equal(t, http.StatusOK, status)
1071+ assert.Empty(t, body, "no body")
1072+ assert.Contains(t, hdr.Get("Content-Disposition"), "owner-proj-"+sha[:7]+".tar.gz")
1073+ assert.Contains(t, hdr.Get("Content-Type"), "gzip")
1074+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/nope", basic(tok))
1075+ assert.Equal(t, http.StatusNotFound, status)
1076+ status, _, _ = c.head("/-/github/api/repos/owner/proj/tarball/main", "")
1077+ assert.Equal(t, http.StatusNotFound, status, "private and anonymous")
1078+ status, _, _ = c.head("/-/github/api/repos/owner/other/tarball/main", basic(tok))
1079+ assert.Equal(t, http.StatusNotFound, status)
1080+}
1081+
1082+// GO-003: one client cannot take every archive slot, and the slots are given
1083+// back.
1084+func TestArchiveDownloadsAreLimitedPerClient(t *testing.T) {
1085+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.archivesPerClient = 1 })
1086+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
1087+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
1088+ require.NoError(t, err)
1089+ c.wt = h.workTree("main")
1090+ c.writeFile(c.wt, "big.bin", bytes.Repeat([]byte("0123456789abcdef"), 1<<20)) // 16 MiB, compressible
1091+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
1092+
1093+ // Start a download and read none of it: the response is held open.
1094+ req, err := http.NewRequest(http.MethodGet, c.url("/-/github/api/repos/owner/proj/tarball/main"), nil)
1095+ require.NoError(t, err)
1096+ req.Header.Set("Authorization", "Bearer "+adminToken)
1097+ resp, err := http.DefaultClient.Do(req)
1098+ require.NoError(t, err)
1099+
1100+ var status int
1101+ c.waitFor("the second download is refused", func() bool {
1102+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1103+ return status == http.StatusTooManyRequests
1104+ })
1105+ assert.Equal(t, http.StatusTooManyRequests, status)
1106+ hs, _, _ := c.head("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1107+ assert.Equal(t, http.StatusOK, hs, "a HEAD takes no slot")
1108+ metaStatus, _, _ := c.get("/-/github/api/repos/owner/proj", "Bearer "+adminToken)
1109+ assert.Equal(t, http.StatusOK, metaStatus, "nor does the repository endpoint")
1110+
1111+ // Reading it to the end gives the slot back.
1112+ _, err = io.Copy(io.Discard, resp.Body)
1113+ require.NoError(t, err)
1114+ _ = resp.Body.Close()
1115+ c.waitFor("the slot is given back", func() bool {
1116+ status, _, _ = c.get("/-/github/api/repos/owner/proj/tarball/main", "Bearer "+adminToken)
1117+ return status == http.StatusOK
1118+ })
1119+}
1120+
1121+// GO-004: owner and repository are validated with GitHub's charset for every
1122+// source; nothing but a name reaches a URL, a path or a command.
1123+func TestResolveActionsRejectsMalformedNames(t *testing.T) {
1124+ remote := newFakeRemote(t)
1125+ c := newExecHarness(t, remote, nil)
1126+ runner := c.registerRunner("alpha", "ubuntu-latest")
1127+ job := c.startJob(runner)
1128+ c.heartbeat(runner, job)
1129+
1130+ bad := []string{
1131+ "../..@" + shaMain, ".../x@" + shaMain, "../x@" + shaMain, "..//x@" + shaMain, "a/..@" + shaMain, "../b@" + shaMain,
1132+ "a/b%2e@" + shaMain, "a%2fb/c@" + shaMain, "a/b?x=1@" + shaMain, "a/b#f@" + shaMain, "a b/c@" + shaMain,
1133+ "a/b c@" + shaMain, "a:b/c@" + shaMain, "a/b\n@" + shaMain, "a@b/c@v1", "a/b\x00@v1", "a/...@v1", "a/.@v1",
1134+ "é/b@v1", strings.Repeat("a", 101) + "/b@v1", "a/" + strings.Repeat("b", 101) + "@v1", "/@v1", "@@",
1135+ }
1136+ got := c.resolve(runner, job, bad...)
1137+ require.Len(t, got, len(bad))
1138+ for i, g := range got {
1139+ assert.Equal(t, "Expected format {org}/{repo}[/path]@ref. Actual '"+bad[i]+"'", g.GetError(), "%q", bad[i])
1140+ assert.Empty(t, g.GetTarballUrl(), "%q", bad[i])
1141+ assert.Empty(t, g.GetToken(), "%q", bad[i])
1142+ }
1143+ assert.Zero(t, remote.count(), "no malformed name reached ls-remote")
1144+
1145+ // Names GitHub accepts still do.
1146+ remote.set("a.b/c_d-e", ptrRef("refs/tags/v1", shaMain))
1147+ ok := c.resolve(runner, job, "a.b/c_d-e@v1", "Owner/repo/sub@"+shaMain)
1148+ assert.Empty(t, ok[0].GetError())
1149+ assert.Empty(t, ok[1].GetError())
1150+}
1151+
1152+// A token revoked, a job lost, the server restarted: the archive token goes with
1153+// the job, and it was never written anywhere but hashed.
1154+func TestArchiveTokenIsRevokedWithTheJobAfterARestart(t *testing.T) {
1155+ c := newExecHarness(t, newFakeRemote(t), func(cfg *usecase.CIConfig) {
1156+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 600*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
1157+ })
1158+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1159+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1160+ runner := c.registerRunner("alpha", "ubuntu-latest")
1161+ job := c.startJob(runner)
1162+ c.heartbeat(runner, job)
1163+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1164+ require.NotEmpty(t, arch)
1165+ url := "/-/github/api/repos/owner/myaction/tarball/" + sha
1166+ status, _, _ := c.get(url, basic(arch))
1167+ require.Equal(t, http.StatusOK, status)
1168+
1169+ // Restart: the tokens are in the store, the secrets in memory are gone.
1170+ cfg, hookExe := c.forge.cfg, binary(t)
1171+ c.srv.Close()
1172+ c.forge.Close(c.ctx)
1173+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, remoteLister: newFakeRemote(t).m, ci: func(cc *usecase.CIConfig) {
1174+ cc.HeartbeatDeadline, cc.Tick = 600*time.Millisecond, 20*time.Millisecond
1175+ }})
1176+ require.NoError(t, err)
1177+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
1178+ srv := httptest.NewServer(f2.server.Handler())
1179+ t.Cleanup(srv.Close)
1180+
1181+ get := func(tok string) int {
1182+ req, _ := http.NewRequest(http.MethodGet, srv.URL+url, nil)
1183+ req.Header.Set("Authorization", basic(tok))
1184+ resp, err := http.DefaultClient.Do(req)
1185+ require.NoError(t, err)
1186+ _ = resp.Body.Close()
1187+ return resp.StatusCode
1188+ }
1189+ assert.Equal(t, http.StatusOK, get(arch), "the token survives a restart")
1190+
1191+ // The runner is gone: the heartbeat deadline of the new server passes, the
1192+ // job is lost and its tokens with it.
1193+ require.Eventually(t, func() bool { return get(arch) == http.StatusUnauthorized }, 10*time.Second, 50*time.Millisecond)
1194+ assert.Equal(t, http.StatusUnauthorized, get(job.GetGithubToken()), "the job's own token as well")
1195+ api := newClients(srv.URL+"/-/api", adminToken)
1196+ res, err := api.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: job.GetRunId()}))
1197+ require.NoError(t, err)
1198+ assert.Contains(t, res.Msg.GetJobs()[0].GetError(), "lost communication")
1199+}
1200+
1201+// GO-006: the archive tokens that read a repository go when it is deleted, and
1202+// the run that held one goes on without a stale index.
1203+func TestArchiveTokensGoWithTheRepositoryTheyRead(t *testing.T) {
1204+ c := newExecHarness(t, newFakeRemote(t), nil)
1205+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1206+ sha := c.bareGit("myaction", "rev-parse", "HEAD")
1207+ runner := c.registerRunner("alpha", "ubuntu-latest")
1208+ job := c.startJob(runner)
1209+ c.heartbeat(runner, job)
1210+ arch := c.resolve(runner, job, "owner/myaction@"+sha)[0].GetToken()
1211+ _, err := c.forge.Auth.Authenticate(c.ctx, arch)
1212+ require.NoError(t, err)
1213+
1214+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("myaction")))
1215+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1216+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "deleted with the repository it reads")
1217+ _, err = c.forge.Auth.Authenticate(c.ctx, job.GetGithubToken())
1218+ assert.NoError(t, err, "the job's own token is the run's repository's, not touched")
1219+
1220+ // A repository created again under the name does not inherit the token.
1221+ c.seed("myaction", domain.VisibilityPrivate, "action.yml")
1222+ _, err = c.forge.Auth.Authenticate(c.ctx, arch)
1223+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
1224+ status, _, _ := c.get("/-/github/api/repos/owner/myaction/tarball/"+sha, basic(arch))
1225+ assert.Equal(t, http.StatusUnauthorized, status)
1226+
1227+ // The job ending afterwards revokes what is left without an error.
1228+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
1229+}
1230+
1231+// GO-005: ls-remote runs are bounded server-wide and a call has a deadline.
1232+func TestResolveActionsBoundsRemoteWork(t *testing.T) {
1233+ t.Run("concurrency", func(t *testing.T) {
1234+ // The jobs of a run resolve at the same time: five calls, two slots.
1235+ var running, peak atomic.Int32
1236+ release := make(chan struct{})
1237+ remote := newFakeRemote(t)
1238+ remote.m.ExpectedCalls = nil
1239+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1240+ func(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
1241+ n := running.Add(1)
1242+ defer running.Add(-1)
1243+ for {
1244+ p := peak.Load()
1245+ if n <= p || peak.CompareAndSwap(p, n) {
1246+ break
1247+ }
1248+ }
1249+ select {
1250+ case <-release:
1251+ case <-ctx.Done():
1252+ return nil, ctx.Err()
1253+ }
1254+ return []ports.RemoteRef{{Name: "refs/tags/v1", ID: shaMain}}, nil
1255+ }).Maybe()
1256+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.RemoteConcurrency = 2 })
1257+ runner := c.registerRunner("alpha", "ubuntu-latest")
1258+ job := c.startJob(runner)
1259+ c.heartbeat(runner, job)
1260+
1261+ var wg sync.WaitGroup
1262+ results := make([][]*pb.ResolvedAction, 5)
1263+ errs := make([]error, 5)
1264+ for i, repo := range []string{"one", "two", "three", "four", "five"} {
1265+ wg.Add(1)
1266+ go func() {
1267+ defer wg.Done()
1268+ results[i], errs[i] = c.resolveErr(runner, job, "a/"+repo+"@v1")
1269+ }()
1270+ }
1271+ require.Eventually(t, func() bool { return running.Load() == 2 }, 5*time.Second, 10*time.Millisecond)
1272+ time.Sleep(150 * time.Millisecond)
1273+ assert.EqualValues(t, 2, running.Load(), "the others wait for a slot")
1274+ close(release)
1275+ wg.Wait()
1276+ assert.LessOrEqual(t, peak.Load(), int32(2))
1277+ for i := range results {
1278+ require.NoError(t, errs[i])
1279+ assert.Empty(t, results[i][0].GetError(), results[i][0].GetRef())
1280+ }
1281+ })
1282+
1283+ t.Run("deadline", func(t *testing.T) {
1284+ remote := newFakeRemote(t)
1285+ remote.m.ExpectedCalls = nil
1286+ remote.m.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1287+ func(ctx context.Context, _, _ string) ([]ports.RemoteRef, error) {
1288+ <-ctx.Done()
1289+ return nil, ctx.Err()
1290+ }).Maybe()
1291+ c := newExecHarness(t, remote, func(cfg *usecase.CIConfig) { cfg.ResolveTimeout = 300 * time.Millisecond })
1292+ runner := c.registerRunner("alpha", "ubuntu-latest")
1293+ job := c.startJob(runner)
1294+ c.heartbeat(runner, job)
1295+ start := time.Now()
1296+ _, err := c.resolveErr(runner, job, "slow/action@v1")
1297+ assert.Equal(t, connect.CodeUnavailable, ciErrCode(err), "a deadline of ours is unavailable, not deadline_exceeded")
1298+ assert.Less(t, time.Since(start), 5*time.Second)
1299+ // The job goes on: the next call is answered.
1300+ ok, err := c.resolveErr(runner, job, "a/b@"+shaMain)
1301+ require.NoError(t, err)
1302+ assert.Empty(t, ok[0].GetError())
1303+ })
1304+}
1305+
1306+// mergeWorkflow runs on main only, so that the branch that carries it runs
1307+// nothing until it is merged.
1308+const mergeWorkflow = `on:
1309+ push:
1310+ branches: [main]
1311+jobs:
1312+ j:
1313+ runs-on: ubuntu-latest
1314+ steps:
1315+ - uses: owner/private-action@v1
1316+`
1317+
1318+// GO-R1: a merge runs the workflow with the rights of the principal that
1319+// merged, not the owner's: a token limited to a repository that merges a
1320+// workflow using a private action of another repository is told "repository not
1321+// found", while the owner merging the same workflow gets the action. The actor
1322+// shown on the run stays the owner, as GitHub shows it.
1323+func TestMergeResolvesActionsWithTheRightsOfTheMerger(t *testing.T) {
1324+ merge := func(c *ciHarness, as func(mr int64, head string) error) (*pb.Job, *pb.Run) {
1325+ c.t.Helper()
1326+ runner := c.registerRunner("alpha", "ubuntu-latest")
1327+ c.push("main", map[string]string{"README": "x\n"}, "")
1328+ feat := c.push("feat", map[string]string{".github/workflows/m.yml": mergeWorkflow}, "")
1329+ mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "CI", SourceBranch: "feat"}))
1330+ require.NoError(c.t, err)
1331+ require.Empty(c.t, c.runs(), "nothing runs before the merge")
1332+ require.NoError(c.t, as(mr.Msg.GetMergeRequest().GetNumber(), feat))
1333+ job := runner.await()
1334+ c.heartbeat(runner, job)
1335+ return job, c.runs()[0]
1336+ }
1337+ setup := func(t *testing.T) *ciHarness {
1338+ c := newExecHarness(t, newFakeRemote(t), nil)
1339+ c.seed("private-action", domain.VisibilityPrivate, "action.yml")
1340+ c.mustGit(c.repoDir("private-action"), "tag", "v1")
1341+ return c
1342+ }
1343+ asToken := func(c *ciHarness, secret string) func(int64, string) error {
1344+ return func(n int64, head string) error {
1345+ _, err := c.api(secret).mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
1346+ return err
1347+ }
1348+ }
1349+
1350+ t.Run("a token limited to the repository", func(t *testing.T) {
1351+ c := setup(t)
1352+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj")
1353+ job, run := merge(c, asToken(c, secret))
1354+ assert.Equal(t, "owner", run.GetActor(), "GitHub shows the owner as the actor of a merge")
1355+ got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1356+ assert.Equal(t, "Unable to resolve action `owner/private-action@v1`, repository not found", got[0].GetError())
1357+ assert.Empty(t, got[0].GetToken())
1358+ })
1359+
1360+ t.Run("a token that lists both repositories", func(t *testing.T) {
1361+ c := setup(t)
1362+ secret, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRMerge}, nil, "proj", "private-action")
1363+ job, _ := merge(c, asToken(c, secret))
1364+ got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1365+ require.Empty(t, got[0].GetError())
1366+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
1367+ })
1368+
1369+ t.Run("the owner", func(t *testing.T) {
1370+ c := setup(t)
1371+ job, run := merge(c, func(n int64, head string) error {
1372+ id, err := domain.ParseObjectID(head)
1373+ require.NoError(t, err)
1374+ _, err = c.forge.MergeRequests.Merge(c.ctx, domain.Owner(), c.h("proj"), n, ports.MergeParams{HeadID: id})
1375+ return err
1376+ })
1377+ assert.Equal(t, "owner", run.GetActor())
1378+ got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1379+ require.Empty(t, got[0].GetError())
1380+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got[0].GetSource())
1381+ assert.True(t, domain.IsRepoToken(got[0].GetToken()))
1382+ })
1383+
1384+ t.Run("the admin token", func(t *testing.T) {
1385+ c := setup(t)
1386+ job, _ := merge(c, asToken(c, adminToken))
1387+ got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, "owner/private-action@v1")
1388+ require.Empty(t, got[0].GetError())
1389+ })
1390+}
1391+
1392+// GO-R4: a repository of this server that the pusher cannot read never falls
1393+// through to github.com under the same name, at the first resolution and when
1394+// the answer comes from a stored one; only a repository that does not exist
1395+// here is github.com's.
1396+func TestAnActionRepositoryOfTheServerNeverFallsThroughToGitHub(t *testing.T) {
1397+ const ref = "owner/b@v1"
1398+ notFound := "Unable to resolve action `owner/b@v1`, repository not found"
1399+ limited := func(c *ciHarness) string {
1400+ s, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
1401+ return s
1402+ }
1403+ setup := func(t *testing.T) (*ciHarness, *fakeRemote, *fakeRunner) {
1404+ remote := newFakeRemote(t)
1405+ remote.set("owner/b", ptrRef("refs/tags/v1", shaOther)) // the repository of the same name on github.com
1406+ c := newExecHarness(t, remote, nil)
1407+ return c, remote, c.registerRunner("alpha", "ubuntu-latest")
1408+ }
1409+ // run pushes the workflow (first) or a new commit as the secret and returns the
1410+ // job the push made.
1411+ run := func(c *ciHarness, runner *fakeRunner, secret string, files map[string]string) *pb.Job {
1412+ c.t.Helper()
1413+ c.pushAs(secret, "main", files)
1414+ job := runner.await()
1415+ c.heartbeat(runner, job)
1416+ return job
1417+ }
1418+ resolve := func(c *ciHarness, job *pb.Job) *pb.ResolvedAction {
1419+ return c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, ref)[0]
1420+ }
1421+
1422+ t.Run("first resolution", func(t *testing.T) {
1423+ c, remote, runner := setup(t)
1424+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1425+ c.mustGit(c.repoDir("b"), "tag", "v1")
1426+ job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1427+ got := resolve(c, job)
1428+ assert.Equal(t, notFound, got.GetError(), "the same text as for a repository that is not there")
1429+ assert.Empty(t, got.GetResolvedSha())
1430+ assert.Empty(t, got.GetTarballUrl())
1431+ assert.Empty(t, got.GetToken())
1432+ assert.False(t, remote.asked("owner/b"), "github.com is never asked about a name the server holds")
1433+ // And not the second time, from the same attempt.
1434+ assert.Equal(t, notFound, resolve(c, job).GetError())
1435+ assert.False(t, remote.asked("owner/b"))
1436+ })
1437+
1438+ t.Run("a pusher who reads it, then one who does not, on the same ref", func(t *testing.T) {
1439+ c, remote, runner := setup(t)
1440+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1441+ c.mustGit(c.repoDir("b"), "tag", "v1")
1442+ bSha := c.bareGit("b", "rev-parse", "HEAD")
1443+
1444+ first := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1445+ got := resolve(c, first)
1446+ require.Empty(t, got.GetError())
1447+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource())
1448+ assert.Equal(t, bSha, got.GetResolvedSha())
1449+
1450+ second := run(c, runner, limited(c), map[string]string{"two.txt": "2\n"})
1451+ got = resolve(c, second)
1452+ assert.Equal(t, notFound, got.GetError(), "another run, another pusher: its own answer")
1453+ assert.Empty(t, got.GetToken())
1454+ assert.False(t, remote.asked("owner/b"))
1455+ // The first run still resolves.
1456+ assert.Equal(t, bSha, resolve(c, first).GetResolvedSha())
1457+ })
1458+
1459+ t.Run("a pusher who does not read it, then one who does, on the same ref", func(t *testing.T) {
1460+ c, remote, runner := setup(t)
1461+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1462+ c.mustGit(c.repoDir("b"), "tag", "v1")
1463+ bSha := c.bareGit("b", "rev-parse", "HEAD")
1464+
1465+ first := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1466+ assert.Equal(t, notFound, resolve(c, first).GetError())
1467+
1468+ second := run(c, runner, adminToken, map[string]string{"two.txt": "2\n"})
1469+ got := resolve(c, second)
1470+ require.Empty(t, got.GetError(), "the refusal of the first run is not kept for the second")
1471+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_ERMITE, got.GetSource())
1472+ assert.Equal(t, bSha, got.GetResolvedSha())
1473+ assert.NotEqual(t, strings.ToLower(shaOther), got.GetResolvedSha(), "not github.com's commit")
1474+ assert.False(t, remote.asked("owner/b"))
1475+ assert.Equal(t, notFound, resolve(c, first).GetError(), "and the first run is still refused")
1476+ })
1477+
1478+ t.Run("a stored resolution from github.com, then the repository appears here", func(t *testing.T) {
1479+ c, remote, runner := setup(t)
1480+ job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1481+ before := resolve(c, job)
1482+ require.Empty(t, before.GetError(), "no such repository here: github.com answers")
1483+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource())
1484+ assert.Equal(t, strings.ToLower(shaOther), before.GetResolvedSha())
1485+ require.Equal(t, 1, remote.count())
1486+
1487+ // The name is taken on the server, privately.
1488+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1489+ after := resolve(c, job)
1490+ assert.Equal(t, notFound, after.GetError(), "the stored github.com answer is not served for a repository the pusher may not read")
1491+ assert.Empty(t, after.GetResolvedSha())
1492+ assert.Empty(t, after.GetTarballUrl())
1493+ assert.Equal(t, 1, remote.count(), "and github.com was not asked again")
1494+ })
1495+
1496+ t.Run("a stored resolution from github.com, a pusher who reads the new repository", func(t *testing.T) {
1497+ c, _, runner := setup(t)
1498+ job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1499+ before := resolve(c, job)
1500+ require.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, before.GetSource())
1501+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1502+ after := resolve(c, job)
1503+ require.Empty(t, after.GetError())
1504+ assert.Equal(t, before.GetResolvedSha(), after.GetResolvedSha(), "stable for the attempt")
1505+ assert.Empty(t, after.GetToken(), "no archive token for a commit of github.com")
1506+ })
1507+
1508+ t.Run("a stored resolution from the server, then the repository goes", func(t *testing.T) {
1509+ c, remote, runner := setup(t)
1510+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1511+ c.mustGit(c.repoDir("b"), "tag", "v1")
1512+ job := run(c, runner, adminToken, map[string]string{oneJobWorkflow: oneJobYAML()})
1513+ require.Empty(t, resolve(c, job).GetError())
1514+ require.NoError(t, c.forge.Repos.Delete(c.ctx, domain.Root(), c.h("b")))
1515+ got := resolve(c, job)
1516+ assert.Equal(t, notFound, got.GetError(), "there is nothing left to serve the stored commit from")
1517+ assert.Empty(t, got.GetToken())
1518+ assert.False(t, remote.asked("owner/b"))
1519+ })
1520+
1521+ t.Run("a repository that does not exist here is github.com's", func(t *testing.T) {
1522+ c, remote, runner := setup(t)
1523+ job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1524+ got := resolve(c, job)
1525+ require.Empty(t, got.GetError())
1526+ assert.Equal(t, pb.ActionSource_ACTION_SOURCE_GITHUB, got.GetSource())
1527+ assert.True(t, remote.asked("owner/b"))
1528+ })
1529+
1530+ t.Run("the owner's case does not open a way around", func(t *testing.T) {
1531+ c, remote, runner := setup(t)
1532+ c.seed("b", domain.VisibilityPrivate, "action.yml")
1533+ c.mustGit(c.repoDir("b"), "tag", "v1")
1534+ job := run(c, runner, limited(c), map[string]string{oneJobWorkflow: oneJobYAML()})
1535+ for _, u := range []string{"OWNER/b@v1", "Owner/B@v1", "owner/B@v1"} {
1536+ got := c.resolve(&fakeRunner{c: c, api: c.api(job.GetJobToken())}, job, u)[0]
1537+ assert.Equal(t, "Unable to resolve action `"+strings.TrimSuffix(u, "@v1")+"@v1`, repository not found", got.GetError(), u)
1538+ }
1539+ assert.Zero(t, remote.count())
1540+ })
1541+}
M internal/app/ci_flow_test.go +3 -2
@@ -324,13 +324,14 @@ func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
324 jobs:324 jobs:
325 j:325 j:
326 runs-on: ubuntu-latest326 runs-on: ubuntu-latest
327+ environment: production
327 steps:328 steps:
328- - uses: actions/checkout@v4329+ - run: echo
329 `}, "")330 `}, "")
330 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })331 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
331 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]332 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
332 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())333 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
333- assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError())334+ assert.Equal(t, "not supported yet: environment", j.GetError())
334 }335 }
335 336
336 // A job for a label no runner has is "waiting for a runner" and fails after the337 // A job for a label no runner has is "waiting for a runner" and fails after the
M internal/app/ci_parity_test.go +46 -6
@@ -307,20 +307,50 @@ jobs:
307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
308 }308 }
309 309
310-// A step timeout stays refused as long as the runner has none.310+// CI-2: planning runs what CI-1 refused (uses, container, services, a step
311-func TestCIStepTimeoutIsStillRefused(t *testing.T) {311+// timeout) and still refuses job-level uses, environment and concurrency.
312+func TestCIPlanningAcceptsCI2Features(t *testing.T) {
312 c := newCIHarness(t, nil)313 c := newCIHarness(t, nil)
313- c.registerRunner("alpha", "ubuntu-latest")314+ runner := c.registerRunner("alpha", "ubuntu-latest")
314 c.push("main", map[string]string{".github/workflows/s.yml": `on: push315 c.push("main", map[string]string{".github/workflows/s.yml": `on: push
315 jobs:316 jobs:
316 j:317 j:
317 runs-on: ubuntu-latest318 runs-on: ubuntu-latest
319+ container: alpine:3
320+ services:
321+ db:
322+ image: postgres:16
318 steps:323 steps:
324+ - uses: actions/checkout@v4
319 - timeout-minutes: 1325 - timeout-minutes: 1
320 run: echo326 run: echo
321 `}, "")327 `}, "")
322- c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })328+ job := runner.await()
323- assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())329+ assert.Equal(t, "j", job.GetJobKey())
330+
331+ for _, tc := range []struct{ name, job, want string }{
332+ {"reusable", "uses: ./.github/workflows/x.yml", "not supported yet: reusable workflows (uses at the job level)"},
333+ {"environment", "runs-on: ubuntu-latest\n environment: prod\n steps:\n - run: echo", "not supported yet: environment"},
334+ {"concurrency", "runs-on: ubuntu-latest\n concurrency: g\n steps:\n - run: echo", "not supported yet: concurrency"},
335+ } {
336+ t.Run(tc.name, func(t *testing.T) {
337+ body := "on: push\njobs:\n r:\n " + tc.job + "\n"
338+ c.push("main", map[string]string{".github/workflows/" + tc.name + ".yml": body}, "")
339+ var got string
340+ c.waitFor(tc.name+" fails", func() bool {
341+ for _, r := range c.runs() {
342+ if r.GetWorkflowPath() == ".github/workflows/"+tc.name+".yml" && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED {
343+ if jobs := c.getRun(r.GetId()).GetJobs(); len(jobs) == 1 {
344+ got = jobs[0].GetError()
345+ return true
346+ }
347+ }
348+ }
349+ return false
350+ })
351+ assert.Equal(t, tc.want, got)
352+ })
353+ }
324 }354 }
325 355
326 // A failed job with continue-on-error does not fail the run, keeps its own356 // A failed job with continue-on-error does not fail the run, keeps its own
@@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) {
416 "/owner/proj": "/proj",446 "/owner/proj": "/proj",
417 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,447 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
418 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",448 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
419- "/owner/proj.git/info/refs": "/proj.git/info/refs",
420 } {449 } {
421 res := b.get(path, nil)450 res := b.get(path, nil)
422 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)451 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
423 assert.Equal(t, to, res.hdr.Get("Location"), path)452 assert.Equal(t, to, res.hdr.Get("Location"), path)
424 }453 }
454+ // The git URLs of the owner are served by git over HTTP, not redirected
455+ // (checkout clones <server>/<owner>/<repo>): the private repository asks
456+ // for credentials, a dumb request has no route.
457+ for path, want := range map[string]int{
458+ "/owner/proj.git/info/refs?service=git-upload-pack": http.StatusUnauthorized,
459+ "/owner/proj/info/refs?service=git-upload-pack": http.StatusUnauthorized,
460+ "/other/proj/info/refs?service=git-upload-pack": http.StatusNotFound,
461+ "/owner/proj.git/info/refs": http.StatusNotFound,
462+ } {
463+ assert.Equal(t, want, b.get(path, nil).status, path)
464+ }
425 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")465 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
426 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")466 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
427 assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)467 assert.Equal(t, http.StatusNotFound, b.get("/owner/Bad Name", nil).status)
M internal/app/cli_e2e_test.go +1 -1
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326 ver := decode[versionOut](t, res.stdout)326 ver := decode[versionOut](t, res.stdout)
327 assert.Equal(t, c.url, ver.Server.URL)327 assert.Equal(t, c.url, ver.Server.URL)
328 assert.NotEmpty(t, ver.Server.Version)328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, ver.Server.Features)
330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331 stdout, stderr, code = c.bin(t, secret, "version")331 stdout, stderr, code = c.bin(t, secret, "version")
332 require.Equal(t, 0, code, stderr)332 require.Equal(t, 0, code, stderr)
M internal/app/forge.go +36 -3
@@ -20,6 +20,8 @@ import (
20 "bnema.dev/ermite/internal/adapters/eventbus"20 "bnema.dev/ermite/internal/adapters/eventbus"
21 "bnema.dev/ermite/internal/adapters/gitcli"21 "bnema.dev/ermite/internal/adapters/gitcli"
22 "bnema.dev/ermite/internal/adapters/githttp"22 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/adapters/githubapi"
24+ "bnema.dev/ermite/internal/adapters/gitremote"
23 "bnema.dev/ermite/internal/adapters/httpserver"25 "bnema.dev/ermite/internal/adapters/httpserver"
24 "bnema.dev/ermite/internal/adapters/kvstore"26 "bnema.dev/ermite/internal/adapters/kvstore"
25 "bnema.dev/ermite/internal/adapters/rpc"27 "bnema.dev/ermite/internal/adapters/rpc"
@@ -50,11 +52,25 @@ type options struct {
50 eventBuffer int52 eventBuffer int
51 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.53 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
52 revalidateEvery time.Duration54 revalidateEvery time.Duration
55+ // archivesPerClient overrides the archives one client downloads at once
56+ // when positive.
57+ archivesPerClient int
58+ // remote replaces the lister of github.com refs (tests never reach the
59+ // network).
60+ remoteLister ports.GitRemote
53 // ci overrides the settings of the CI engine (tests shorten its61 // ci overrides the settings of the CI engine (tests shorten its
54 // durations).62 // durations).
55 ci func(*usecase.CIConfig)63 ci func(*usecase.CIConfig)
56 }64 }
57 65
66+// remote returns the lister of github.com refs: the test's, else def.
67+func (o options) remote(def ports.GitRemote) ports.GitRemote {
68+ if o.remoteLister != nil {
69+ return o.remoteLister
70+ }
71+ return def
72+}
73+
58 // forge is the wired server: every adapter and use case, ready to serve.74 // forge is the wired server: every adapter and use case, ready to serve.
59 type forge struct {75 type forge struct {
60 cfg config.Config76 cfg config.Config
@@ -161,7 +177,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161 Msg("repositories reconciled")177 Msg("repositories reconciled")
162 178
163 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())179 f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions())
164- f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock)180+ f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock).WithRepoTokens(store.CI())
165 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)181 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
166 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)182 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
167 183
@@ -199,7 +215,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
199 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,215 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,216 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,217 Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202- PollTimeout: cfg.CIPollTimeout,218+ PollTimeout: cfg.CIPollTimeout, Archive: git,
219+ Remote: opts.remote(gitremote.New(gitremote.Config{
220+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
221+ Proxy: gitremote.ProxyEnv(os.Getenv),
222+ })),
223+ Tokens: store.Tokens(),
203 }224 }
204 if opts.ci != nil {225 if opts.ci != nil {
205 opts.ci(&ciCfg)226 opts.ci(&ciCfg)
@@ -279,15 +300,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
279 Repos: f.Repos,300 Repos: f.Repos,
280 Pushes: f.Pushes,301 Pushes: f.Pushes,
281 Backend: git,302 Backend: git,
303+ Owner: cfg.Owner,
282 Hooks: f.hooks,304 Hooks: f.hooks,
283 TrustedProxies: cfg.TrustedProxies,305 TrustedProxies: cfg.TrustedProxies,
284 Limiter: limiter,306 Limiter: limiter,
285 MaxBodyBytes: maxBody,307 MaxBodyBytes: maxBody,
286 })308 })
287- routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns))309+ routes := make([]httpserver.Route, 0, 2*len(githttp.Patterns)+len(githubapi.Patterns)+len(web.Patterns))
288 for _, p := range githttp.Patterns {310 for _, p := range githttp.Patterns {
289 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})311 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
290 }312 }
313+ // The owner form of the clone URL (github.repository), which GitHub's
314+ // actions use. config.Owner follows the rules of repository names, so it is
315+ // a literal segment that never overlaps /-/.
316+ for _, p := range githttp.OwnerPatterns(cfg.Owner) {
317+ routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
318+ }
319+ // The REST subset the actions call on github.api_url.
320+ api := githubapi.New(githubapi.Config{Auth: f.Auth, API: f.CI, TrustedProxies: cfg.TrustedProxies, Limiter: limiter, MaxArchivesPerClient: opts.archivesPerClient})
321+ for _, p := range githubapi.Patterns {
322+ routes = append(routes, httpserver.Route{Pattern: p, Handler: api})
323+ }
291 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)324 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
292 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)325 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
293 site, err := newWeb(cfg, f, limiter)326 site, err := newWeb(cfg, f, limiter)
M internal/app/web_passkey_test.go +2 -2
@@ -188,7 +188,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
188 assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)188 assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
189 assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")189 assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
190 190
191- rm := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID).ok(t)191+ rm := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID).ok(t)
192 assert.Contains(t, rm.stderr, "every browser is signed out")192 assert.Contains(t, rm.stderr, "every browser is signed out")
193 for _, tok := range []string{token, ck.Value} {193 for _, tok := range []string{token, ck.Value} {
194 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")194 assert.Equal(t, http.StatusNotFound, p.b.get("/-/passkeys", as(tok)).status, "the epoch moved: both sessions are gone")
@@ -197,7 +197,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
197 res = p.login()197 res = p.login()
198 assert.Equal(t, http.StatusUnauthorized, res.status, res.body)198 assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
199 assert.Empty(t, res.hdr.Values("Set-Cookie"))199 assert.Empty(t, res.hdr.Values("Set-Cookie"))
200- again := p.cli(t, adminToken, "web", "passkey", "remove", listed[0].ID)200+ again := p.cli(t, adminToken, "web", "passkey", "remove", "--", listed[0].ID)
201 require.Error(t, again.err)201 require.Error(t, again.err)
202 assert.Contains(t, again.err.Error(), "not found")202 assert.Contains(t, again.err.Error(), "not found")
203 }203 }
M internal/domain/ci.go +3 -0
@@ -191,6 +191,9 @@ type CIRunSource struct {
191 Repo RepoName191 Repo RepoName
192 WorkflowYAML string192 WorkflowYAML string
193 GithubJSON string193 GithubJSON string
194+ // Pusher is who pushed the commit: what the actions of other repositories
195+ // of the server may be resolved on behalf of.
196+ Pusher CIPusher
194 }197 }
195 198
196 // CIStep is a step of a job with where its lines are in the job log.199 // CIStep is a step of a job with where its lines are in the job log.
A internal/domain/ci_token.go +137 -0
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "strings"
7+ "time"
8+)
9+
10+// RepoTokenPrefix starts the secret of a job repository token: the
11+// "github.token" of a job, or the token of one action archive. Unlike the
12+// stored tokens (TokenSecretPrefix) it is never created, listed or revoked by
13+// a person: the CI engine issues it with a job and revokes it with the job.
14+const RepoTokenPrefix = "ermitegh_"
15+
16+// IsRepoToken reports whether s has the shape of a job repository token.
17+func IsRepoToken(s string) bool { return hasTokenShape(s, RepoTokenPrefix) }
18+
19+// repoTokenIDPrefix starts the TokenID of the principal of a job repository
20+// token, so that it never collides with the ID of a stored token.
21+const repoTokenIDPrefix = "job:"
22+
23+// IsRepoTokenID reports whether id is the TokenID of the principal of a job
24+// repository token (see RepoTokenPrincipal).
25+func IsRepoTokenID(id string) bool { return strings.HasPrefix(id, repoTokenIDPrefix) }
26+
27+// CIRepoToken is a job repository token: it reads one repository, until
28+// ExpiresAt or the end of its job. The secret is never stored, only its hash.
29+type CIRepoToken struct {
30+ // ID identifies the token; it is not secret.
31+ ID string
32+ Hash TokenHash
33+ // Repo is the one repository the token reads.
34+ Repo RepoName
35+ // RunID, RunAttempt and JobID name the job it belongs to.
36+ RunID int64
37+ RunAttempt int32
38+ JobID string
39+ // Archive is set for the token of an action archive, unset for the job's
40+ // own token ("github.token"). An archive token downloads the commit Sha of
41+ // Repo and nothing else.
42+ Archive bool
43+ // Sha is the commit an archive token is bound to; empty for the job's own.
44+ Sha string
45+ ExpiresAt time.Time
46+ CreatedAt time.Time
47+}
48+
49+// Expired reports whether the token is no longer valid at now.
50+func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
51+
52+// RepoTokenGrace is how long a job repository token outlives the deadline of
53+// its job at the most (a safety net: it is revoked when the job ends).
54+const RepoTokenGrace = 5 * time.Minute
55+
56+// RepoTokenPrincipal returns the principal of the holder of a job repository
57+// token.
58+//
59+// The job's own token is shaped like a stored token (the scope repo:read on one
60+// repository, an expiry), so that every authorization rule of the forge applies
61+// to it unchanged: it reads that repository, even when private, and writes
62+// nothing.
63+//
64+// An archive token holds no scope at all: it is the grant to download one
65+// commit of one repository as an archive, which only the archive endpoint
66+// reads (Principal.ArchiveGrant). Git over HTTP, the repository endpoint and
67+// the rest of the API see a caller with no rights, so a private repository
68+// does not show itself to it.
69+//
70+// Both have a TokenID that starts with "job:" and matches no stored token.
71+func RepoTokenPrincipal(t CIRepoToken) Principal {
72+ if t.Archive {
73+ return Principal{
74+ kind: kindArchive, tokenID: repoTokenIDPrefix + t.ID, label: "ci archive", expiresAt: t.ExpiresAt,
75+ archiveRepo: t.Repo, archiveSha: t.Sha,
76+ }
77+ }
78+ return TokenPrincipal(Token{
79+ ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
80+ Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
81+ })
82+}
83+
84+// CIPusherKind tells who pushed the commit of a run.
85+type CIPusherKind string
86+
87+// Pusher kinds. The zero value is a run stored before the pusher was recorded.
88+const (
89+ CIPusherNone CIPusherKind = ""
90+ CIPusherRoot CIPusherKind = "root"
91+ CIPusherOwner CIPusherKind = "owner"
92+ CIPusherToken CIPusherKind = "token"
93+)
94+
95+// CIPusher is who pushed the commit of a run, as recorded with the run. A
96+// token is recorded by its ID only: what it may read is looked up when it is
97+// needed, so a token revoked since no longer reads anything on behalf of the
98+// run.
99+type CIPusher struct {
100+ Kind CIPusherKind
101+ TokenID string
102+}
103+
104+// PusherOf records the pusher p. Anonymous callers, which cannot push, and the
105+// holders of job tokens, which cannot either, are recorded as nobody.
106+func PusherOf(p Principal) CIPusher {
107+ switch {
108+ case p.IsOwner():
109+ return CIPusher{Kind: CIPusherOwner}
110+ case p.IsRoot():
111+ return CIPusher{Kind: CIPusherRoot}
112+ case p.TokenID() != "" && !IsRepoTokenID(p.TokenID()):
113+ return CIPusher{Kind: CIPusherToken, TokenID: p.TokenID()}
114+ }
115+ return CIPusher{}
116+}
117+
118+// CIActionSource says where an action was found.
119+type CIActionSource int
120+
121+// Action sources, with the numbers of ActionSource in the protocol.
122+const (
123+ CIActionGitHub CIActionSource = 1
124+ CIActionErmite CIActionSource = 2
125+)
126+
127+// CIActionResolution is the resolution of an action reference for one run
128+// attempt: every job of the attempt gets the same commit.
129+type CIActionResolution struct {
130+ RunID int64
131+ RunAttempt int32
132+ // Key is "owner/repo@ref", lower-cased.
133+ Key string
134+ NameWithOwner string
135+ ResolvedSHA string
136+ Source CIActionSource
137+}
A internal/domain/ci_token_test.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestIsRepoToken(t *testing.T) {
16+ t.Parallel()
17+ ok := domain.RepoTokenPrefix + strings.Repeat("aB3-_", 8) + "xyz"
18+ assert.True(t, domain.IsRepoToken(ok))
19+ for _, bad := range []string{
20+ "", domain.RepoTokenPrefix, ok + "x", ok[:len(ok)-1], strings.Replace(ok, "ermitegh_", "ermitejob_", 1),
21+ domain.RepoTokenPrefix + strings.Repeat("a", 42) + "=", domain.RepoTokenPrefix + strings.Repeat("a", 42) + " ",
22+ domain.TokenSecretPrefix + strings.Repeat("a", 43),
23+ } {
24+ assert.False(t, domain.IsRepoToken(bad), bad)
25+ }
26+ assert.False(t, domain.IsJobToken(ok), "the two kinds of secret are never mistaken")
27+}
28+
29+func TestRepoTokenPrincipalReadsOneRepositoryAndNothingElse(t *testing.T) {
30+ t.Parallel()
31+ repo, _ := domain.ParseRepoName("proj")
32+ other, _ := domain.ParseRepoName("other")
33+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
34+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab12", Repo: repo, ExpiresAt: exp})
35+
36+ assert.True(t, p.Allows(domain.ScopeRepoRead, repo))
37+ assert.False(t, p.Allows(domain.ScopeRepoRead, other))
38+ for _, s := range domain.AllScopes() {
39+ if s != domain.ScopeRepoRead {
40+ assert.False(t, p.Allows(s, repo), s)
41+ }
42+ }
43+ assert.False(t, p.IsRoot())
44+ assert.False(t, p.HasOwnerRights())
45+ assert.False(t, p.IsAnonymous())
46+ assert.Equal(t, exp, p.ExpiresAt())
47+ assert.True(t, domain.IsRepoTokenID(p.TokenID()))
48+ assert.False(t, domain.IsRepoTokenID("0123456789abcdef"), "a stored token id is 16 hex characters")
49+
50+ tok := domain.CIRepoToken{ExpiresAt: exp}
51+ assert.False(t, tok.Expired(exp.Add(-time.Second)))
52+ assert.True(t, tok.Expired(exp), "the expiry instant is already over")
53+}
54+
55+func TestArchivePrincipalHoldsNoScopeAndOneGrant(t *testing.T) {
56+ t.Parallel()
57+ repo, _ := domain.ParseRepoName("action")
58+ sha := strings.Repeat("a", 40)
59+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
60+ p := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab", Repo: repo, Archive: true, Sha: sha, ExpiresAt: exp})
61+
62+ gr, gs, ok := p.ArchiveGrant()
63+ assert.True(t, ok)
64+ assert.Equal(t, repo, gr)
65+ assert.Equal(t, sha, gs)
66+ for _, s := range domain.AllScopes() {
67+ assert.False(t, p.HasScope(s), s)
68+ assert.False(t, p.Allows(s, repo), s)
69+ }
70+ assert.False(t, p.IsRoot())
71+ assert.False(t, p.IsAnonymous(), "it is authenticated, with nothing to read")
72+ assert.False(t, p.HasOwnerRights())
73+ assert.Equal(t, exp, p.ExpiresAt())
74+ assert.True(t, domain.IsRepoTokenID(p.TokenID()))
75+ assert.Equal(t, "archive", p.Label())
76+
77+ own := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, ExpiresAt: exp})
78+ _, _, ok = own.ArchiveGrant()
79+ assert.False(t, ok, "the job's own token has no grant, it has the scope")
80+ for _, other := range []domain.Principal{domain.Anonymous(), domain.Root(), domain.Owner()} {
81+ _, _, ok = other.ArchiveGrant()
82+ assert.False(t, ok)
83+ }
84+}
85+
86+func TestPusherOf(t *testing.T) {
87+ t.Parallel()
88+ repo, _ := domain.ParseRepoName("proj")
89+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherRoot}, domain.PusherOf(domain.Root()))
90+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherOwner}, domain.PusherOf(domain.Owner()))
91+ assert.Equal(t, domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "abc"},
92+ domain.PusherOf(domain.TokenPrincipal(domain.Token{ID: "abc", Scopes: []domain.Scope{domain.ScopeRepoWrite}})))
93+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.Anonymous()))
94+ assert.Equal(t, domain.CIPusher{}, domain.PusherOf(domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "x", Repo: repo})), "a job token never pushes")
95+}
96+
97+func TestOwnerMatches(t *testing.T) {
98+ t.Parallel()
99+ assert.True(t, domain.OwnerMatches("acme", "acme"))
100+ assert.True(t, domain.OwnerMatches("acme", "ACME"))
101+ assert.True(t, domain.OwnerMatches("acme", "Acme"))
102+ assert.False(t, domain.OwnerMatches("acme", "acme2"))
103+ assert.False(t, domain.OwnerMatches("acme", ""))
104+ assert.False(t, domain.OwnerMatches("", ""), "no owner configured matches nothing")
105+ assert.False(t, domain.OwnerMatches("", "acme"))
106+}
M internal/domain/errors.go +4 -0
@@ -36,6 +36,10 @@ var (
36 // ErrResourceExhausted means a limit of the server is reached, for example36 // ErrResourceExhausted means a limit of the server is reached, for example
37 // the number of event watchers; the caller may retry later.37 // the number of event watchers; the caller may retry later.
38 ErrResourceExhausted = errors.New("resource exhausted")38 ErrResourceExhausted = errors.New("resource exhausted")
39+ // ErrUnavailable means something the operation depends on, outside the
40+ // forge (github.com for an action), could not be reached; the caller may
41+ // retry later.
42+ ErrUnavailable = errors.New("unavailable")
39 )43 )
40 44
41 // invalidErrors are the errors that mean a request was malformed.45 // invalidErrors are the errors that mean a request was malformed.
M internal/domain/name.go +9 -0
@@ -101,3 +101,12 @@ func nameProblem(s string) string {
101 }101 }
102 return ""102 return ""
103 }103 }
104+
105+// OwnerMatches reports whether name is the owner of the forge, as GitHub
106+// matches the owner of a repository: without regard to case. It is the one
107+// rule for the owner segment of the Git URLs, of the REST subset and of the
108+// references of actions. configured is ERMITE_OWNER, which is lower case (it
109+// follows the rules of repository names); an empty one matches nothing.
110+func OwnerMatches(configured, name string) bool {
111+ return configured != "" && strings.EqualFold(configured, name)
112+}
M internal/domain/principal.go +20 -0
@@ -17,6 +17,10 @@ const (
17 kindRoot17 kindRoot
18 kindToken18 kindToken
19 kindOwner19 kindOwner
20+ // kindArchive is the holder of the archive token of an action: it holds no
21+ // scope at all, only a grant to download one commit of one repository (see
22+ // ArchivePrincipal).
23+ kindArchive
20 )24 )
21 25
22 // Principal is the authenticated identity of a caller: nobody, the root26 // Principal is the authenticated identity of a caller: nobody, the root
@@ -31,6 +35,9 @@ type Principal struct {
31 branchPrefixes []string35 branchPrefixes []string
32 expiresAt time.Time36 expiresAt time.Time
33 label string37 label string
38+ // archiveRepo and archiveSha are the grant of a kindArchive principal.
39+ archiveRepo RepoName
40+ archiveSha string
34 }41 }
35 42
36 // Anonymous returns the principal of a caller without credentials.43 // Anonymous returns the principal of a caller without credentials.
@@ -67,6 +74,8 @@ func (p Principal) Label() string {
67 return "owner"74 return "owner"
68 case kindToken:75 case kindToken:
69 return "token"76 return "token"
77+ case kindArchive:
78+ return "archive"
70 }79 }
71 return "anonymous"80 return "anonymous"
72 }81 }
@@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool {
187 // TokenLabel returns the label of the stored token, or "" for other196 // TokenLabel returns the label of the stored token, or "" for other
188 // principals.197 // principals.
189 func (p Principal) TokenLabel() string { return p.label }198 func (p Principal) TokenLabel() string { return p.label }
199+
200+// ArchiveGrant returns the one commit of one repository that p may download as
201+// an archive, and ok true, when p is the holder of an archive token. Only the
202+// archive endpoint of the REST subset reads it: for everything else an archive
203+// principal holds no scope, so it reads what an anonymous caller reads.
204+func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
205+ if p.kind != kindArchive {
206+ return RepoName{}, "", false
207+ }
208+ return p.archiveRepo, p.archiveSha, true
209+}
M internal/ports/ci.go +77 -5
@@ -25,10 +25,35 @@ type CIRunDraft struct {
25 // Checks are derived from the jobs and upserted without a precondition.25 // Checks are derived from the jobs and upserted without a precondition.
26 // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the26 // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the
27 // repository no longer exists.27 // repository no longer exists.
28+//
29+// RepoTokens are job repository tokens created with the batch (they must not
30+// exist yet), RevokeJobs the jobs whose repository tokens are deleted by it,
31+// and Resolutions the action resolutions of a run attempt to keep. They are
32+// written under the lock of the run that owns them.
28 type CIWrite struct {33 type CIWrite struct {
29- Runs []domain.CIRun34+ Runs []domain.CIRun
30- Jobs []domain.CIJob35+ Jobs []domain.CIJob
31- Checks []domain.CICheck36+ Checks []domain.CICheck
37+ RepoTokens []domain.CIRepoToken
38+ RevokeJobs []CIJobRef
39+ Resolutions []domain.CIActionResolution
40+}
41+
42+// CIJobRef names one execution of a job.
43+type CIJobRef struct {
44+ RunID int64
45+ RunAttempt int32
46+ JobID string
47+}
48+
49+// CIRepoTokenStore reads the job repository tokens, for the authentication of
50+// a request. They are written by CIStore.Apply. *kvstore.CI implements it.
51+type CIRepoTokenStore interface {
52+ // FindRepoTokenByHash returns the token whose secret has that hash, or
53+ // domain.ErrNotFound.
54+ FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error)
55+ // GetRepoToken returns the token with that id, or domain.ErrNotFound.
56+ GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error)
32 }57 }
33 58
34 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes59 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
@@ -46,6 +71,9 @@ type CIStore interface {
46 Apply(ctx context.Context, w CIWrite) (version uint64, err error)71 Apply(ctx context.Context, w CIWrite) (version uint64, err error)
47 // GetRun returns the run, or domain.ErrNotFound.72 // GetRun returns the run, or domain.ErrNotFound.
48 GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)73 GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)
74+ // GetActionResolution returns the resolution kept for the key in the run
75+ // attempt, or domain.ErrNotFound.
76+ GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error)
49 // GetRunSource returns what the run was planned from, or77 // GetRunSource returns what the run was planned from, or
50 // domain.ErrNotFound. It is how a run is found from its id alone.78 // domain.ErrNotFound. It is how a run is found from its id alone.
51 GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)79 GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)
@@ -165,6 +193,11 @@ type CILogStore interface {
165 // errors never reach the push.193 // errors never reach the push.
166 type WorkflowTrigger interface {194 type WorkflowTrigger interface {
167 OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)195 OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)
196+ // OnMerge is OnPush for the move of a target branch by a merge. GitHub shows
197+ // the owner as the actor of the push of a merge, whoever merged; but the
198+ // rights the run's actions are resolved with are those of merger, the
199+ // principal that merged, never the owner's.
200+ OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal)
168 }201 }
169 202
170 // CIGit is what planning needs from Git beyond RepoReader and MergeGit.203 // CIGit is what planning needs from Git beyond RepoReader and MergeGit.
@@ -200,8 +233,10 @@ type RegisteredRunner struct {
200 233
201 // RunnerJob is a job given to a runner.234 // RunnerJob is a job given to a runner.
202 type RunnerJob struct {235 type RunnerJob struct {
203- Job domain.CIJob236+ Job domain.CIJob
204- Token string237+ Token string
238+ // GithubToken is the job repository token, the "github.token" of the job.
239+ GithubToken string
205 Repo domain.RepoName240 Repo domain.RepoName
206 Sha, Ref string241 Sha, Ref string
207 WorkflowPath string242 WorkflowPath string
@@ -247,6 +282,38 @@ type CompleteJobResult struct {
247 Applied bool282 Applied bool
248 }283 }
249 284
285+// ResolvedAction is the resolution of one "uses" of ResolveActions: where to
286+// download the action, or Error (then only Ref is set).
287+type ResolvedAction struct {
288+ Ref string
289+ NameWithOwner string
290+ Path string
291+ ResolvedSHA string
292+ TarballURL string
293+ Token string
294+ Source domain.CIActionSource
295+ Error string
296+}
297+
298+// RemoteRef is a ref of a remote repository, as `git ls-remote` lists it.
299+type RemoteRef struct {
300+ // Name is the full ref name ("refs/heads/main", "refs/tags/v1").
301+ Name string
302+ // ID is the object the ref points to (the tag object of an annotated tag).
303+ ID string
304+ // Peeled is the commit an annotated tag points to; empty for other refs.
305+ Peeled string
306+}
307+
308+// GitRemote lists the refs of a repository on github.com, with no
309+// credentials. It returns domain.ErrNotFound for a repository that does not
310+// exist or needs credentials (GitHub answers alike), and an error matching
311+// domain.ErrUnavailable when it cannot tell (network, timeout, too large an
312+// answer).
313+type GitRemote interface {
314+ ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error)
315+}
316+
250 // JobSignal is one message of WatchJob.317 // JobSignal is one message of WatchJob.
251 type JobSignal struct {318 type JobSignal struct {
252 Keepalive bool319 Keepalive bool
@@ -278,6 +345,11 @@ type RunnerService interface {
278 // then, because a client's call returns only when they arrive.345 // then, because a client's call returns only when they arrive.
279 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error346 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
280 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)347 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
348+ // ResolveActions resolves the "uses" of the job's steps, one answer per
349+ // reference in the same order. A reference that cannot be resolved is not
350+ // an error: its answer carries Error. A local ("./") or Docker
351+ // ("docker://") reference, or more than 100, is domain.ErrInvalid.
352+ ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error)
281 }353 }
282 354
283 // JobView is a job as owners see it.355 // JobView is a job as owners see it.
A internal/ports/mocks/CIRepoTokenStore.go +180 -0
@@ -0,0 +1,180 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIRepoTokenStore creates a new instance of CIRepoTokenStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIRepoTokenStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIRepoTokenStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIRepoTokenStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIRepoTokenStore is an autogenerated mock type for the CIRepoTokenStore type
38+type CIRepoTokenStore struct {
39+ mock.Mock
40+}
41+
42+type CIRepoTokenStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIRepoTokenStore) EXPECT() *CIRepoTokenStore_Expecter {
47+ return &CIRepoTokenStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// FindRepoTokenByHash provides a mock function for the type CIRepoTokenStore
51+func (_mock *CIRepoTokenStore) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) {
52+ ret := _mock.Called(ctx, hash)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for FindRepoTokenByHash")
56+ }
57+
58+ var r0 domain.CIRepoToken
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.CIRepoToken, error)); ok {
61+ return returnFunc(ctx, hash)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.CIRepoToken); ok {
64+ r0 = returnFunc(ctx, hash)
65+ } else {
66+ r0 = ret.Get(0).(domain.CIRepoToken)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok {
69+ r1 = returnFunc(ctx, hash)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// CIRepoTokenStore_FindRepoTokenByHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRepoTokenByHash'
77+type CIRepoTokenStore_FindRepoTokenByHash_Call struct {
78+ *mock.Call
79+}
80+
81+// FindRepoTokenByHash is a helper method to define mock.On call
82+// - ctx context.Context
83+// - hash domain.TokenHash
84+func (_e *CIRepoTokenStore_Expecter) FindRepoTokenByHash(ctx any, hash any) *CIRepoTokenStore_FindRepoTokenByHash_Call {
85+ return &CIRepoTokenStore_FindRepoTokenByHash_Call{Call: _e.mock.On("FindRepoTokenByHash", ctx, hash)}
86+}
87+
88+func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *CIRepoTokenStore_FindRepoTokenByHash_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 domain.TokenHash
95+ if args[1] != nil {
96+ arg1 = args[1].(domain.TokenHash)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) Return(cIRepoToken domain.CIRepoToken, err error) *CIRepoTokenStore_FindRepoTokenByHash_Call {
107+ _c.Call.Return(cIRepoToken, err)
108+ return _c
109+}
110+
111+func (_c *CIRepoTokenStore_FindRepoTokenByHash_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error)) *CIRepoTokenStore_FindRepoTokenByHash_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
116+// GetRepoToken provides a mock function for the type CIRepoTokenStore
117+func (_mock *CIRepoTokenStore) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) {
118+ ret := _mock.Called(ctx, id)
119+
120+ if len(ret) == 0 {
121+ panic("no return value specified for GetRepoToken")
122+ }
123+
124+ var r0 domain.CIRepoToken
125+ var r1 error
126+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRepoToken, error)); ok {
127+ return returnFunc(ctx, id)
128+ }
129+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRepoToken); ok {
130+ r0 = returnFunc(ctx, id)
131+ } else {
132+ r0 = ret.Get(0).(domain.CIRepoToken)
133+ }
134+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
135+ r1 = returnFunc(ctx, id)
136+ } else {
137+ r1 = ret.Error(1)
138+ }
139+ return r0, r1
140+}
141+
142+// CIRepoTokenStore_GetRepoToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoToken'
143+type CIRepoTokenStore_GetRepoToken_Call struct {
144+ *mock.Call
145+}
146+
147+// GetRepoToken is a helper method to define mock.On call
148+// - ctx context.Context
149+// - id string
150+func (_e *CIRepoTokenStore_Expecter) GetRepoToken(ctx any, id any) *CIRepoTokenStore_GetRepoToken_Call {
151+ return &CIRepoTokenStore_GetRepoToken_Call{Call: _e.mock.On("GetRepoToken", ctx, id)}
152+}
153+
154+func (_c *CIRepoTokenStore_GetRepoToken_Call) Run(run func(ctx context.Context, id string)) *CIRepoTokenStore_GetRepoToken_Call {
155+ _c.Call.Run(func(args mock.Arguments) {
156+ var arg0 context.Context
157+ if args[0] != nil {
158+ arg0 = args[0].(context.Context)
159+ }
160+ var arg1 string
161+ if args[1] != nil {
162+ arg1 = args[1].(string)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *CIRepoTokenStore_GetRepoToken_Call) Return(cIRepoToken domain.CIRepoToken, err error) *CIRepoTokenStore_GetRepoToken_Call {
173+ _c.Call.Return(cIRepoToken, err)
174+ return _c
175+}
176+
177+func (_c *CIRepoTokenStore_GetRepoToken_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRepoToken, error)) *CIRepoTokenStore_GetRepoToken_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
M internal/ports/mocks/CIStore.go +78 -0
@@ -405,6 +405,84 @@ func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Cont
405 return _c405 return _c
406 }406 }
407 407
408+// GetActionResolution provides a mock function for the type CIStore
409+func (_mock *CIStore) GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error) {
410+ ret := _mock.Called(ctx, runID, runAttempt, key)
411+
412+ if len(ret) == 0 {
413+ panic("no return value specified for GetActionResolution")
414+ }
415+
416+ var r0 domain.CIActionResolution
417+ var r1 error
418+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) (domain.CIActionResolution, error)); ok {
419+ return returnFunc(ctx, runID, runAttempt, key)
420+ }
421+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) domain.CIActionResolution); ok {
422+ r0 = returnFunc(ctx, runID, runAttempt, key)
423+ } else {
424+ r0 = ret.Get(0).(domain.CIActionResolution)
425+ }
426+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok {
427+ r1 = returnFunc(ctx, runID, runAttempt, key)
428+ } else {
429+ r1 = ret.Error(1)
430+ }
431+ return r0, r1
432+}
433+
434+// CIStore_GetActionResolution_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActionResolution'
435+type CIStore_GetActionResolution_Call struct {
436+ *mock.Call
437+}
438+
439+// GetActionResolution is a helper method to define mock.On call
440+// - ctx context.Context
441+// - runID int64
442+// - runAttempt int32
443+// - key string
444+func (_e *CIStore_Expecter) GetActionResolution(ctx any, runID any, runAttempt any, key any) *CIStore_GetActionResolution_Call {
445+ return &CIStore_GetActionResolution_Call{Call: _e.mock.On("GetActionResolution", ctx, runID, runAttempt, key)}
446+}
447+
448+func (_c *CIStore_GetActionResolution_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32, key string)) *CIStore_GetActionResolution_Call {
449+ _c.Call.Run(func(args mock.Arguments) {
450+ var arg0 context.Context
451+ if args[0] != nil {
452+ arg0 = args[0].(context.Context)
453+ }
454+ var arg1 int64
455+ if args[1] != nil {
456+ arg1 = args[1].(int64)
457+ }
458+ var arg2 int32
459+ if args[2] != nil {
460+ arg2 = args[2].(int32)
461+ }
462+ var arg3 string
463+ if args[3] != nil {
464+ arg3 = args[3].(string)
465+ }
466+ run(
467+ arg0,
468+ arg1,
469+ arg2,
470+ arg3,
471+ )
472+ })
473+ return _c
474+}
475+
476+func (_c *CIStore_GetActionResolution_Call) Return(cIActionResolution domain.CIActionResolution, err error) *CIStore_GetActionResolution_Call {
477+ _c.Call.Return(cIActionResolution, err)
478+ return _c
479+}
480+
481+func (_c *CIStore_GetActionResolution_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error)) *CIStore_GetActionResolution_Call {
482+ _c.Call.Return(run)
483+ return _c
484+}
485+
408 // GetJob provides a mock function for the type CIStore486 // GetJob provides a mock function for the type CIStore
409 func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {487 func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
410 ret := _mock.Called(ctx, runID, runAttempt, jobID)488 ret := _mock.Called(ctx, runID, runAttempt, jobID)
A internal/ports/mocks/GitArchive.go +124 -0
@@ -0,0 +1,124 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewGitArchive creates a new instance of GitArchive. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewGitArchive(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *GitArchive {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &GitArchive{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// GitArchive is an autogenerated mock type for the GitArchive type
39+type GitArchive struct {
40+ mock.Mock
41+}
42+
43+type GitArchive_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *GitArchive) EXPECT() *GitArchive_Expecter {
48+ return &GitArchive_Expecter{mock: &_m.Mock}
49+}
50+
51+// Archive provides a mock function for the type GitArchive
52+func (_mock *GitArchive) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
53+ ret := _mock.Called(ctx, repo, commit, prefix, w)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Archive")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, string, io.Writer) error); ok {
61+ r0 = returnFunc(ctx, repo, commit, prefix, w)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// GitArchive_Archive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Archive'
69+type GitArchive_Archive_Call struct {
70+ *mock.Call
71+}
72+
73+// Archive is a helper method to define mock.On call
74+// - ctx context.Context
75+// - repo domain.RepoName
76+// - commit domain.ObjectID
77+// - prefix string
78+// - w io.Writer
79+func (_e *GitArchive_Expecter) Archive(ctx any, repo any, commit any, prefix any, w any) *GitArchive_Archive_Call {
80+ return &GitArchive_Archive_Call{Call: _e.mock.On("Archive", ctx, repo, commit, prefix, w)}
81+}
82+
83+func (_c *GitArchive_Archive_Call) Run(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer)) *GitArchive_Archive_Call {
84+ _c.Call.Run(func(args mock.Arguments) {
85+ var arg0 context.Context
86+ if args[0] != nil {
87+ arg0 = args[0].(context.Context)
88+ }
89+ var arg1 domain.RepoName
90+ if args[1] != nil {
91+ arg1 = args[1].(domain.RepoName)
92+ }
93+ var arg2 domain.ObjectID
94+ if args[2] != nil {
95+ arg2 = args[2].(domain.ObjectID)
96+ }
97+ var arg3 string
98+ if args[3] != nil {
99+ arg3 = args[3].(string)
100+ }
101+ var arg4 io.Writer
102+ if args[4] != nil {
103+ arg4 = args[4].(io.Writer)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ arg3,
110+ arg4,
111+ )
112+ })
113+ return _c
114+}
115+
116+func (_c *GitArchive_Archive_Call) Return(err error) *GitArchive_Archive_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *GitArchive_Archive_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error) *GitArchive_Archive_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
A internal/ports/mocks/GitHubAPIService.go +372 -0
@@ -0,0 +1,372 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewGitHubAPIService creates a new instance of GitHubAPIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewGitHubAPIService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *GitHubAPIService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &GitHubAPIService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// GitHubAPIService is an autogenerated mock type for the GitHubAPIService type
39+type GitHubAPIService struct {
40+ mock.Mock
41+}
42+
43+type GitHubAPIService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *GitHubAPIService) EXPECT() *GitHubAPIService_Expecter {
48+ return &GitHubAPIService_Expecter{mock: &_m.Mock}
49+}
50+
51+// GetRepo provides a mock function for the type GitHubAPIService
52+func (_mock *GitHubAPIService) GetRepo(ctx context.Context, p domain.Principal, owner string, repo string) ([]byte, error) {
53+ ret := _mock.Called(ctx, p, owner, repo)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for GetRepo")
57+ }
58+
59+ var r0 []byte
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) ([]byte, error)); ok {
62+ return returnFunc(ctx, p, owner, repo)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) []byte); ok {
65+ r0 = returnFunc(ctx, p, owner, repo)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]byte)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string) error); ok {
72+ r1 = returnFunc(ctx, p, owner, repo)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// GitHubAPIService_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo'
80+type GitHubAPIService_GetRepo_Call struct {
81+ *mock.Call
82+}
83+
84+// GetRepo is a helper method to define mock.On call
85+// - ctx context.Context
86+// - p domain.Principal
87+// - owner string
88+// - repo string
89+func (_e *GitHubAPIService_Expecter) GetRepo(ctx any, p any, owner any, repo any) *GitHubAPIService_GetRepo_Call {
90+ return &GitHubAPIService_GetRepo_Call{Call: _e.mock.On("GetRepo", ctx, p, owner, repo)}
91+}
92+
93+func (_c *GitHubAPIService_GetRepo_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string)) *GitHubAPIService_GetRepo_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.Principal
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.Principal)
102+ }
103+ var arg2 string
104+ if args[2] != nil {
105+ arg2 = args[2].(string)
106+ }
107+ var arg3 string
108+ if args[3] != nil {
109+ arg3 = args[3].(string)
110+ }
111+ run(
112+ arg0,
113+ arg1,
114+ arg2,
115+ arg3,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *GitHubAPIService_GetRepo_Call) Return(bytes []byte, err error) *GitHubAPIService_GetRepo_Call {
122+ _c.Call.Return(bytes, err)
123+ return _c
124+}
125+
126+func (_c *GitHubAPIService_GetRepo_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string) ([]byte, error)) *GitHubAPIService_GetRepo_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
131+// HashAlgorithm provides a mock function for the type GitHubAPIService
132+func (_mock *GitHubAPIService) HashAlgorithm(ctx context.Context, p domain.Principal, owner string, repo string) (string, error) {
133+ ret := _mock.Called(ctx, p, owner, repo)
134+
135+ if len(ret) == 0 {
136+ panic("no return value specified for HashAlgorithm")
137+ }
138+
139+ var r0 string
140+ var r1 error
141+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) (string, error)); ok {
142+ return returnFunc(ctx, p, owner, repo)
143+ }
144+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string) string); ok {
145+ r0 = returnFunc(ctx, p, owner, repo)
146+ } else {
147+ r0 = ret.Get(0).(string)
148+ }
149+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string) error); ok {
150+ r1 = returnFunc(ctx, p, owner, repo)
151+ } else {
152+ r1 = ret.Error(1)
153+ }
154+ return r0, r1
155+}
156+
157+// GitHubAPIService_HashAlgorithm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HashAlgorithm'
158+type GitHubAPIService_HashAlgorithm_Call struct {
159+ *mock.Call
160+}
161+
162+// HashAlgorithm is a helper method to define mock.On call
163+// - ctx context.Context
164+// - p domain.Principal
165+// - owner string
166+// - repo string
167+func (_e *GitHubAPIService_Expecter) HashAlgorithm(ctx any, p any, owner any, repo any) *GitHubAPIService_HashAlgorithm_Call {
168+ return &GitHubAPIService_HashAlgorithm_Call{Call: _e.mock.On("HashAlgorithm", ctx, p, owner, repo)}
169+}
170+
171+func (_c *GitHubAPIService_HashAlgorithm_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string)) *GitHubAPIService_HashAlgorithm_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ var arg0 context.Context
174+ if args[0] != nil {
175+ arg0 = args[0].(context.Context)
176+ }
177+ var arg1 domain.Principal
178+ if args[1] != nil {
179+ arg1 = args[1].(domain.Principal)
180+ }
181+ var arg2 string
182+ if args[2] != nil {
183+ arg2 = args[2].(string)
184+ }
185+ var arg3 string
186+ if args[3] != nil {
187+ arg3 = args[3].(string)
188+ }
189+ run(
190+ arg0,
191+ arg1,
192+ arg2,
193+ arg3,
194+ )
195+ })
196+ return _c
197+}
198+
199+func (_c *GitHubAPIService_HashAlgorithm_Call) Return(s string, err error) *GitHubAPIService_HashAlgorithm_Call {
200+ _c.Call.Return(s, err)
201+ return _c
202+}
203+
204+func (_c *GitHubAPIService_HashAlgorithm_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string) (string, error)) *GitHubAPIService_HashAlgorithm_Call {
205+ _c.Call.Return(run)
206+ return _c
207+}
208+
209+// Tarball provides a mock function for the type GitHubAPIService
210+func (_mock *GitHubAPIService) Tarball(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer) error {
211+ ret := _mock.Called(ctx, p, owner, repo, ref, begin)
212+
213+ if len(ret) == 0 {
214+ panic("no return value specified for Tarball")
215+ }
216+
217+ var r0 error
218+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string, func(filename string) io.Writer) error); ok {
219+ r0 = returnFunc(ctx, p, owner, repo, ref, begin)
220+ } else {
221+ r0 = ret.Error(0)
222+ }
223+ return r0
224+}
225+
226+// GitHubAPIService_Tarball_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tarball'
227+type GitHubAPIService_Tarball_Call struct {
228+ *mock.Call
229+}
230+
231+// Tarball is a helper method to define mock.On call
232+// - ctx context.Context
233+// - p domain.Principal
234+// - owner string
235+// - repo string
236+// - ref string
237+// - begin func(filename string) io.Writer
238+func (_e *GitHubAPIService_Expecter) Tarball(ctx any, p any, owner any, repo any, ref any, begin any) *GitHubAPIService_Tarball_Call {
239+ return &GitHubAPIService_Tarball_Call{Call: _e.mock.On("Tarball", ctx, p, owner, repo, ref, begin)}
240+}
241+
242+func (_c *GitHubAPIService_Tarball_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer)) *GitHubAPIService_Tarball_Call {
243+ _c.Call.Run(func(args mock.Arguments) {
244+ var arg0 context.Context
245+ if args[0] != nil {
246+ arg0 = args[0].(context.Context)
247+ }
248+ var arg1 domain.Principal
249+ if args[1] != nil {
250+ arg1 = args[1].(domain.Principal)
251+ }
252+ var arg2 string
253+ if args[2] != nil {
254+ arg2 = args[2].(string)
255+ }
256+ var arg3 string
257+ if args[3] != nil {
258+ arg3 = args[3].(string)
259+ }
260+ var arg4 string
261+ if args[4] != nil {
262+ arg4 = args[4].(string)
263+ }
264+ var arg5 func(filename string) io.Writer
265+ if args[5] != nil {
266+ arg5 = args[5].(func(filename string) io.Writer)
267+ }
268+ run(
269+ arg0,
270+ arg1,
271+ arg2,
272+ arg3,
273+ arg4,
274+ arg5,
275+ )
276+ })
277+ return _c
278+}
279+
280+func (_c *GitHubAPIService_Tarball_Call) Return(err error) *GitHubAPIService_Tarball_Call {
281+ _c.Call.Return(err)
282+ return _c
283+}
284+
285+func (_c *GitHubAPIService_Tarball_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string, begin func(filename string) io.Writer) error) *GitHubAPIService_Tarball_Call {
286+ _c.Call.Return(run)
287+ return _c
288+}
289+
290+// TarballName provides a mock function for the type GitHubAPIService
291+func (_mock *GitHubAPIService) TarballName(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error) {
292+ ret := _mock.Called(ctx, p, owner, repo, ref)
293+
294+ if len(ret) == 0 {
295+ panic("no return value specified for TarballName")
296+ }
297+
298+ var r0 string
299+ var r1 error
300+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) (string, error)); ok {
301+ return returnFunc(ctx, p, owner, repo, ref)
302+ }
303+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, string) string); ok {
304+ r0 = returnFunc(ctx, p, owner, repo, ref)
305+ } else {
306+ r0 = ret.Get(0).(string)
307+ }
308+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, string) error); ok {
309+ r1 = returnFunc(ctx, p, owner, repo, ref)
310+ } else {
311+ r1 = ret.Error(1)
312+ }
313+ return r0, r1
314+}
315+
316+// GitHubAPIService_TarballName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TarballName'
317+type GitHubAPIService_TarballName_Call struct {
318+ *mock.Call
319+}
320+
321+// TarballName is a helper method to define mock.On call
322+// - ctx context.Context
323+// - p domain.Principal
324+// - owner string
325+// - repo string
326+// - ref string
327+func (_e *GitHubAPIService_Expecter) TarballName(ctx any, p any, owner any, repo any, ref any) *GitHubAPIService_TarballName_Call {
328+ return &GitHubAPIService_TarballName_Call{Call: _e.mock.On("TarballName", ctx, p, owner, repo, ref)}
329+}
330+
331+func (_c *GitHubAPIService_TarballName_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string)) *GitHubAPIService_TarballName_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 context.Context
334+ if args[0] != nil {
335+ arg0 = args[0].(context.Context)
336+ }
337+ var arg1 domain.Principal
338+ if args[1] != nil {
339+ arg1 = args[1].(domain.Principal)
340+ }
341+ var arg2 string
342+ if args[2] != nil {
343+ arg2 = args[2].(string)
344+ }
345+ var arg3 string
346+ if args[3] != nil {
347+ arg3 = args[3].(string)
348+ }
349+ var arg4 string
350+ if args[4] != nil {
351+ arg4 = args[4].(string)
352+ }
353+ run(
354+ arg0,
355+ arg1,
356+ arg2,
357+ arg3,
358+ arg4,
359+ )
360+ })
361+ return _c
362+}
363+
364+func (_c *GitHubAPIService_TarballName_Call) Return(s string, err error) *GitHubAPIService_TarballName_Call {
365+ _c.Call.Return(s, err)
366+ return _c
367+}
368+
369+func (_c *GitHubAPIService_TarballName_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref string) (string, error)) *GitHubAPIService_TarballName_Call {
370+ _c.Call.Return(run)
371+ return _c
372+}
A internal/ports/mocks/GitRemote.go +122 -0
@@ -0,0 +1,122 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewGitRemote creates a new instance of GitRemote. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewGitRemote(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *GitRemote {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &GitRemote{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// GitRemote is an autogenerated mock type for the GitRemote type
38+type GitRemote struct {
39+ mock.Mock
40+}
41+
42+type GitRemote_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *GitRemote) EXPECT() *GitRemote_Expecter {
47+ return &GitRemote_Expecter{mock: &_m.Mock}
48+}
49+
50+// ListRefs provides a mock function for the type GitRemote
51+func (_mock *GitRemote) ListRefs(ctx context.Context, owner string, repo string) ([]ports.RemoteRef, error) {
52+ ret := _mock.Called(ctx, owner, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ListRefs")
56+ }
57+
58+ var r0 []ports.RemoteRef
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]ports.RemoteRef, error)); ok {
61+ return returnFunc(ctx, owner, repo)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []ports.RemoteRef); ok {
64+ r0 = returnFunc(ctx, owner, repo)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]ports.RemoteRef)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
71+ r1 = returnFunc(ctx, owner, repo)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// GitRemote_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
79+type GitRemote_ListRefs_Call struct {
80+ *mock.Call
81+}
82+
83+// ListRefs is a helper method to define mock.On call
84+// - ctx context.Context
85+// - owner string
86+// - repo string
87+func (_e *GitRemote_Expecter) ListRefs(ctx any, owner any, repo any) *GitRemote_ListRefs_Call {
88+ return &GitRemote_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx, owner, repo)}
89+}
90+
91+func (_c *GitRemote_ListRefs_Call) Run(run func(ctx context.Context, owner string, repo string)) *GitRemote_ListRefs_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 string
98+ if args[1] != nil {
99+ arg1 = args[1].(string)
100+ }
101+ var arg2 string
102+ if args[2] != nil {
103+ arg2 = args[2].(string)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *GitRemote_ListRefs_Call) Return(remoteRefs []ports.RemoteRef, err error) *GitRemote_ListRefs_Call {
115+ _c.Call.Return(remoteRefs, err)
116+ return _c
117+}
118+
119+func (_c *GitRemote_ListRefs_Call) RunAndReturn(run func(ctx context.Context, owner string, repo string) ([]ports.RemoteRef, error)) *GitRemote_ListRefs_Call {
120+ _c.Call.Return(run)
121+ return _c
122+}
M internal/ports/mocks/RunnerService.go +80 -0
@@ -417,6 +417,86 @@ func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context
417 return _c417 return _c
418 }418 }
419 419
420+// ResolveActions provides a mock function for the type RunnerService
421+func (_mock *RunnerService) ResolveActions(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error) {
422+ ret := _mock.Called(ctx, secret, jobID, uses)
423+
424+ if len(ret) == 0 {
425+ panic("no return value specified for ResolveActions")
426+ }
427+
428+ var r0 []ports.ResolvedAction
429+ var r1 error
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) ([]ports.ResolvedAction, error)); ok {
431+ return returnFunc(ctx, secret, jobID, uses)
432+ }
433+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) []ports.ResolvedAction); ok {
434+ r0 = returnFunc(ctx, secret, jobID, uses)
435+ } else {
436+ if ret.Get(0) != nil {
437+ r0 = ret.Get(0).([]ports.ResolvedAction)
438+ }
439+ }
440+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok {
441+ r1 = returnFunc(ctx, secret, jobID, uses)
442+ } else {
443+ r1 = ret.Error(1)
444+ }
445+ return r0, r1
446+}
447+
448+// RunnerService_ResolveActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveActions'
449+type RunnerService_ResolveActions_Call struct {
450+ *mock.Call
451+}
452+
453+// ResolveActions is a helper method to define mock.On call
454+// - ctx context.Context
455+// - secret string
456+// - jobID string
457+// - uses []string
458+func (_e *RunnerService_Expecter) ResolveActions(ctx any, secret any, jobID any, uses any) *RunnerService_ResolveActions_Call {
459+ return &RunnerService_ResolveActions_Call{Call: _e.mock.On("ResolveActions", ctx, secret, jobID, uses)}
460+}
461+
462+func (_c *RunnerService_ResolveActions_Call) Run(run func(ctx context.Context, secret string, jobID string, uses []string)) *RunnerService_ResolveActions_Call {
463+ _c.Call.Run(func(args mock.Arguments) {
464+ var arg0 context.Context
465+ if args[0] != nil {
466+ arg0 = args[0].(context.Context)
467+ }
468+ var arg1 string
469+ if args[1] != nil {
470+ arg1 = args[1].(string)
471+ }
472+ var arg2 string
473+ if args[2] != nil {
474+ arg2 = args[2].(string)
475+ }
476+ var arg3 []string
477+ if args[3] != nil {
478+ arg3 = args[3].([]string)
479+ }
480+ run(
481+ arg0,
482+ arg1,
483+ arg2,
484+ arg3,
485+ )
486+ })
487+ return _c
488+}
489+
490+func (_c *RunnerService_ResolveActions_Call) Return(resolvedActions []ports.ResolvedAction, err error) *RunnerService_ResolveActions_Call {
491+ _c.Call.Return(resolvedActions, err)
492+ return _c
493+}
494+
495+func (_c *RunnerService_ResolveActions_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error)) *RunnerService_ResolveActions_Call {
496+ _c.Call.Return(run)
497+ return _c
498+}
499+
420 // UpdateJob provides a mock function for the type RunnerService500 // UpdateJob provides a mock function for the type RunnerService
421 func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {501 func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
422 ret := _mock.Called(ctx, secret, jobID, steps)502 ret := _mock.Called(ctx, secret, jobID, steps)
M internal/ports/mocks/WorkflowTrigger.go +58 -0
@@ -47,6 +47,64 @@ func (_m *WorkflowTrigger) EXPECT() *WorkflowTrigger_Expecter {
47 return &WorkflowTrigger_Expecter{mock: &_m.Mock}47 return &WorkflowTrigger_Expecter{mock: &_m.Mock}
48 }48 }
49 49
50+// OnMerge provides a mock function for the type WorkflowTrigger
51+func (_mock *WorkflowTrigger) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
52+ _mock.Called(ctx, repo, updates, merger)
53+ return
54+}
55+
56+// WorkflowTrigger_OnMerge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnMerge'
57+type WorkflowTrigger_OnMerge_Call struct {
58+ *mock.Call
59+}
60+
61+// OnMerge is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+// - merger domain.Principal
66+func (_e *WorkflowTrigger_Expecter) OnMerge(ctx any, repo any, updates any, merger any) *WorkflowTrigger_OnMerge_Call {
67+ return &WorkflowTrigger_OnMerge_Call{Call: _e.mock.On("OnMerge", ctx, repo, updates, merger)}
68+}
69+
70+func (_c *WorkflowTrigger_OnMerge_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal)) *WorkflowTrigger_OnMerge_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 domain.RepoName
77+ if args[1] != nil {
78+ arg1 = args[1].(domain.RepoName)
79+ }
80+ var arg2 []domain.RefUpdate
81+ if args[2] != nil {
82+ arg2 = args[2].([]domain.RefUpdate)
83+ }
84+ var arg3 domain.Principal
85+ if args[3] != nil {
86+ arg3 = args[3].(domain.Principal)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ arg2,
92+ arg3,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *WorkflowTrigger_OnMerge_Call) Return() *WorkflowTrigger_OnMerge_Call {
99+ _c.Call.Return()
100+ return _c
101+}
102+
103+func (_c *WorkflowTrigger_OnMerge_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal)) *WorkflowTrigger_OnMerge_Call {
104+ _c.Run(run)
105+ return _c
106+}
107+
50 // OnPush provides a mock function for the type WorkflowTrigger108 // OnPush provides a mock function for the type WorkflowTrigger
51 func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {109 func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
52 _mock.Called(ctx, repo, updates, pusher)110 _mock.Called(ctx, repo, updates, pusher)
M internal/ports/ports.go +8 -0
@@ -200,6 +200,14 @@ type GitBackend interface {
200 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)200 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
201 }201 }
202 202
203+// GitArchive writes the archive of a commit.
204+type GitArchive interface {
205+ // Archive writes the tree of the commit as a gzip-compressed tar to w, all
206+ // the paths below the directory prefix. It writes nothing and returns
207+ // domain.ErrNotFound when the repository or the commit does not exist.
208+ Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error
209+}
210+
203 // TokenStore keeps the stored tokens and the index from secret hash to token.211 // TokenStore keeps the stored tokens and the index from secret hash to token.
204 // The secret itself is never stored.212 // The secret itself is never stored.
205 type TokenStore interface {213 type TokenStore interface {
M internal/ports/services.go +23 -0
@@ -55,6 +55,29 @@ type TokenService interface {
55 Revoke(ctx context.Context, p domain.Principal, id string) error55 Revoke(ctx context.Context, p domain.Principal, id string) error
56 }56 }
57 57
58+// GitHubAPIService is the subset of GitHub's REST API that ermite serves under
59+// /-/github/api, for the actions that run on it (ADR 0025, CI-2). owner is the
60+// owner of the URL: only ERMITE_OWNER names repositories, any other owner is
61+// domain.ErrNotFound, as is a repository the principal may not read.
62+// *usecase.CI implements it.
63+type GitHubAPIService interface {
64+ // GetRepo returns the JSON of GET /repos/{owner}/{repo}.
65+ GetRepo(ctx context.Context, p domain.Principal, owner, repo string) ([]byte, error)
66+ // HashAlgorithm returns the object format of the repository, "sha1".
67+ HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)
68+ // Tarball resolves ref (a branch, a tag or a commit id; empty means the
69+ // default branch) and writes the gzip-compressed tar of the commit to the
70+ // writer that begin returns; the archive token of an action (see
71+ // domain.RepoTokenPrincipal) gets the commit it was issued for and no other.
72+ // begin is called once, with the file name of the
73+ // archive, before the first byte and not at all when the ref does not
74+ // resolve (domain.ErrNotFound).
75+ Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
76+ // TarballName is Tarball for a HEAD request: the same access and the same
77+ // resolution of ref, then the file name, without producing the archive.
78+ TarballName(ctx context.Context, p domain.Principal, owner, repo, ref string) (string, error)
79+}
80+
58 // BrowseService is the read side of a repository's content. *usecase.Browse81 // BrowseService is the read side of a repository's content. *usecase.Browse
59 // implements it.82 // implements it.
60 type BrowseService interface {83 type BrowseService interface {
M internal/usecase/access.go +6 -0
@@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool {
20 // canReadAs is canRead for a repository known by its name and visibility only,20 // canReadAs is canRead for a repository known by its name and visibility only,
21 // such as one that no longer exists.21 // such as one that no longer exists.
22 func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {22 func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility) bool {
23+ if _, _, archive := p.ArchiveGrant(); archive {
24+ // The holder of an archive token downloads one commit through the
25+ // archive endpoint, which checks its grant; everywhere else it reads
26+ // nothing, not even a public repository (domain.RepoTokenPrincipal).
27+ return false
28+ }
23 if vis == domain.VisibilityPublic {29 if vis == domain.VisibilityPublic {
24 return true30 return true
25 }31 }
M internal/usecase/ci.go +54 -15
@@ -29,6 +29,11 @@ const (
29 DefaultCIKeepalive = 30 * time.Second29 DefaultCIKeepalive = 30 * time.Second
30 // DefaultCITriggerQueue is the size of the queue of pushes to plan.30 // DefaultCITriggerQueue is the size of the queue of pushes to plan.
31 DefaultCITriggerQueue = 25631 DefaultCITriggerQueue = 256
32+ // DefaultCIRemoteConcurrency is the git ls-remote processes ResolveActions
33+ // runs at once, whatever the number of jobs that resolve.
34+ DefaultCIRemoteConcurrency = 4
35+ // DefaultCIResolveTimeout bounds one ResolveActions call.
36+ DefaultCIResolveTimeout = 60 * time.Second
32 // MaxCIChecks is the most checks ListChecks returns.37 // MaxCIChecks is the most checks ListChecks returns.
33 MaxCIChecks = 100038 MaxCIChecks = 1000
34 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log39 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log
@@ -56,6 +61,20 @@ type CIConfig struct {
56 Clock ports.Clock61 Clock ports.Clock
57 // Auth re-validates the token of a log stream; nil skips it.62 // Auth re-validates the token of a log stream; nil skips it.
58 Auth ports.Revalidator63 Auth ports.Revalidator
64+ // Remote lists the refs of github.com repositories for ResolveActions; nil
65+ // answers every github.com action with unavailable.
66+ Remote ports.GitRemote
67+ // Archive serves the tarballs of the REST subset.
68+ Archive ports.GitArchive
69+ // Tokens finds the stored token of the pusher of a run, to ask what it may
70+ // read when an action of another repository is resolved for the run. Nil
71+ // reads nothing for a token (fail closed).
72+ Tokens ports.TokenStore
73+ // RemoteConcurrency bounds the git ls-remote processes at once (default
74+ // DefaultCIRemoteConcurrency); ResolveTimeout bounds one ResolveActions call
75+ // (default DefaultCIResolveTimeout).
76+ RemoteConcurrency int
77+ ResolveTimeout time.Duration
59 // Owner is the owner login of the github context.78 // Owner is the owner login of the github context.
60 Owner string79 Owner string
61 // PublicURL is the base URL of the instance (github.server_url).80 // PublicURL is the base URL of the instance (github.server_url).
@@ -90,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig {
90 def(&c.TimeoutSlack, domain.CITimeoutSlack)109 def(&c.TimeoutSlack, domain.CITimeoutSlack)
91 def(&c.QueueTimeout, domain.CIQueueTimeout)110 def(&c.QueueTimeout, domain.CIQueueTimeout)
92 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)111 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)
112+ if c.RemoteConcurrency <= 0 {
113+ c.RemoteConcurrency = DefaultCIRemoteConcurrency
114+ }
115+ def(&c.ResolveTimeout, DefaultCIResolveTimeout)
93 if c.TriggerQueue <= 0 {116 if c.TriggerQueue <= 0 {
94 c.TriggerQueue = DefaultCITriggerQueue117 c.TriggerQueue = DefaultCITriggerQueue
95 }118 }
@@ -112,19 +135,25 @@ type CI struct {
112 ctx context.Context135 ctx context.Context
113 wg sync.WaitGroup136 wg sync.WaitGroup
114 137
115- locks sync.Map // run id -> *sync.Mutex138+ remoteSlots chan struct{} // slots of the git ls-remote processes
116- assign sync.Mutex139+ locks sync.Map // run id -> *sync.Mutex
117- jobs notifier // woken when a job changes: key is the job reference140+ assign sync.Mutex
118- pending notifier // woken when a job may be ready or a runner was removed141+ jobs notifier // woken when a job changes: key is the job reference
119- flows *wfCache142+ pending notifier // woken when a job may be ready or a runner was removed
143+ flows *wfCache
120 144
121 mu sync.Mutex145 mu sync.Mutex
122 heartbeats map[string]time.Time // job reference -> last heartbeat146 heartbeats map[string]time.Time // job reference -> last heartbeat
123 seen map[string]time.Time // runner id -> last seen147 seen map[string]time.Time // runner id -> last seen
124 polls map[string]pollRecord148 polls map[string]pollRecord
125- streams int149+ // archiveTokens are the secrets of the tokens given for the archives of
126- perClient map[string]int150+ // ermite actions, per job reference then "<repository>@<sha>", so that a job
127- closed bool151+ // that resolves the same commit twice gets one token (memory only: a
152+ // restart issues new ones).
153+ archiveTokens map[string]map[string]string
154+ streams int
155+ perClient map[string]int
156+ closed bool
128 }157 }
129 158
130 var (159 var (
@@ -137,10 +166,11 @@ var (
137 // pollRecord remembers the assignment a poll made, for the retry of a lost166 // pollRecord remembers the assignment a poll made, for the retry of a lost
138 // response (FetchJobRequest.poll_id). The token is kept in memory only.167 // response (FetchJobRequest.poll_id). The token is kept in memory only.
139 type pollRecord struct {168 type pollRecord struct {
140- pollID string169+ pollID string
141- ref string170+ ref string
142- token string171+ token string
143- hash domain.TokenHash172+ ghToken string
173+ hash domain.TokenHash
144 }174 }
145 175
146 // NewCI builds the CI use cases. ctx carries the logger; its cancellation is176 // NewCI builds the CI use cases. ctx carries the logger; its cancellation is
@@ -153,9 +183,10 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
153 run, stop := context.WithCancel(context.WithoutCancel(ctx))183 run, stop := context.WithCancel(context.WithoutCancel(ctx))
154 return &CI{184 return &CI{
155 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),185 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
156- ctx: run, stop: stop, flows: newWfCache(64),186+ ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),
157 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},187 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
158- perClient: map[string]int{},188+ archiveTokens: map[string]map[string]string{},
189+ perClient: map[string]int{},
159 }190 }
160 }191 }
161 192
@@ -258,6 +289,12 @@ type runState struct {
258 // chk marks jobs whose check must be written even when the job itself did289 // chk marks jobs whose check must be written even when the job itself did
259 // not change (a rerun moves every check to the new attempt).290 // not change (a rerun moves every check to the new attempt).
260 checks map[string]bool291 checks map[string]bool
292+ // tokens are the job repository tokens to create, revoke the jobs whose
293+ // tokens are deleted, with the next flush.
294+ tokens []domain.CIRepoToken
295+ revoke []ports.CIJobRef
296+ // resolutions are the action resolutions to keep with the next flush.
297+ resolutions []domain.CIActionResolution
261 }298 }
262 299
263 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)300 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)
@@ -341,7 +378,8 @@ func (rs *runState) flush(ctx context.Context) error {
341 if rs.runDirty {378 if rs.runDirty {
342 w.Runs = []domain.CIRun{rs.run}379 w.Runs = []domain.CIRun{rs.run}
343 }380 }
344- if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 {381+ w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions
382+ if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 && len(w.Resolutions) == 0 {
345 return nil383 return nil
346 }384 }
347 v, err := c.cfg.Store.Apply(ctx, w)385 v, err := c.cfg.Store.Apply(ctx, w)
@@ -356,6 +394,7 @@ func (rs *runState) flush(ctx context.Context) error {
356 }394 }
357 changedRun := rs.runDirty395 changedRun := rs.runDirty
358 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false396 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
397+ rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
359 398
360 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)399 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
361 if err != nil {400 if err != nil {
A internal/usecase/ci_actions.go +500 -0
@@ -0,0 +1,500 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// ResolveActions turns the "uses" of a job into where to download the actions
17+// (ADR 0025, CI-2), as GitHub's action download info does: ermite resolves, the
18+// runner downloads from the sandbox.
19+//
20+// - owner = ERMITE_OWNER and the repository exists on ermite: ermite wins (as
21+// the actions of a GitHub Enterprise Server do). The ref is a tag, else a
22+// branch, else a full commit id that exists; the archive is served by the
23+// REST subset and downloaded with a read token for that repository only.
24+// - anything else: github.com. The ref is resolved with `git ls-remote` (tag,
25+// then branch, the peeled commit of an annotated tag); a full commit id is
26+// returned as it is, without checking. The archive is codeload's, with no
27+// token.
28+//
29+// The commit a ref resolves to is kept for the run attempt: every job and every
30+// call of the attempt gets the same one, the first that was stored. A
31+// reference that cannot be resolved is not an error of the call: its answer
32+// carries GitHub's message.
33+func (c *CI) ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
34+ out, err := c.resolveActions(ctx, secret, jobID, uses)
35+ if err != nil && ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) {
36+ // Our own deadline, not the caller's: github.com (or the disk) was too
37+ // slow. The runner may retry, or fail the job.
38+ return nil, fmt.Errorf("%w: resolving the actions took more than %s", domain.ErrUnavailable, c.cfg.ResolveTimeout)
39+ }
40+ return out, err
41+}
42+
43+func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ports.ResolvedAction, error) {
44+ ctx, cancel := context.WithTimeout(ctx, c.cfg.ResolveTimeout)
45+ defer cancel()
46+ j, err := c.jobFor(ctx, secret, jobID)
47+ if err != nil {
48+ return nil, err
49+ }
50+ if j.Ended() {
51+ return nil, endedError(j)
52+ }
53+ if len(uses) > maxActionRefs {
54+ return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs)
55+ }
56+ parsed := make([]actionRef, len(uses))
57+ for i, u := range uses {
58+ if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") {
59+ return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u)
60+ }
61+ if strings.HasPrefix(u, "docker://") {
62+ return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u)
63+ }
64+ parsed[i] = parseActionRef(u)
65+ }
66+
67+ src, err := c.cfg.Store.GetRunSource(ctx, j.RunID)
68+ if err != nil {
69+ return nil, err
70+ }
71+ by := c.onBehalfOf(src)
72+
73+ // Resolve what the attempt has not resolved yet, outside the lock of the
74+ // run: a ls-remote takes seconds and must not hold up the job's calls.
75+ found := map[string]domain.CIActionResolution{}
76+ fails := map[string]string{}
77+ remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)
78+ for _, p := range parsed {
79+ if p.err != "" {
80+ continue
81+ }
82+ if _, done := found[p.key()]; done {
83+ continue
84+ }
85+ if _, bad := fails[p.key()]; bad {
86+ continue
87+ }
88+ if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil {
89+ found[p.key()] = res
90+ continue
91+ } else if !errors.Is(err, domain.ErrNotFound) {
92+ return nil, err
93+ }
94+ res, msg, err := c.resolveAction(ctx, j, p, remote, by)
95+ if err != nil {
96+ return nil, err
97+ }
98+ if msg != "" {
99+ fails[p.key()] = msg
100+ continue
101+ }
102+ found[p.key()] = res
103+ }
104+
105+ out := make([]ports.ResolvedAction, len(parsed))
106+ err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
107+ if cur.Ended() {
108+ return endedError(*cur)
109+ }
110+ rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt)
111+ if err != nil {
112+ return err
113+ }
114+ own := rs.find(cur.ID)
115+ if own == nil {
116+ return domain.ErrNotFound
117+ }
118+ for i, p := range parsed {
119+ out[i] = ports.ResolvedAction{Ref: p.uses}
120+ if p.err != "" {
121+ out[i].Error = p.err
122+ continue
123+ }
124+ if msg, bad := fails[p.key()]; bad {
125+ out[i].Error = msg
126+ continue
127+ }
128+ // The first resolution stored wins, ours or a concurrent job's.
129+ res, ok := found[p.key()]
130+ if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil {
131+ res, ok = cached, true
132+ } else if !errors.Is(err, domain.ErrNotFound) {
133+ return err
134+ } else if ok {
135+ rs.resolutions = append(rs.resolutions, res)
136+ found[p.key()] = res // two refs of the call with the same key
137+ }
138+ if !ok {
139+ continue
140+ }
141+ ra, err := c.answer(ctx, rs, own, p, res, by)
142+ if err != nil {
143+ return err
144+ }
145+ out[i] = ra
146+ }
147+ if err := rs.flush(ctx); err != nil {
148+ // The tokens were not stored: forget their secrets.
149+ c.mu.Lock()
150+ delete(c.archiveTokens, jobRef(*cur))
151+ c.mu.Unlock()
152+ return err
153+ }
154+ return nil
155+ })
156+ if err != nil {
157+ return nil, err
158+ }
159+ return out, nil
160+}
161+
162+// maxActionRefs is the most references of one ResolveActions call.
163+const maxActionRefs = 100
164+
165+// answer builds the answer for a resolved reference. The archive token of an
166+// ermite action is bound to the commit of the answer: the one the job already
167+// holds for it, else a new one, written with the next flush. Whether the pusher
168+// may read the repository is asked again at each answer, not only when the
169+// commit was resolved: a token revoked since, or a repository made private,
170+// stops the answers.
171+func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution, by pusherAccess) (ports.ResolvedAction, error) {
172+ ra := ports.ResolvedAction{
173+ Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
174+ }
175+ notFound := ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}
176+
177+ // A repository of this server that the pusher cannot read is never served
178+ // from anywhere else: not from github.com under the same name, whatever
179+ // source an earlier resolution of the attempt stored. It answers as a
180+ // repository that does not exist. (When it does not exist on this server, a
181+ // stored ermite resolution has nothing to serve either.)
182+ repo, onErmite, err := c.ermiteRepoOf(ctx, p)
183+ if err != nil {
184+ return ports.ResolvedAction{}, err
185+ }
186+ if onErmite {
187+ if ok, err := c.pusherCanRead(ctx, by, rs.run.Repo, repo); err != nil {
188+ return ports.ResolvedAction{}, err
189+ } else if !ok {
190+ return notFound, nil
191+ }
192+ }
193+ switch res.Source {
194+ case domain.CIActionGitHub:
195+ // The case the workflow wrote: the stored resolution is shared by
196+ // spellings that differ only by case, and github.com ignores it.
197+ ra.NameWithOwner = p.owner + "/" + p.repo
198+ ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
199+ case domain.CIActionErmite:
200+ if !onErmite {
201+ return notFound, nil
202+ }
203+ ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
204+ ra.Token = c.archiveToken(rs, j, repo.Name, res.ResolvedSHA)
205+ }
206+ return ra, nil
207+}
208+
209+// archiveToken returns the secret of the token of the job for the archive of
210+// the commit sha of repo, issuing it when the job has none. The token downloads
211+// that archive and nothing else (domain.RepoTokenPrincipal).
212+func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName, sha string) string {
213+ ref, key := jobRef(*j), repo.String()+"@"+sha
214+ c.mu.Lock()
215+ tok, ok := c.archiveTokens[ref][key]
216+ c.mu.Unlock()
217+ if ok {
218+ return tok
219+ }
220+ tok = rs.issueRepoTokenFor(j, repo, sha)
221+ c.mu.Lock()
222+ if c.archiveTokens[ref] == nil {
223+ c.archiveTokens[ref] = map[string]string{}
224+ }
225+ c.archiveTokens[ref][key] = tok
226+ c.mu.Unlock()
227+ return tok
228+}
229+
230+// ermiteRepoOf finds the repository of this server that a reference names: the
231+// owner is ERMITE_OWNER (without regard to case) and the repository exists. ok
232+// is false for any other reference, which github.com serves. It is the one
233+// place that decides the source of an action, for a first resolution and for the
234+// answer from a stored one.
235+func (c *CI) ermiteRepoOf(ctx context.Context, a actionRef) (repo domain.Repo, ok bool, err error) {
236+ if !domain.OwnerMatches(c.cfg.Owner, a.owner) {
237+ return domain.Repo{}, false, nil
238+ }
239+ name, perr := domain.ParseRepoName(strings.ToLower(a.repo))
240+ if perr != nil {
241+ return domain.Repo{}, false, nil // not a name ermite can hold
242+ }
243+ repo, err = c.cfg.Meta.Get(ctx, name)
244+ switch {
245+ case errors.Is(err, domain.ErrNotFound):
246+ return domain.Repo{}, false, nil
247+ case err != nil:
248+ return domain.Repo{}, false, err
249+ }
250+ return repo, true, nil
251+}
252+
253+// pusherAccess is who the actions of a run are resolved on behalf of: the
254+// pusher recorded with the run.
255+type pusherAccess struct{ pusher domain.CIPusher }
256+
257+func (c *CI) onBehalfOf(src domain.CIRunSource) pusherAccess { return pusherAccess{src.Pusher} }
258+
259+// pusherCanRead tells whether the pusher of a run may read repo, with the rule
260+// of the forge (canRead) applied to the principal the pusher is now: the root
261+// token and the owner's session read everything; a stored token is looked up
262+// again, so a token revoked or expired since reads nothing, and its scopes and
263+// repository list apply; a run that records no pusher reads what an anonymous
264+// caller reads. The repository of the run itself needs no check: the job reads
265+// it with its own token.
266+func (c *CI) pusherCanRead(ctx context.Context, by pusherAccess, runRepo domain.RepoName, repo domain.Repo) (bool, error) {
267+ if repo.Name == runRepo {
268+ return true, nil
269+ }
270+ var p domain.Principal
271+ switch by.pusher.Kind {
272+ case domain.CIPusherRoot:
273+ p = domain.Root()
274+ case domain.CIPusherOwner:
275+ p = domain.Owner()
276+ case domain.CIPusherToken:
277+ if c.cfg.Tokens == nil {
278+ return false, nil
279+ }
280+ tok, err := c.cfg.Tokens.Get(ctx, by.pusher.TokenID)
281+ if errors.Is(err, domain.ErrNotFound) {
282+ return false, nil
283+ }
284+ if err != nil {
285+ return false, err
286+ }
287+ if tok.Expired(c.now()) {
288+ return false, nil
289+ }
290+ p = domain.TokenPrincipal(tok)
291+ default:
292+ p = domain.Anonymous()
293+ }
294+ return canRead(p, repo), nil
295+}
296+
297+// actionRef is a parsed "uses": owner/repo[/path]@ref.
298+type actionRef struct {
299+ uses, owner, repo, path, ref string
300+ // err is GitHub's message for a reference of the wrong shape.
301+ err string
302+}
303+
304+// key identifies the resolution: GitHub names are case-insensitive, refs are
305+// not.
306+func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref }
307+
308+// parseActionRef splits a "uses" of a repository action. Only the shape
309+// owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and
310+// ignores empty segments (WorkflowTemplateConverter.cs), so do we.
311+func parseActionRef(uses string) actionRef {
312+ a := actionRef{uses: uses}
313+ left, ref, ok := strings.Cut(uses, "@")
314+ segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' })
315+ if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" {
316+ a.err = errActionFormat(uses)
317+ return a
318+ }
319+ for _, s := range segs[2:] {
320+ if s == "." || s == ".." {
321+ a.err = errActionFormat(uses)
322+ return a
323+ }
324+ }
325+ if !validActionName(segs[0]) || !validActionName(segs[1]) {
326+ a.err = errActionFormat(uses)
327+ return a
328+ }
329+ a.owner, a.repo, a.ref = segs[0], segs[1], ref
330+ a.path = strings.Join(segs[2:], "/")
331+ return a
332+}
333+
334+// validActionName reports whether s can be an owner or a repository name on
335+// GitHub: letters, digits, '.', '_' and '-', 1 to 100 characters, not made of
336+// dots only ("." and ".." are path elements). Every source takes the same names, so that nothing but a name reaches a
337+// URL, a path or a command.
338+func validActionName(s string) bool {
339+ if s == "" || len(s) > 100 || strings.Trim(s, ".") == "" {
340+ return false
341+ }
342+ for _, r := range s {
343+ switch {
344+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
345+ default:
346+ return false
347+ }
348+ }
349+ return true
350+}
351+
352+// --- GitHub's texts ---
353+//
354+// All the messages a reference can fail with are here. The first is the
355+// runner's own parser text; the two others are the ones GitHub gives for an
356+// unknown version and an unknown repository, to be confirmed by recording
357+// against github.com (ci2-execution plan, phase A).
358+
359+func errActionFormat(uses string) string {
360+ return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'"
361+}
362+
363+func errUnresolvedRef(a actionRef) string {
364+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`"
365+}
366+
367+func errUnresolvedRepo(a actionRef) string {
368+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found"
369+}
370+
371+// resolveAction resolves one reference. msg is the message of a reference that
372+// does not resolve; err is a failure of the call.
373+func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs, by pusherAccess) (res domain.CIActionResolution, msg string, err error) {
374+ res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
375+ repo, onErmite, err := c.ermiteRepoOf(ctx, a)
376+ if err != nil {
377+ return res, "", err
378+ }
379+ if onErmite {
380+ // The repository exists here, so github.com never answers for it. A
381+ // repository the pusher may not read answers as one that does not exist,
382+ // before any ref is looked at, so that the texts leak nothing about it.
383+ if ok, err := c.pusherCanRead(ctx, by, j.Repo, repo); err != nil {
384+ return res, "", err
385+ } else if !ok {
386+ return res, errUnresolvedRepo(a), nil
387+ }
388+ sha, found, err := c.resolveOnErmite(ctx, repo.Name, a.ref)
389+ if err != nil {
390+ return res, "", err
391+ }
392+ if !found {
393+ return res, errUnresolvedRef(a), nil
394+ }
395+ res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+repo.Name.String(), sha, domain.CIActionErmite
396+ return res, "", nil
397+ }
398+ res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
399+ if low := strings.ToLower(a.ref); isHex40(low) {
400+ res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download
401+ return res, "", nil
402+ }
403+ refs, err := remote.list(ctx, a.owner, a.repo)
404+ switch {
405+ case errors.Is(err, domain.ErrNotFound):
406+ return res, errUnresolvedRepo(a), nil
407+ case err != nil:
408+ return res, "", err
409+ }
410+ sha := pickRemoteRef(refs, a.ref)
411+ if sha == "" {
412+ return res, errUnresolvedRef(a), nil
413+ }
414+ res.ResolvedSHA = sha
415+ return res, "", nil
416+}
417+
418+// pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a
419+// remote. An annotated tag gives its peeled commit.
420+func pickRemoteRef(refs []ports.RemoteRef, name string) string {
421+ for _, prefix := range []string{"refs/tags/", "refs/heads/"} {
422+ for _, r := range refs {
423+ if r.Name != prefix+name {
424+ continue
425+ }
426+ if r.Peeled != "" {
427+ return r.Peeled
428+ }
429+ return r.ID
430+ }
431+ }
432+ return ""
433+}
434+
435+// resolveOnErmite resolves a ref on a repository of this server: a tag, else
436+// a branch, else a full commit id that exists.
437+func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) {
438+ refs, err := c.cfg.Reader.ListRefs(ctx, repo)
439+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
440+ return "", false, err
441+ }
442+ for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} {
443+ i := slices.IndexFunc(refs, func(r domain.Ref) bool {
444+ return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero()
445+ })
446+ if i >= 0 {
447+ return refs[i].CommitID.String(), true, nil
448+ }
449+ }
450+ if id, perr := domain.ParseObjectID(ref); perr == nil {
451+ ok, err := c.cfg.Git.HasCommit(ctx, repo, id)
452+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
453+ return "", false, err
454+ }
455+ if ok {
456+ return id.String(), true, nil
457+ }
458+ }
459+ return "", false, nil
460+}
461+
462+// remoteRefs lists the refs of a remote once per repository within a call.
463+type remoteRefs struct {
464+ remote ports.GitRemote
465+ slots chan struct{}
466+ seen map[string]remoteList
467+}
468+
469+type remoteList struct {
470+ refs []ports.RemoteRef
471+ err error
472+}
473+
474+func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {
475+ return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}
476+}
477+
478+func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
479+ if r.remote == nil {
480+ return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
481+ }
482+ key := strings.ToLower(owner + "/" + repo)
483+ if l, ok := r.seen[key]; ok {
484+ return l.refs, l.err
485+ }
486+ // At most a few ls-remote at once on the whole server, whatever the number
487+ // of jobs resolving: a process each, and github.com is slow to answer.
488+ select {
489+ case r.slots <- struct{}{}:
490+ defer func() { <-r.slots }()
491+ case <-ctx.Done():
492+ return nil, ctx.Err()
493+ }
494+ refs, err := r.remote.ListRefs(ctx, owner, repo)
495+ if ctx.Err() != nil {
496+ return nil, ctx.Err()
497+ }
498+ r.seen[key] = remoteList{refs, err}
499+ return refs, err
500+}
A internal/usecase/ci_actions_test.go +68 -0
@@ -0,0 +1,68 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+func TestParseActionRef(t *testing.T) {
15+ t.Parallel()
16+ tests := []struct {
17+ uses string
18+ owner, repo, path, ref, fail string
19+ }{
20+ {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"},
21+ {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"},
22+ {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"},
23+ {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators
24+ {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments
25+ {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"},
26+ {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"},
27+ {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"},
28+ {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"},
29+ {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
30+ {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
31+ {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
32+ {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"},
33+ {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"},
34+ {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"},
35+ {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"},
36+ {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"},
37+ {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"},
38+ {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
39+ }
40+ for _, tt := range tests {
41+ t.Run(tt.uses, func(t *testing.T) {
42+ t.Parallel()
43+ a := parseActionRef(tt.uses)
44+ assert.Equal(t, tt.fail, a.err)
45+ if tt.fail == "" {
46+ assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref})
47+ }
48+ })
49+ }
50+ assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive")
51+ assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not")
52+}
53+
54+func TestPickRemoteRef(t *testing.T) {
55+ t.Parallel()
56+ refs := []ports.RemoteRef{
57+ {Name: "refs/heads/v1", ID: "b1"},
58+ {Name: "refs/tags/v1", ID: "t1"},
59+ {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"},
60+ {Name: "refs/heads/main", ID: "m"},
61+ }
62+ assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch")
63+ assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag")
64+ assert.Equal(t, "m", pickRemoteRef(refs, "main"))
65+ assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only")
66+ assert.Empty(t, pickRemoteRef(refs, "nope"))
67+ assert.Empty(t, pickRemoteRef(nil, "v1"))
68+}
A internal/usecase/ci_githubapi.go +181 -0
@@ -0,0 +1,181 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "strings"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// The REST subset (ADR 0025, CI-2): what the actions that run on ermite call
17+// on github.api_url. The access rule is the forge's: a repository is read by
18+// anyone when public, and with repo:read when private, which the principal of a
19+// job repository token holds on the repository of its run (and the archive
20+// tokens on theirs). The holder of an archive token reads none of it but its
21+// own archive.
22+
23+var _ ports.GitHubAPIService = (*CI)(nil)
24+
25+// githubRepo looks the repository up as GitHub names it, owner/repo. Another
26+// owner, a name that is not one and a repository the caller cannot read are all
27+// not found.
28+func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {
29+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
30+ return domain.Repo{}, domain.ErrNotFound
31+ }
32+ rn, err := domain.ParseRepoName(strings.ToLower(name))
33+ if err != nil {
34+ return domain.Repo{}, domain.ErrNotFound
35+ }
36+ return c.readableRepo(ctx, p, rn)
37+}
38+
39+// GetRepo returns GET /repos/{owner}/{repo}.
40+//
41+// The object is the repository of the push event (repoObj: real values where
42+// ermite has them, GitHub's for a repository without the feature elsewhere)
43+// with the REST differences: dates are ISO 8601 strings, and the permissions of
44+// the caller are given, as GitHub does for an authenticated request.
45+func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) {
46+ repo, err := c.githubRepo(ctx, p, owner, name)
47+ if err != nil {
48+ return nil, err
49+ }
50+ branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name)
51+ if errors.Is(err, domain.ErrNotFound) {
52+ return nil, domain.ErrNotFound
53+ }
54+ if err != nil {
55+ return nil, err
56+ }
57+ if branch == "" {
58+ branch = "main"
59+ }
60+ u := c.urls(repo)
61+ created, pushed := repo.CreatedAt, repo.PushedAt
62+ obj := c.repoObj(repo, u, branch, created)
63+ obj["created_at"] = created.UTC().Format(timeLayout)
64+ if pushed.IsZero() {
65+ pushed = created
66+ }
67+ obj["pushed_at"] = pushed.UTC().Format(timeLayout)
68+ obj["updated_at"] = pushed.UTC().Format(timeLayout)
69+ delete(obj, "master_branch") // push event only
70+ delete(obj, "stargazers")
71+ delete(obj, "watchers")
72+ delete(obj, "forks")
73+ delete(obj, "open_issues")
74+ for k, v := range map[string]any{
75+ "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
76+ "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77+ "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
78+ "use_squash_pr_title_as_default": false, "has_pull_requests": true,
79+ } {
80+ obj[k] = v
81+ }
82+ if !p.IsAnonymous() {
83+ write := canWrite(p, repo.Name)
84+ obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true}
85+ }
86+ return json.Marshal(obj)
87+}
88+
89+// timeLayout is the ISO 8601 form of GitHub's REST dates.
90+const timeLayout = "2006-01-02T15:04:05Z"
91+
92+// HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks
93+// it before it runs git init. ermite repositories are SHA-1.
94+func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) {
95+ if _, err := c.githubRepo(ctx, p, owner, name); err != nil {
96+ return "", err
97+ }
98+ return "sha1", nil
99+}
100+
101+// archiveTarget finds the repository and the commit of an archive request and
102+// checks the access to it. A reader of the repository (the job's own token, any
103+// principal with repo:read, anyone for a public one) gets any ref. The holder of
104+// an archive token (domain.RepoTokenPrincipal) gets its commit of its
105+// repository and nothing else: the ref may be anything that resolves to exactly
106+// that commit, and every other answer is not found.
107+func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) {
108+ var repo domain.Repo
109+ grantRepo, grantSha, granted := p.ArchiveGrant()
110+ if granted {
111+ if !domain.OwnerMatches(c.cfg.Owner, owner) {
112+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
113+ }
114+ rn, err := domain.ParseRepoName(strings.ToLower(name))
115+ if err != nil || rn != grantRepo {
116+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
117+ }
118+ if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil {
119+ return domain.Repo{}, domain.ObjectID{}, err
120+ }
121+ } else {
122+ var err error
123+ if repo, err = c.githubRepo(ctx, p, owner, name); err != nil {
124+ return domain.Repo{}, domain.ObjectID{}, err
125+ }
126+ }
127+ rev, err := domain.ParseRevision(ref)
128+ if err != nil {
129+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
130+ }
131+ res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
132+ if err != nil {
133+ return domain.Repo{}, domain.ObjectID{}, err
134+ }
135+ if granted && res.CommitID.String() != grantSha {
136+ return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
137+ }
138+ return repo, res.CommitID, nil
139+}
140+
141+func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string {
142+ return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7]
143+}
144+
145+// TarballName answers a HEAD of the archive: the access and the ref are
146+// checked as for the archive, and the name of the file is returned, without
147+// running git archive.
148+func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) {
149+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
150+ if err != nil {
151+ return "", err
152+ }
153+ return c.archiveDir(repo, commit) + ".tar.gz", nil
154+}
155+
156+// Tarball streams the archive of a ref as `git archive` makes it, in one
157+// response (GitHub answers 302 to codeload: ermite serves it itself). The files
158+// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
159+// GitHub names it.
160+func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
161+ repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
162+ if err != nil {
163+ return err
164+ }
165+ dir := c.archiveDir(repo, commit)
166+ return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
167+}
168+
169+// lazyWriter calls begin at the first write, so that nothing is sent before git
170+// produced a byte.
171+type lazyWriter struct {
172+ begin func() io.Writer
173+ w io.Writer
174+}
175+
176+func (l *lazyWriter) Write(b []byte) (int, error) {
177+ if l.w == nil {
178+ l.w = l.begin()
179+ }
180+ return l.w.Write(b)
181+}
M internal/usecase/ci_plan.go +5 -17
@@ -52,32 +52,19 @@ func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error
52 return wf, nil52 return wf, nil
53 }53 }
54 54
55-// unsupported names the feature of a job that CI-1 does not run, or "". Such55+// unsupported names the feature of a job that ermite does not run yet, or "".
56-// a job fails with "not supported yet: <feature>" instead of running wrong.56+// Such a job fails with "not supported yet: <feature>" instead of running
57-// Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3).57+// wrong. CI-2 runs steps with uses:, job container:, services: and step
58+// timeout-minutes (ADR 0025); what stays is CI-3.
58 func unsupported(j *workflow.Job) string {59 func unsupported(j *workflow.Job) string {
59 switch {60 switch {
60 case j.IsReusable():61 case j.IsReusable():
61 return "reusable workflows (uses at the job level)"62 return "reusable workflows (uses at the job level)"
62- case j.Container != nil:
63- return "container"
64- case j.Services != nil:
65- return "services"
66 case j.Environment != nil:63 case j.Environment != nil:
67 return "environment"64 return "environment"
68 case j.Concurrency != nil:65 case j.Concurrency != nil:
69 return "concurrency"66 return "concurrency"
70 }67 }
71- for _, s := range j.Steps {
72- switch {
73- case s.Uses != nil:
74- return "uses: " + s.Uses.Value
75- case s.TimeoutMinutes != nil:
76- // The runner refuses it too (executor.notSupported): it has no
77- // step timeout yet.
78- return "timeout-minutes on a step"
79- }
80- }
81 return ""68 return ""
82 }69 }
83 70
@@ -253,6 +240,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
253 if j.HasToken() {240 if j.HasToken() {
254 j.TokenValidUntil = now.Add(domain.CITokenGrace)241 j.TokenValidUntil = now.Add(domain.CITokenGrace)
255 }242 }
243+ rs.revokeTokens(j)
256 rs.touch(j)244 rs.touch(j)
257 rs.c.mu.Lock()245 rs.c.mu.Lock()
258 delete(rs.c.heartbeats, jobRef(*j))246 delete(rs.c.heartbeats, jobRef(*j))
A internal/usecase/ci_repotoken.go +71 -0
@@ -0,0 +1,71 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "crypto/rand"
7+ "encoding/hex"
8+ "slices"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// Job repository tokens (ADR 0025, CI-2). A job gets one at its assignment:
15+// "ermitegh_" and 32 random bytes, stored hashed, read-only on the repository
16+// of its run. Auth.Authenticate resolves it to a principal shaped like a
17+// stored token, so git over HTTP, the API and the REST subset need no rule of
18+// their own. It is written in the batch of the assignment, deleted in the
19+// batch that ends the job (CompleteJob, a cancellation that concludes, a lost
20+// runner, a timeout, the queue and log limits) or that takes the assignment
21+// back, and with the run when the run is deleted. Its expiry is the deadline of
22+// the job plus a few minutes, whatever happens.
23+
24+func newRepoTokenID() string {
25+ var raw [8]byte
26+ _, _ = rand.Read(raw[:])
27+ return hex.EncodeToString(raw[:])
28+}
29+
30+// repoTokenExpiry is when a token of the job expires at the latest: the
31+// deadline of the job and the grace.
32+func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) {
33+ expires.ExpiresAt = c.deadline(j).Add(domain.RepoTokenGrace)
34+ return expires
35+}
36+
37+// issueRepoToken adds a repository token for j to the next flush and returns
38+// its secret. j must be assigned (its AssignedAt is set).
39+func (rs *runState) issueRepoToken(j *domain.CIJob) string {
40+ return rs.issueRepoTokenFor(j, rs.run.Repo, "")
41+}
42+
43+// issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes
44+// it the archive token of that commit of repo, which downloads that archive and
45+// nothing else.
46+func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string {
47+ c := rs.c
48+ now := c.now()
49+ at := *j
50+ if at.AssignedAt.IsZero() {
51+ at.AssignedAt = now
52+ }
53+ secret := newToken(domain.RepoTokenPrefix)
54+ t := c.repoTokenExpiry(at)
55+ t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
56+ t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
57+ rs.tokens = append(rs.tokens, t)
58+ return secret
59+}
60+
61+// revokeTokens deletes, with the next flush, every repository token of j, and
62+// forgets the archive tokens kept in memory.
63+func (rs *runState) revokeTokens(j *domain.CIJob) {
64+ ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID}
65+ if !slices.Contains(rs.revoke, ref) {
66+ rs.revoke = append(rs.revoke, ref)
67+ }
68+ rs.c.mu.Lock()
69+ delete(rs.c.archiveTokens, jobRef(*j))
70+ rs.c.mu.Unlock()
71+}
M internal/usecase/ci_runner.go +6 -5
@@ -207,16 +207,16 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208 return errNoJob208 return errNoJob
209 }209 }
210- out = c.runnerJob(rs, *j, rec.token)210+ out = c.runnerJob(rs, *j, rec.token, rec.ghToken)
211 return nil211 return nil
212 })212 })
213 return out, err == nil && out != nil213 return out, err == nil && out != nil
214 }214 }
215 215
216 // runnerJob is the job as the runner gets it.216 // runnerJob is the job as the runner gets it.
217-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token string) *ports.RunnerJob {217+func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob {
218 return &ports.RunnerJob{218 return &ports.RunnerJob{
219- Job: j, Token: token, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,219+ Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
221 }221 }
222 }222 }
@@ -241,6 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
241 return errNoJob241 return errNoJob
242 }242 }
243 token := newToken(domain.JobTokenPrefix)243 token := newToken(domain.JobTokenPrefix)
244+ ghToken := rs.issueRepoToken(j)
244 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
245 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
246 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation247 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
@@ -248,10 +249,10 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
248 if err := rs.flush(ctx); err != nil {249 if err := rs.flush(ctx); err != nil {
249 return err250 return err
250 }251 }
251- out = c.runnerJob(rs, *j, token)252+ out = c.runnerJob(rs, *j, token, ghToken)
252 if pollID != "" {253 if pollID != "" {
253 c.mu.Lock()254 c.mu.Lock()
254- c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, hash: j.TokenHash}255+ c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}
255 c.mu.Unlock()256 c.mu.Unlock()
256 }257 }
257 return nil258 return nil
M internal/usecase/ci_trigger.go +17 -3
@@ -36,6 +36,7 @@ type ciTrigger struct {
36 repo domain.RepoName36 repo domain.RepoName
37 updates []domain.RefUpdate37 updates []domain.RefUpdate
38 actor string38 actor string
39+ pusher domain.CIPusher
39 at time.Time40 at time.Time
40 log zerowrap.Logger41 log zerowrap.Logger
41 }42 }
@@ -44,6 +45,19 @@ type ciTrigger struct {
44 // a worker plans them. A full queue drops the trigger, logged at error: a push45 // a worker plans them. A full queue drops the trigger, logged at error: a push
45 // is never slowed down or failed by CI.46 // is never slowed down or failed by CI.
46 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {47 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
48+ c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher))
49+}
50+
51+// OnMerge queues the move of a target branch by a merge. The actor of the run is
52+// the owner, as GitHub shows; the pusher recorded with it, whose rights the
53+// actions of other repositories are resolved with, is the principal that merged:
54+// a token limited to some repositories does not gain the owner's reach by
55+// merging.
56+func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
57+ c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger))
58+}
59+
60+func (c *CI) enqueue(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher) {
47 var kept []domain.RefUpdate61 var kept []domain.RefUpdate
48 for _, u := range updates {62 for _, u := range updates {
49 if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) {63 if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) {
@@ -53,7 +67,7 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
53 if len(kept) == 0 {67 if len(kept) == 0 {
54 return68 return
55 }69 }
56- t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}70+ t := ciTrigger{repo: repo, updates: kept, actor: actor, pusher: pusher, at: c.now(), log: zerowrap.FromCtx(ctx)}
57 c.mu.Lock()71 c.mu.Lock()
58 closed := c.closed72 closed := c.closed
59 c.mu.Unlock()73 c.mu.Unlock()
@@ -384,7 +398,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
384 CreatedAt: now, StartedAt: now, CompletedAt: now,398 CreatedAt: now, StartedAt: now, CompletedAt: now,
385 }399 }
386 return ports.CIRunDraft{400 return ports.CIRunDraft{
387- Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},401+ Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}", Pusher: t.pusher},
388 Checks: []domain.CICheck{{402 Checks: []domain.CICheck{{
389 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,403 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
390 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,404 Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: name, CreatedAt: now, StartedAt: now, CompletedAt: now,
@@ -436,7 +450,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
436 run.Name, run.Title = runName, runName450 run.Name, run.Title = runName, runName
437 }451 }
438 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{452 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
439- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON,453+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: githubJSON, Pusher: t.pusher,
440 }}454 }}
441 for i, j := range wf.Jobs {455 for i, j := range wf.Jobs {
442 job := domain.CIJob{456 job := domain.CIJob{
M internal/usecase/ci_watch.go +1 -0
@@ -189,6 +189,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
189 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}189 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone190 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
191 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}191 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
192+ rs.revokeTokens(j) // the next assignment gets new ones
192 c.forgetPoll(jobRef(*j))193 c.forgetPoll(jobRef(*j))
193 if j.CancelReason != domain.CICancelNone {194 if j.CancelReason != domain.CICancelNone {
194 // Cancelled (by the owner, fail-fast or the time budget) before the195 // Cancelled (by the owner, fail-fast or the time budget) before the
M internal/usecase/mergerequests.go +3 -2
@@ -814,8 +814,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
814 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,814 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
815 })815 })
816 if m.ci != nil {816 if m.ci != nil {
817- // GitHub runs the "push" workflows of a merge, with the owner as pusher.817+ // GitHub runs the "push" workflows of a merge, with the owner as actor;
818- m.ci.OnPush(ctx, name, updates[:1], domain.Owner())818+ // the run is trusted with what the merging principal may read.
819+ m.ci.OnMerge(ctx, name, updates[:1], p)
819 }820 }
820 return mr, deleted, nil821 return mr, deleted, nil
821 }822 }
M internal/usecase/tokens.go +58 -0
@@ -273,6 +273,9 @@ type Auth struct {
273 root domain.TokenHash273 root domain.TokenHash
274 tokens ports.TokenStore274 tokens ports.TokenStore
275 clock ports.Clock275 clock ports.Clock
276+ // repoTokens resolves the job repository tokens (domain.RepoTokenPrefix);
277+ // nil when CI is not wired.
278+ repoTokens ports.CIRepoTokenStore
276 }279 }
277 280
278 var (281 var (
@@ -286,6 +289,17 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo
286 return &Auth{root: rootHash, tokens: tokens, clock: clock}289 return &Auth{root: rootHash, tokens: tokens, clock: clock}
287 }290 }
288 291
292+// WithRepoTokens makes Authenticate accept the job repository tokens of CI
293+// (the "github.token" of a job, ADR 0025). They resolve to a principal shaped
294+// like a stored token (repo:read on one repository, with an expiry), so no
295+// rule of the forge knows them; they are stored apart from the stored tokens,
296+// so ListTokens never shows them and token:admin never manages them. Call it
297+// once while wiring.
298+func (a *Auth) WithRepoTokens(s ports.CIRepoTokenStore) *Auth {
299+ a.repoTokens = s
300+ return a
301+}
302+
289 // Revalidate checks that the holder of a principal obtained earlier is still303 // Revalidate checks that the holder of a principal obtained earlier is still
290 // authorized: root always is; a stored token must still exist and not have304 // authorized: root always is; a stored token must still exist and not have
291 // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the305 // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
@@ -298,6 +312,9 @@ func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
298 if p.IsAnonymous() {312 if p.IsAnonymous() {
299 return domain.ErrUnauthenticated313 return domain.ErrUnauthenticated
300 }314 }
315+ if id := p.TokenID(); domain.IsRepoTokenID(id) {
316+ return a.revalidateRepoToken(ctx, strings.TrimPrefix(id, "job:"))
317+ }
301 tok, err := a.tokens.Get(ctx, p.TokenID())318 tok, err := a.tokens.Get(ctx, p.TokenID())
302 if errors.Is(err, domain.ErrNotFound) {319 if errors.Is(err, domain.ErrNotFound) {
303 return domain.ErrUnauthenticated320 return domain.ErrUnauthenticated
@@ -325,6 +342,9 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
325 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {342 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
326 return domain.Root(), nil343 return domain.Root(), nil
327 }344 }
345+ if domain.IsRepoToken(secret) {
346+ return a.authenticateRepoToken(ctx, h)
347+ }
328 tok, err := a.tokens.FindByHash(ctx, h)348 tok, err := a.tokens.FindByHash(ctx, h)
329 if errors.Is(err, domain.ErrNotFound) {349 if errors.Is(err, domain.ErrNotFound) {
330 return domain.Principal{}, domain.ErrUnauthenticated350 return domain.Principal{}, domain.ErrUnauthenticated
@@ -344,3 +364,41 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
344 }364 }
345 return domain.TokenPrincipal(tok), nil365 return domain.TokenPrincipal(tok), nil
346 }366 }
367+
368+// authenticateRepoToken resolves a job repository token: it exists (it is
369+// deleted when its job ends, is cancelled or lost) and has not expired.
370+func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (domain.Principal, error) {
371+ if a.repoTokens == nil {
372+ return domain.Principal{}, domain.ErrUnauthenticated
373+ }
374+ t, err := a.repoTokens.FindRepoTokenByHash(ctx, h)
375+ if errors.Is(err, domain.ErrNotFound) {
376+ return domain.Principal{}, domain.ErrUnauthenticated
377+ }
378+ if err != nil {
379+ return domain.Principal{}, fmt.Errorf("find repository token: %w", err)
380+ }
381+ if t.Expired(a.clock.Now().UTC()) {
382+ return domain.Principal{}, domain.ErrUnauthenticated
383+ }
384+ return domain.RepoTokenPrincipal(t), nil
385+}
386+
387+// revalidateRepoToken is Revalidate for the principal of a job repository
388+// token.
389+func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error {
390+ if a.repoTokens == nil {
391+ return domain.ErrUnauthenticated
392+ }
393+ t, err := a.repoTokens.GetRepoToken(ctx, id)
394+ if errors.Is(err, domain.ErrNotFound) {
395+ return domain.ErrUnauthenticated
396+ }
397+ if err != nil {
398+ return fmt.Errorf("find repository token: %w", err)
399+ }
400+ if t.Expired(a.clock.Now().UTC()) {
401+ return domain.ErrUnauthenticated
402+ }
403+ return nil
404+}
M internal/usecase/tokens_test.go +72 -0
@@ -668,3 +668,75 @@ func TestTokensCreateBranchPrefixes(t *testing.T) {
668 assert.Equal(t, "mine", got[0].ID)668 assert.Equal(t, "mine", got[0].ID)
669 })669 })
670 }670 }
671+
672+// The job repository tokens of CI: Authenticate resolves them to a principal
673+// shaped like a stored token, and only while they exist and have not expired.
674+func TestAuthRepoTokens(t *testing.T) {
675+ t.Parallel()
676+ ctx := context.Background()
677+ secret := domain.RepoTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen)
678+ h := domain.HashTokenSecret(secret)
679+ repo := name(t, "proj")
680+ tok := domain.CIRepoToken{ID: "id1", Hash: h, Repo: repo, ExpiresAt: now.Add(time.Minute)}
681+
682+ t.Run("a live token reads its repository", func(t *testing.T) {
683+ t.Parallel()
684+ f := newTokenFixture(t)
685+ rt := mocks.NewCIRepoTokenStore(t)
686+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(tok, nil)
687+ f.auth.WithRepoTokens(rt)
688+ p, err := f.auth.Authenticate(ctx, secret)
689+ require.NoError(t, err)
690+ assert.True(t, p.Allows(domain.ScopeRepoRead, repo))
691+ assert.False(t, p.Allows(domain.ScopeRepoWrite, repo))
692+ assert.False(t, p.Allows(domain.ScopeTokenAdmin, repo))
693+ })
694+ t.Run("the stored tokens are never asked", func(t *testing.T) {
695+ t.Parallel()
696+ f := newTokenFixture(t) // its TokenStore mock fails on any call
697+ rt := mocks.NewCIRepoTokenStore(t)
698+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, domain.ErrNotFound)
699+ f.auth.WithRepoTokens(rt)
700+ _, err := f.auth.Authenticate(ctx, secret)
701+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked or unknown token")
702+ })
703+ t.Run("expired", func(t *testing.T) {
704+ t.Parallel()
705+ f := newTokenFixture(t)
706+ rt := mocks.NewCIRepoTokenStore(t)
707+ old := tok
708+ old.ExpiresAt = now
709+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(old, nil)
710+ f.auth.WithRepoTokens(rt)
711+ _, err := f.auth.Authenticate(ctx, secret)
712+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
713+ })
714+ t.Run("a store failure is not unauthenticated", func(t *testing.T) {
715+ t.Parallel()
716+ f := newTokenFixture(t)
717+ rt := mocks.NewCIRepoTokenStore(t)
718+ boom := errors.New("disk")
719+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, boom)
720+ f.auth.WithRepoTokens(rt)
721+ _, err := f.auth.Authenticate(ctx, secret)
722+ assert.ErrorIs(t, err, boom)
723+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
724+ })
725+ t.Run("without CI wired the shape is refused", func(t *testing.T) {
726+ t.Parallel()
727+ f := newTokenFixture(t)
728+ _, err := f.auth.Authenticate(ctx, secret)
729+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
730+ })
731+ t.Run("revalidate follows the token, not the stored tokens", func(t *testing.T) {
732+ t.Parallel()
733+ f := newTokenFixture(t)
734+ rt := mocks.NewCIRepoTokenStore(t)
735+ rt.EXPECT().GetRepoToken(ctx, "id1").Return(tok, nil).Once()
736+ rt.EXPECT().GetRepoToken(ctx, "id1").Return(domain.CIRepoToken{}, domain.ErrNotFound).Once()
737+ f.auth.WithRepoTokens(rt)
738+ p := domain.RepoTokenPrincipal(tok)
739+ assert.NoError(t, f.auth.Revalidate(ctx, p))
740+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated, "revoked since")
741+ })
742+}
M proto/ermite/storage/ci.proto +40 -0
@@ -112,6 +112,11 @@ message CIRunSource {
112 // Repository of the run: the key of the run record is run/<repo>/<id>, and112 // Repository of the run: the key of the run record is run/<repo>/<id>, and
113 // the runner side knows a run by its id only.113 // the runner side knows a run by its id only.
114 string repo = 3;114 string repo = 3;
115+ // Who pushed the commit: "root", "owner", "token" or empty (a run stored
116+ // before it was recorded, or pushed by nobody). pusher_token_id is the ID of
117+ // the stored token for "token". What a token may read is looked up live.
118+ string pusher_kind = 5;
119+ string pusher_token_id = 6;
115 }120 }
116 121
117 // CIStep is a step of a job.122 // CIStep is a step of a job.
@@ -220,3 +225,38 @@ message CIRunner {
220 google.protobuf.Timestamp registered_at = 8;225 google.protobuf.Timestamp registered_at = 8;
221 bytes token_hash = 9;226 bytes token_hash = 9;
222 }227 }
228+
229+// CIRepoToken is a job repository token (the "github.token" of a job, or the
230+// token of one action archive), stored under "ghtoken/<id>". The secret is
231+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id,
232+// "ghtokenjob/<run:020d>/<run attempt:010d>/<job id>/<id>" lists the tokens of a
233+// job so that they are revoked together, and "ghtokenrepo/<repo>/<id>" the
234+// tokens that read a repository, so that they go with it.
235+message CIRepoToken {
236+ string id = 1;
237+ bytes hash = 2;
238+ // The one repository the token reads.
239+ string repo = 3;
240+ // The job the token belongs to: it is revoked when the job ends.
241+ int64 run_id = 4;
242+ int32 run_attempt = 5;
243+ string job_id = 6;
244+ // Set for the token of an action archive, unset for the job's own token.
245+ bool archive = 7;
246+ google.protobuf.Timestamp expires_at = 8;
247+ google.protobuf.Timestamp created_at = 9;
248+ // The commit an archive token downloads.
249+ string sha = 10;
250+}
251+
252+// CIActionResolution is the resolution of an action reference for a run
253+// attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
254+// so that every job of the attempt gets the same commit.
255+message CIActionResolution {
256+ // "owner/repo@ref", lower-cased owner and repository.
257+ string key = 1;
258+ string name_with_owner = 2;
259+ string resolved_sha = 3;
260+ // 1 = github.com, 2 = this server (the numbers of ActionSource).
261+ int32 source = 4;
262+}

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