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:
7373 - rule: no-adapter-to-adapter-imports
7474 path: internal/adapters/githttp/**
7575 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
7679 - rule: no-adapter-to-adapter-imports
7780 path: internal/adapters/rpc/**
7881 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
9393 git push http://u:<token>@git.example.com/<repo>.git main
9494 ```
9595
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+
9698 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`).
9799
98100 ## Branch-limited tokens
@@ -116,7 +118,15 @@ ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a
116118
117119 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.
118120
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`.
120130
121131 ## Web sign-in
122132
M gen/ermite/storage/ci.pb.go +277 -32 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362
16- bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6
16+ bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 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{
3838 "POST /{repo}/git-receive-pack",
3939 }
4040
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+
4158 // DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
4259 // is zero.
4360 const DefaultMaxBodyBytes int64 = 4 << 30
@@ -67,6 +84,9 @@ type Config struct {
6784 Repos ports.RepoLookup
6885 Pushes ports.PushService
6986 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
7090 // Hooks provides hook tokens for pushes. It is required.
7191 Hooks ports.HookTokens
7292 // TrustedProxies are the networks allowed to set X-Forwarded-For.
@@ -146,12 +166,36 @@ type route struct {
146166
147167 func (rt route) push() bool { return rt.service == serviceReceive }
148168
169+// gitSuffixes are the ends of the paths the handler serves, after the
170+// repository.
171+var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceReceive}
172+
149173 // parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
150-// and /<repo>/git-receive-pack. info/refs without a service is the dumb
151-// protocol, which ermite does not serve.
152-func parseRoute(r *http.Request) (route, int) {
153- seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
154- if !ok || seg == "" {
174+// and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
175+// the forge. info/refs without a service is the dumb protocol, which ermite
176+// does not serve.
177+func (h *Handler) parseRoute(r *http.Request) (route, int) {
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, "/") {
155199 return route{}, http.StatusNotFound
156200 }
157201 rt := route{repo: seg, suffix: rest}
@@ -175,8 +219,6 @@ func parseRoute(r *http.Request) (route, int) {
175219 return route{}, http.StatusMethodNotAllowed
176220 }
177221 rt.service = rest
178- default:
179- return route{}, http.StatusNotFound
180222 }
181223 return rt, 0
182224 }
@@ -198,7 +240,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
198240 Msg("git request")
199241 }()
200242
201- rt, status := parseRoute(r)
243+ rt, status := h.parseRoute(r)
202244 if status != 0 {
203245 http.Error(w, http.StatusText(status), status)
204246 return
@@ -265,7 +307,8 @@ func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal,
265307 }
266308
267309 // 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 the
310+// Basic (the username is ignored, actions/checkout sends x-access-token), or
311+// the value of "Bearer" or "token". present is true when the
269312 // header exists, even if it carries nothing usable, which then fails
270313 // authentication.
271314 func credentials(r *http.Request) (secret string, present bool) {
@@ -276,7 +319,7 @@ func credentials(r *http.Request) (secret string, present bool) {
276319 if _, pass, ok := r.BasicAuth(); ok {
277320 return pass, true
278321 }
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")) {
280323 return strings.TrimSpace(tok), true
281324 }
282325 return "", true
M internal/adapters/githttp/handler_test.go +53 -2
@@ -11,6 +11,7 @@ import (
1111 "net/netip"
1212 "os"
1313 "path/filepath"
14+ "slices"
1415 "strings"
1516 "sync"
1617 "testing"
@@ -172,7 +173,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
172173 repos := mocks.NewRepoLookup(t)
173174 r.stubUseCases(repos)
174175 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",
176177 }
177178 script := cgiScript(t)
178179 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
@@ -188,7 +189,7 @@ func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
188189 }
189190 r.h = githttp.New(cfg)
190191 mux := http.NewServeMux()
191- for _, p := range githttp.Patterns {
192+ for _, p := range slices.Concat(githttp.Patterns, githttp.OwnerPatterns("acme"), githttp.OwnerPatterns("ACME")) {
192193 mux.Handle(p, r.h)
193194 }
194195 r.srv = httptest.NewServer(mux)
@@ -506,6 +507,54 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
506507 assert.Equal(t, 200, code)
507508 }
508509
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+
509558 func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510559 t.Parallel()
511560 r := newRig(t, nil)
@@ -514,6 +563,8 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
514563 assert.Equal(t, 200, code)
515564 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
516565 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")
517568 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
518569 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
519570 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 (
3737 // ciready/<ready_at:020d>/<job key> job waiting for a runner
3838 // ciactive/<job key> job assigned or running
3939 // jobtoken/<hash> job token (value: job key)
40+//
41+// The job repository tokens and the action resolutions are in ci_token.go.
4042 const (
4143 runPrefix = "run/"
4244 runSrcPrefix = "runsrc/"
@@ -507,6 +509,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
507509 guarded[j.Repo] = true
508510 }
509511 }
512+ c.stageRepoTokens(ctx, b, w, guarded)
510513 // Checks are derived from the jobs, written in the same batch and under
511514 // the lock of their run: they are upserted without a precondition.
512515 for _, ck := range w.Checks {
@@ -587,6 +590,7 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
587590 }
588591 src := mustMarshal(&storagepb.CIRunSource{
589592 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,
590594 })
591595 b.RequireAbsent(runSrcKey(draft.Run.ID))
592596 b.Put(runSrcKey(draft.Run.ID), src)
@@ -639,7 +643,10 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
639643 if err != nil {
640644 return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
641645 }
642- return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil
646+ 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
643650 }
644651
645652 // 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
828835 // runKeys lists every key of a run except its record: source, jobs, indexes.
829836 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
830837 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
838+ keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
831839 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
832840 keys = append(keys, slices.Clone(key))
833841 if j, err := decodeJob(item); err == nil {
@@ -852,6 +860,7 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
852860 }
853861 keys = append(keys, slices.Clone(key))
854862 }
863+ keys = append(keys, repoReaderKeys(ctx, snap, repo)...)
855864 for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
856865 for k := range snap.Prefix(ctx, []byte(p)) {
857866 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
2828 // Features are the optional capabilities of the contract that this server
2929 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3030 // 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"}
3232
3333 // FeaturePasskeys is listed by GetVersion when passkeys are available.
3434 const FeaturePasskeys = "passkeys"
M internal/adapters/rpc/ci.go +38 -0
@@ -33,6 +33,9 @@ var runnerSecretProcedures = map[string]bool{
3333 ermiteconnect.RunnerServiceHeartbeatProcedure: true,
3434 ermiteconnect.RunnerServiceWatchJobProcedure: true,
3535 ermiteconnect.RunnerServiceCompleteJobProcedure: true,
36+ // ResolveActions and UploadSummary are job calls too.
37+ ermiteconnect.RunnerServiceResolveActionsProcedure: true,
38+ ermiteconnect.RunnerServiceUploadSummaryProcedure: true,
3639 }
3740
3841 // 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
169172 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
170173 }
171174
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+
172199 // runService implements ermiteconnect.RunServiceHandler.
173200 type runService struct{ runs ports.RunService }
174201
@@ -306,3 +333,14 @@ func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.R
306333 }
307334 return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
308335 }
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 {
205205 JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
206206 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
207207 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,
209209 }
210210 }
211211
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+
212227 func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
213228 out := make([]ports.StepUpdate, 0, len(steps))
214229 for _, s := range steps {
M internal/adapters/rpc/errors.go +2 -0
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
5555 return connect.CodeAborted, true
5656 case errors.Is(err, domain.ErrResourceExhausted):
5757 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrUnavailable):
59+ return connect.CodeUnavailable, true
5860 }
5961 if domain.IsInvalid(err) {
6062 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) {
235235 return "", false
236236 }
237237 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 == "" {
239239 return "", false
240240 }
241241 repo, _, _ := strings.Cut(rest, "/")
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 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)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
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) {
324324 jobs:
325325 j:
326326 runs-on: ubuntu-latest
327+ environment: production
327328 steps:
328- - uses: actions/checkout@v4
329+ - run: echo
329330 `}, "")
330331 c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
331332 j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
332333 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())
334335 }
335336
336337 // 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:
307307 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
308308 }
309309
310-// A step timeout stays refused as long as the runner has none.
311-func TestCIStepTimeoutIsStillRefused(t *testing.T) {
310+// CI-2: planning runs what CI-1 refused (uses, container, services, a step
311+// timeout) and still refuses job-level uses, environment and concurrency.
312+func TestCIPlanningAcceptsCI2Features(t *testing.T) {
312313 c := newCIHarness(t, nil)
313- c.registerRunner("alpha", "ubuntu-latest")
314+ runner := c.registerRunner("alpha", "ubuntu-latest")
314315 c.push("main", map[string]string{".github/workflows/s.yml": `on: push
315316 jobs:
316317 j:
317318 runs-on: ubuntu-latest
319+ container: alpine:3
320+ services:
321+ db:
322+ image: postgres:16
318323 steps:
324+ - uses: actions/checkout@v4
319325 - timeout-minutes: 1
320326 run: echo
321327 `}, "")
322- c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
323- assert.Equal(t, "not supported yet: timeout-minutes on a step", c.getRun(c.runs()[0].GetId()).GetJobs()[0].GetError())
328+ job := runner.await()
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+ }
324354 }
325355
326356 // A failed job with continue-on-error does not fail the run, keeps its own
@@ -416,12 +446,22 @@ func TestWebRedirectsOwnerURLs(t *testing.T) {
416446 "/owner/proj": "/proj",
417447 "/owner/proj/commit/" + sha: "/proj/commit/" + sha,
418448 "/owner/proj/compare/a...b?x=1": "/proj/compare/a...b?x=1",
419- "/owner/proj.git/info/refs": "/proj.git/info/refs",
420449 } {
421450 res := b.get(path, nil)
422451 assert.Equal(t, http.StatusPermanentRedirect, res.status, path)
423452 assert.Equal(t, to, res.hdr.Get("Location"), path)
424453 }
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+ }
425465 assert.Equal(t, http.StatusNotFound, b.get("/other/proj/commit/"+sha, nil).status, "not the owner")
426466 assert.Equal(t, http.StatusNotFound, b.get("/owner", nil).status, "a bare owner is no page")
427467 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) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 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)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
M internal/app/forge.go +36 -3
@@ -20,6 +20,8 @@ import (
2020 "bnema.dev/ermite/internal/adapters/eventbus"
2121 "bnema.dev/ermite/internal/adapters/gitcli"
2222 "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/adapters/githubapi"
24+ "bnema.dev/ermite/internal/adapters/gitremote"
2325 "bnema.dev/ermite/internal/adapters/httpserver"
2426 "bnema.dev/ermite/internal/adapters/kvstore"
2527 "bnema.dev/ermite/internal/adapters/rpc"
@@ -50,11 +52,25 @@ type options struct {
5052 eventBuffer int
5153 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5254 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
5361 // ci overrides the settings of the CI engine (tests shorten its
5462 // durations).
5563 ci func(*usecase.CIConfig)
5664 }
5765
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+
5874 // forge is the wired server: every adapter and use case, ready to serve.
5975 type forge struct {
6076 cfg config.Config
@@ -161,7 +177,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
161177 Msg("repositories reconciled")
162178
163179 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())
165181 f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay)
166182 f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth)
167183
@@ -199,7 +215,12 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
199215 Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200216 Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201217 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(),
203224 }
204225 if opts.ci != nil {
205226 opts.ci(&ciCfg)
@@ -279,15 +300,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
279300 Repos: f.Repos,
280301 Pushes: f.Pushes,
281302 Backend: git,
303+ Owner: cfg.Owner,
282304 Hooks: f.hooks,
283305 TrustedProxies: cfg.TrustedProxies,
284306 Limiter: limiter,
285307 MaxBodyBytes: maxBody,
286308 })
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))
288310 for _, p := range githttp.Patterns {
289311 routes = append(routes, httpserver.Route{Pattern: p, Handler: gh})
290312 }
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+ }
291324 warnSessionsNeedHTTPS(ctx, cfg.PublicURL)
292325 warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient)
293326 site, err := newWeb(cfg, f, limiter)
M internal/app/web_passkey_test.go +2 -2
@@ -188,7 +188,7 @@ func TestPasskeyEndToEnd(t *testing.T) {
188188 assert.Equal(t, base64.RawURLEncoding.EncodeToString(p.cred.ID), listed[0].ID)
189189 assert.NotNil(t, listed[0].LastUsedAt, "the sign-in was recorded")
190190
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)
192192 assert.Contains(t, rm.stderr, "every browser is signed out")
193193 for _, tok := range []string{token, ck.Value} {
194194 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) {
197197 res = p.login()
198198 assert.Equal(t, http.StatusUnauthorized, res.status, res.body)
199199 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)
201201 require.Error(t, again.err)
202202 assert.Contains(t, again.err.Error(), "not found")
203203 }
M internal/domain/ci.go +3 -0
@@ -191,6 +191,9 @@ type CIRunSource struct {
191191 Repo RepoName
192192 WorkflowYAML string
193193 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
194197 }
195198
196199 // 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 (
3636 // ErrResourceExhausted means a limit of the server is reached, for example
3737 // the number of event watchers; the caller may retry later.
3838 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")
3943 )
4044
4145 // 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 {
101101 }
102102 return ""
103103 }
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 (
1717 kindRoot
1818 kindToken
1919 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
2024 )
2125
2226 // Principal is the authenticated identity of a caller: nobody, the root
@@ -31,6 +35,9 @@ type Principal struct {
3135 branchPrefixes []string
3236 expiresAt time.Time
3337 label string
38+ // archiveRepo and archiveSha are the grant of a kindArchive principal.
39+ archiveRepo RepoName
40+ archiveSha string
3441 }
3542
3643 // Anonymous returns the principal of a caller without credentials.
@@ -67,6 +74,8 @@ func (p Principal) Label() string {
6774 return "owner"
6875 case kindToken:
6976 return "token"
77+ case kindArchive:
78+ return "archive"
7079 }
7180 return "anonymous"
7281 }
@@ -187,3 +196,14 @@ func (p Principal) Allows(s Scope, repo RepoName) bool {
187196 // TokenLabel returns the label of the stored token, or "" for other
188197 // principals.
189198 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 {
2525 // Checks are derived from the jobs and upserted without a precondition.
2626 // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the
2727 // 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.
2833 type CIWrite struct {
29- Runs []domain.CIRun
30- Jobs []domain.CIJob
31- Checks []domain.CICheck
34+ Runs []domain.CIRun
35+ Jobs []domain.CIJob
36+ 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)
3257 }
3358
3459 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
@@ -46,6 +71,9 @@ type CIStore interface {
4671 Apply(ctx context.Context, w CIWrite) (version uint64, err error)
4772 // GetRun returns the run, or domain.ErrNotFound.
4873 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)
4977 // GetRunSource returns what the run was planned from, or
5078 // domain.ErrNotFound. It is how a run is found from its id alone.
5179 GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)
@@ -165,6 +193,11 @@ type CILogStore interface {
165193 // errors never reach the push.
166194 type WorkflowTrigger interface {
167195 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)
168201 }
169202
170203 // CIGit is what planning needs from Git beyond RepoReader and MergeGit.
@@ -200,8 +233,10 @@ type RegisteredRunner struct {
200233
201234 // RunnerJob is a job given to a runner.
202235 type RunnerJob struct {
203- Job domain.CIJob
204- Token string
236+ Job domain.CIJob
237+ Token string
238+ // GithubToken is the job repository token, the "github.token" of the job.
239+ GithubToken string
205240 Repo domain.RepoName
206241 Sha, Ref string
207242 WorkflowPath string
@@ -247,6 +282,38 @@ type CompleteJobResult struct {
247282 Applied bool
248283 }
249284
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+
250317 // JobSignal is one message of WatchJob.
251318 type JobSignal struct {
252319 Keepalive bool
@@ -278,6 +345,11 @@ type RunnerService interface {
278345 // then, because a client's call returns only when they arrive.
279346 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
280347 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)
281353 }
282354
283355 // 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
405405 return _c
406406 }
407407
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+
408486 // GetJob provides a mock function for the type CIStore
409487 func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
410488 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
417417 return _c
418418 }
419419
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+
420500 // UpdateJob provides a mock function for the type RunnerService
421501 func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
422502 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 {
4747 return &WorkflowTrigger_Expecter{mock: &_m.Mock}
4848 }
4949
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+
50108 // OnPush provides a mock function for the type WorkflowTrigger
51109 func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
52110 _mock.Called(ctx, repo, updates, pusher)
M internal/ports/ports.go +8 -0
@@ -200,6 +200,14 @@ type GitBackend interface {
200200 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
201201 }
202202
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+
203211 // TokenStore keeps the stored tokens and the index from secret hash to token.
204212 // The secret itself is never stored.
205213 type TokenStore interface {
M internal/ports/services.go +23 -0
@@ -55,6 +55,29 @@ type TokenService interface {
5555 Revoke(ctx context.Context, p domain.Principal, id string) error
5656 }
5757
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+
5881 // BrowseService is the read side of a repository's content. *usecase.Browse
5982 // implements it.
6083 type BrowseService interface {
M internal/usecase/access.go +6 -0
@@ -20,6 +20,12 @@ func canRead(p domain.Principal, repo domain.Repo) bool {
2020 // canReadAs is canRead for a repository known by its name and visibility only,
2121 // such as one that no longer exists.
2222 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+ }
2329 if vis == domain.VisibilityPublic {
2430 return true
2531 }
M internal/usecase/ci.go +54 -15
@@ -29,6 +29,11 @@ const (
2929 DefaultCIKeepalive = 30 * time.Second
3030 // DefaultCITriggerQueue is the size of the queue of pushes to plan.
3131 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
3237 // MaxCIChecks is the most checks ListChecks returns.
3338 MaxCIChecks = 1000
3439 // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log
@@ -56,6 +61,20 @@ type CIConfig struct {
5661 Clock ports.Clock
5762 // Auth re-validates the token of a log stream; nil skips it.
5863 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
5978 // Owner is the owner login of the github context.
6079 Owner string
6180 // PublicURL is the base URL of the instance (github.server_url).
@@ -90,6 +109,10 @@ func (c CIConfig) withDefaults() CIConfig {
90109 def(&c.TimeoutSlack, domain.CITimeoutSlack)
91110 def(&c.QueueTimeout, domain.CIQueueTimeout)
92111 def(&c.Retention, domain.CIRetentionDays*24*time.Hour)
112+ if c.RemoteConcurrency <= 0 {
113+ c.RemoteConcurrency = DefaultCIRemoteConcurrency
114+ }
115+ def(&c.ResolveTimeout, DefaultCIResolveTimeout)
93116 if c.TriggerQueue <= 0 {
94117 c.TriggerQueue = DefaultCITriggerQueue
95118 }
@@ -112,19 +135,25 @@ type CI struct {
112135 ctx context.Context
113136 wg sync.WaitGroup
114137
115- locks sync.Map // run id -> *sync.Mutex
116- assign sync.Mutex
117- jobs notifier // woken when a job changes: key is the job reference
118- pending notifier // woken when a job may be ready or a runner was removed
119- flows *wfCache
138+ remoteSlots chan struct{} // slots of the git ls-remote processes
139+ locks sync.Map // run id -> *sync.Mutex
140+ assign sync.Mutex
141+ jobs notifier // woken when a job changes: key is the job reference
142+ pending notifier // woken when a job may be ready or a runner was removed
143+ flows *wfCache
120144
121145 mu sync.Mutex
122146 heartbeats map[string]time.Time // job reference -> last heartbeat
123147 seen map[string]time.Time // runner id -> last seen
124148 polls map[string]pollRecord
125- streams int
126- perClient map[string]int
127- closed bool
149+ // archiveTokens are the secrets of the tokens given for the archives of
150+ // ermite actions, per job reference then "<repository>@<sha>", so that a job
151+ // 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
128157 }
129158
130159 var (
@@ -137,10 +166,11 @@ var (
137166 // pollRecord remembers the assignment a poll made, for the retry of a lost
138167 // response (FetchJobRequest.poll_id). The token is kept in memory only.
139168 type pollRecord struct {
140- pollID string
141- ref string
142- token string
143- hash domain.TokenHash
169+ pollID string
170+ ref string
171+ token string
172+ ghToken string
173+ hash domain.TokenHash
144174 }
145175
146176 // 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 {
153183 run, stop := context.WithCancel(context.WithoutCancel(ctx))
154184 return &CI{
155185 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),
157187 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{},
159190 }
160191 }
161192
@@ -258,6 +289,12 @@ type runState struct {
258289 // chk marks jobs whose check must be written even when the job itself did
259290 // not change (a rerun moves every check to the new attempt).
260291 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
261298 }
262299
263300 // 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 {
341378 if rs.runDirty {
342379 w.Runs = []domain.CIRun{rs.run}
343380 }
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 {
345383 return nil
346384 }
347385 v, err := c.cfg.Store.Apply(ctx, w)
@@ -356,6 +394,7 @@ func (rs *runState) flush(ctx context.Context) error {
356394 }
357395 changedRun := rs.runDirty
358396 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
397+ rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
359398
360399 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
361400 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
5252 return wf, nil
5353 }
5454
55-// unsupported names the feature of a job that CI-1 does not run, or "". Such
56-// a job fails with "not supported yet: <feature>" instead of running wrong.
57-// Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3).
55+// unsupported names the feature of a job that ermite does not run yet, or "".
56+// Such a job fails with "not supported yet: <feature>" instead of running
57+// wrong. CI-2 runs steps with uses:, job container:, services: and step
58+// timeout-minutes (ADR 0025); what stays is CI-3.
5859 func unsupported(j *workflow.Job) string {
5960 switch {
6061 case j.IsReusable():
6162 return "reusable workflows (uses at the job level)"
62- case j.Container != nil:
63- return "container"
64- case j.Services != nil:
65- return "services"
6663 case j.Environment != nil:
6764 return "environment"
6865 case j.Concurrency != nil:
6966 return "concurrency"
7067 }
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- }
8168 return ""
8269 }
8370
@@ -253,6 +240,7 @@ func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg str
253240 if j.HasToken() {
254241 j.TokenValidUntil = now.Add(domain.CITokenGrace)
255242 }
243+ rs.revokeTokens(j)
256244 rs.touch(j)
257245 rs.c.mu.Lock()
258246 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
207207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208208 return errNoJob
209209 }
210- out = c.runnerJob(rs, *j, rec.token)
210+ out = c.runnerJob(rs, *j, rec.token, rec.ghToken)
211211 return nil
212212 })
213213 return out, err == nil && out != nil
214214 }
215215
216216 // 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 {
218218 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,
220220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
221221 }
222222 }
@@ -241,6 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
241241 return errNoJob
242242 }
243243 token := newToken(domain.JobTokenPrefix)
244+ ghToken := rs.issueRepoToken(j)
244245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
245246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
246247 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
248249 if err := rs.flush(ctx); err != nil {
249250 return err
250251 }
251- out = c.runnerJob(rs, *j, token)
252+ out = c.runnerJob(rs, *j, token, ghToken)
252253 if pollID != "" {
253254 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}
255256 c.mu.Unlock()
256257 }
257258 return nil
M internal/usecase/ci_trigger.go +17 -3
@@ -36,6 +36,7 @@ type ciTrigger struct {
3636 repo domain.RepoName
3737 updates []domain.RefUpdate
3838 actor string
39+ pusher domain.CIPusher
3940 at time.Time
4041 log zerowrap.Logger
4142 }
@@ -44,6 +45,19 @@ type ciTrigger struct {
4445 // a worker plans them. A full queue drops the trigger, logged at error: a push
4546 // is never slowed down or failed by CI.
4647 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) {
4761 var kept []domain.RefUpdate
4862 for _, u := range updates {
4963 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.
5367 if len(kept) == 0 {
5468 return
5569 }
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)}
5771 c.mu.Lock()
5872 closed := c.closed
5973 c.mu.Unlock()
@@ -384,7 +398,7 @@ func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f
384398 CreatedAt: now, StartedAt: now, CompletedAt: now,
385399 }
386400 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},
388402 Checks: []domain.CICheck{{
389403 ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
390404 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
436450 run.Name, run.Title = runName, runName
437451 }
438452 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,
440454 }}
441455 for i, j := range wf.Jobs {
442456 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
189189 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190190 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
191191 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
192+ rs.revokeTokens(j) // the next assignment gets new ones
192193 c.forgetPoll(jobRef(*j))
193194 if j.CancelReason != domain.CICancelNone {
194195 // 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
814814 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
815815 })
816816 if m.ci != nil {
817- // GitHub runs the "push" workflows of a merge, with the owner as pusher.
818- m.ci.OnPush(ctx, name, updates[:1], domain.Owner())
817+ // GitHub runs the "push" workflows of a merge, with the owner as actor;
818+ // the run is trusted with what the merging principal may read.
819+ m.ci.OnMerge(ctx, name, updates[:1], p)
819820 }
820821 return mr, deleted, nil
821822 }
M internal/usecase/tokens.go +58 -0
@@ -273,6 +273,9 @@ type Auth struct {
273273 root domain.TokenHash
274274 tokens ports.TokenStore
275275 clock ports.Clock
276+ // repoTokens resolves the job repository tokens (domain.RepoTokenPrefix);
277+ // nil when CI is not wired.
278+ repoTokens ports.CIRepoTokenStore
276279 }
277280
278281 var (
@@ -286,6 +289,17 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo
286289 return &Auth{root: rootHash, tokens: tokens, clock: clock}
287290 }
288291
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+
289303 // Revalidate checks that the holder of a principal obtained earlier is still
290304 // authorized: root always is; a stored token must still exist and not have
291305 // 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 {
298312 if p.IsAnonymous() {
299313 return domain.ErrUnauthenticated
300314 }
315+ if id := p.TokenID(); domain.IsRepoTokenID(id) {
316+ return a.revalidateRepoToken(ctx, strings.TrimPrefix(id, "job:"))
317+ }
301318 tok, err := a.tokens.Get(ctx, p.TokenID())
302319 if errors.Is(err, domain.ErrNotFound) {
303320 return domain.ErrUnauthenticated
@@ -325,6 +342,9 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
325342 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
326343 return domain.Root(), nil
327344 }
345+ if domain.IsRepoToken(secret) {
346+ return a.authenticateRepoToken(ctx, h)
347+ }
328348 tok, err := a.tokens.FindByHash(ctx, h)
329349 if errors.Is(err, domain.ErrNotFound) {
330350 return domain.Principal{}, domain.ErrUnauthenticated
@@ -344,3 +364,41 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
344364 }
345365 return domain.TokenPrincipal(tok), nil
346366 }
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) {
668668 assert.Equal(t, "mine", got[0].ID)
669669 })
670670 }
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 {
112112 // Repository of the run: the key of the run record is run/<repo>/<id>, and
113113 // the runner side knows a run by its id only.
114114 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;
115120 }
116121
117122 // CIStep is a step of a job.
@@ -220,3 +225,38 @@ message CIRunner {
220225 google.protobuf.Timestamp registered_at = 8;
221226 bytes token_hash = 9;
222227 }
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