detached 8d1000a

feat(ci): issue job repository tokens and resolve actions

A job gets a read-only repository token (ermitegh_ + 32 random bytes, stored
hashed) at its assignment, in Job.github_token. Auth.Authenticate resolves it
to a token-shaped principal (repo:read on the repository of the run, expiring
at the job deadline plus 5 minutes), so no rule of the forge knows it. It is
deleted in the batch that ends the job, takes the assignment back, or deletes
the run, and is never listed or managed as a stored token.

RunnerService.ResolveActions answers github.com actions with git ls-remote
(tag, then branch, peeled commit of an annotated tag; a full id as it is) and
actions of a repository of ERMITE_OWNER from ermite, with a per-job archive
token. Resolutions are kept per run attempt. ls-remote is bounded and
injectable (ports.GitRemote).
commit
8d1000aeac61e2bba8c14b68a53fedfe6355f6cd
author
brice · · 2026-10-13 10:41 UTC
parent
bfdf4d9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
34 files · +2745 -58
M gen/ermite/storage/ci.pb.go +240 -29 generated file: collapsed
M go.sum +0 -2
@@ -1,7 +1,5 @@
11 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE=
22 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 h1:S7Zk7maQJoOUxY2BU4CKeAaWorDOhNXGw7sbT76Yft8=
4-bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
53 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8=
64 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
75 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
@@ -1,7 +1,5 @@
1 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE=1 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362 h1:w+HHiVba0R8eCUX/GiocoXAZJGjBsiNlM9g+3MPl6lE=
2 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=2 bnema.dev/ermite-actions v0.0.0-20261013040121-5c0e25d50362/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6 h1:S7Zk7maQJoOUxY2BU4CKeAaWorDOhNXGw7sbT76Yft8=
4-bnema.dev/ermite-api v0.0.0-20261013034722-4db3b8badaf6/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
5 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8=3 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a h1:RoMj0lnpAyvbiIwWq7QyQoyVy8hQ47XSKtwaEe36Qy8=
6 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=4 bnema.dev/ermite-api v0.1.1-0.20261013091642-49ac5fd7a78a/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
7 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=5 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
A internal/adapters/gitremote/gitremote.go +198 -0
@@ -0,0 +1,198 @@
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+}
49+
50+// Remote implements ports.GitRemote over the git binary.
51+type Remote struct{ cfg Config }
52+
53+var _ ports.GitRemote = (*Remote)(nil)
54+
55+// New builds a Remote.
56+func New(cfg Config) *Remote {
57+ if cfg.BaseURL == "" {
58+ cfg.BaseURL = DefaultBaseURL
59+ }
60+ if cfg.Timeout <= 0 {
61+ cfg.Timeout = DefaultTimeout
62+ }
63+ if cfg.MaxOutput <= 0 {
64+ cfg.MaxOutput = DefaultMaxOutput
65+ }
66+ if cfg.AllowedProtocols == "" {
67+ cfg.AllowedProtocols = "https"
68+ }
69+ return &Remote{cfg: cfg}
70+}
71+
72+// validSegment reports whether s can be an owner or a repository name on
73+// GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'.
74+func validSegment(s string) bool {
75+ if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' {
76+ return false
77+ }
78+ for _, r := range s {
79+ switch {
80+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
81+ default:
82+ return false
83+ }
84+ }
85+ return true
86+}
87+
88+// limitWriter keeps at most max bytes and records that more came.
89+type limitWriter struct {
90+ buf bytes.Buffer
91+ max int
92+ exceeded bool
93+ cancel context.CancelFunc
94+}
95+
96+func (w *limitWriter) Write(p []byte) (int, error) {
97+ if w.buf.Len()+len(p) > w.max {
98+ w.exceeded = true
99+ w.cancel()
100+ return 0, errors.New("output limit reached")
101+ }
102+ return w.buf.Write(p)
103+}
104+
105+// ListRefs lists the branches and tags of owner/repo, with the peeled commit
106+// of the annotated tags.
107+func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
108+ if !validSegment(owner) || !validSegment(repo) {
109+ return nil, domain.ErrNotFound
110+ }
111+ ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
112+ defer cancel()
113+ out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel}
114+ var stderr bytes.Buffer
115+ url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo
116+ cmd := exec.CommandContext(ctx, r.cfg.Bin,
117+ "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false",
118+ "ls-remote", "--", url, "refs/heads/*", "refs/tags/*")
119+ cmd.Env = []string{
120+ "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home,
121+ "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
122+ "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
123+ }
124+ cmd.Dir = r.cfg.Home
125+ cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
126+ cmd.WaitDelay = 2 * time.Second
127+ err := cmd.Run()
128+ switch {
129+ case out.exceeded:
130+ return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput)
131+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
132+ return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout)
133+ case ctx.Err() != nil:
134+ return nil, ctx.Err()
135+ case err != nil:
136+ msg := stderr.String()
137+ if notFound(msg) {
138+ return nil, domain.ErrNotFound
139+ }
140+ return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg))
141+ }
142+ return parse(out.buf.String()), nil
143+}
144+
145+// notFound recognises what git says when github.com answers 404 or asks for
146+// credentials, which it does for a repository that does not exist as for a
147+// private one, and the prompt being disabled.
148+func notFound(stderr string) bool {
149+ for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} {
150+ if strings.Contains(stderr, s) {
151+ return true
152+ }
153+ }
154+ return false
155+}
156+
157+func firstLine(s string) string {
158+ s = strings.TrimSpace(s)
159+ if i := strings.IndexByte(s, '\n'); i >= 0 {
160+ s = s[:i]
161+ }
162+ return s
163+}
164+
165+// limitedBuffer keeps the first max bytes written and drops the rest.
166+type limitedBuffer struct {
167+ b *bytes.Buffer
168+ max int
169+}
170+
171+func (l *limitedBuffer) Write(p []byte) (int, error) {
172+ if room := l.max - l.b.Len(); room > 0 {
173+ l.b.Write(p[:min(room, len(p))])
174+ }
175+ return len(p), nil
176+}
177+
178+// parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives
179+// the peeled commit of the annotated tag listed before it.
180+func parse(out string) []ports.RemoteRef {
181+ var refs []ports.RemoteRef
182+ index := map[string]int{}
183+ for line := range strings.SplitSeq(out, "\n") {
184+ id, name, ok := strings.Cut(line, "\t")
185+ if !ok || len(id) != domain.ObjectIDLen {
186+ continue
187+ }
188+ if base, peeled := strings.CutSuffix(name, "^{}"); peeled {
189+ if i, ok := index[base]; ok {
190+ refs[i].Peeled = id
191+ }
192+ continue
193+ }
194+ index[name] = len(refs)
195+ refs = append(refs, ports.RemoteRef{Name: name, ID: id})
196+ }
197+ return refs
198+}
@@ -0,0 +1,198 @@
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+}
49+
50+// Remote implements ports.GitRemote over the git binary.
51+type Remote struct{ cfg Config }
52+
53+var _ ports.GitRemote = (*Remote)(nil)
54+
55+// New builds a Remote.
56+func New(cfg Config) *Remote {
57+ if cfg.BaseURL == "" {
58+ cfg.BaseURL = DefaultBaseURL
59+ }
60+ if cfg.Timeout <= 0 {
61+ cfg.Timeout = DefaultTimeout
62+ }
63+ if cfg.MaxOutput <= 0 {
64+ cfg.MaxOutput = DefaultMaxOutput
65+ }
66+ if cfg.AllowedProtocols == "" {
67+ cfg.AllowedProtocols = "https"
68+ }
69+ return &Remote{cfg: cfg}
70+}
71+
72+// validSegment reports whether s can be an owner or a repository name on
73+// GitHub: letters, digits, '.', '_' and '-', not starting with '-' or '.'.
74+func validSegment(s string) bool {
75+ if s == "" || len(s) > 100 || s[0] == '-' || s[0] == '.' {
76+ return false
77+ }
78+ for _, r := range s {
79+ switch {
80+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
81+ default:
82+ return false
83+ }
84+ }
85+ return true
86+}
87+
88+// limitWriter keeps at most max bytes and records that more came.
89+type limitWriter struct {
90+ buf bytes.Buffer
91+ max int
92+ exceeded bool
93+ cancel context.CancelFunc
94+}
95+
96+func (w *limitWriter) Write(p []byte) (int, error) {
97+ if w.buf.Len()+len(p) > w.max {
98+ w.exceeded = true
99+ w.cancel()
100+ return 0, errors.New("output limit reached")
101+ }
102+ return w.buf.Write(p)
103+}
104+
105+// ListRefs lists the branches and tags of owner/repo, with the peeled commit
106+// of the annotated tags.
107+func (r *Remote) ListRefs(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
108+ if !validSegment(owner) || !validSegment(repo) {
109+ return nil, domain.ErrNotFound
110+ }
111+ ctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
112+ defer cancel()
113+ out := &limitWriter{max: r.cfg.MaxOutput, cancel: cancel}
114+ var stderr bytes.Buffer
115+ url := strings.TrimSuffix(r.cfg.BaseURL, "/") + "/" + owner + "/" + repo
116+ cmd := exec.CommandContext(ctx, r.cfg.Bin,
117+ "-c", "credential.helper=", "-c", "core.askPass=", "-c", "http.followRedirects=false",
118+ "ls-remote", "--", url, "refs/heads/*", "refs/tags/*")
119+ cmd.Env = []string{
120+ "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "HOME=" + r.cfg.Home,
121+ "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never",
122+ "GIT_ALLOW_PROTOCOL=" + r.cfg.AllowedProtocols, "LC_ALL=C", "PATH=" + r.cfg.Path,
123+ }
124+ cmd.Dir = r.cfg.Home
125+ cmd.Stdout, cmd.Stderr = out, &limitedBuffer{b: &stderr, max: 4096}
126+ cmd.WaitDelay = 2 * time.Second
127+ err := cmd.Run()
128+ switch {
129+ case out.exceeded:
130+ return nil, fmt.Errorf("%w: git ls-remote %s/%s listed more than %d bytes", domain.ErrUnavailable, owner, repo, r.cfg.MaxOutput)
131+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
132+ return nil, fmt.Errorf("%w: git ls-remote %s/%s took more than %s", domain.ErrUnavailable, owner, repo, r.cfg.Timeout)
133+ case ctx.Err() != nil:
134+ return nil, ctx.Err()
135+ case err != nil:
136+ msg := stderr.String()
137+ if notFound(msg) {
138+ return nil, domain.ErrNotFound
139+ }
140+ return nil, fmt.Errorf("%w: git ls-remote %s/%s: %v", domain.ErrUnavailable, owner, repo, firstLine(msg))
141+ }
142+ return parse(out.buf.String()), nil
143+}
144+
145+// notFound recognises what git says when github.com answers 404 or asks for
146+// credentials, which it does for a repository that does not exist as for a
147+// private one, and the prompt being disabled.
148+func notFound(stderr string) bool {
149+ for _, s := range []string{"Repository not found", "terminal prompts disabled", "could not read Username", "Authentication failed", "returned error: 404"} {
150+ if strings.Contains(stderr, s) {
151+ return true
152+ }
153+ }
154+ return false
155+}
156+
157+func firstLine(s string) string {
158+ s = strings.TrimSpace(s)
159+ if i := strings.IndexByte(s, '\n'); i >= 0 {
160+ s = s[:i]
161+ }
162+ return s
163+}
164+
165+// limitedBuffer keeps the first max bytes written and drops the rest.
166+type limitedBuffer struct {
167+ b *bytes.Buffer
168+ max int
169+}
170+
171+func (l *limitedBuffer) Write(p []byte) (int, error) {
172+ if room := l.max - l.b.Len(); room > 0 {
173+ l.b.Write(p[:min(room, len(p))])
174+ }
175+ return len(p), nil
176+}
177+
178+// parse reads the lines "<id>\t<ref>" of ls-remote. A "<ref>^{}" line gives
179+// the peeled commit of the annotated tag listed before it.
180+func parse(out string) []ports.RemoteRef {
181+ var refs []ports.RemoteRef
182+ index := map[string]int{}
183+ for line := range strings.SplitSeq(out, "\n") {
184+ id, name, ok := strings.Cut(line, "\t")
185+ if !ok || len(id) != domain.ObjectIDLen {
186+ continue
187+ }
188+ if base, peeled := strings.CutSuffix(name, "^{}"); peeled {
189+ if i, ok := index[base]; ok {
190+ refs[i].Peeled = id
191+ }
192+ continue
193+ }
194+ index[name] = len(refs)
195+ refs = append(refs, ports.RemoteRef{Name: name, ID: id})
196+ }
197+ return refs
198+}
A internal/adapters/gitremote/gitremote_test.go +148 -0
@@ -0,0 +1,148 @@
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+}
@@ -0,0 +1,148 @@
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+}
M internal/adapters/kvstore/ci.go +4 -0
@@ -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 {
@@ -828,6 +831,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
828831 // runKeys lists every key of a run except its record: source, jobs, indexes.
829832 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
830833 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
834+ keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
831835 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
832836 keys = append(keys, slices.Clone(key))
833837 if j, err := decodeJob(item); err == nil {
@@ -37,6 +37,8 @@ import (
37 // ciready/<ready_at:020d>/<job key> job waiting for a runner37 // ciready/<ready_at:020d>/<job key> job waiting for a runner
38 // ciactive/<job key> job assigned or running38 // ciactive/<job key> job assigned or running
39 // jobtoken/<hash> job token (value: job key)39 // jobtoken/<hash> job token (value: job key)
40+//
41+// The job repository tokens and the action resolutions are in ci_token.go.
40 const (42 const (
41 runPrefix = "run/"43 runPrefix = "run/"
42 runSrcPrefix = "runsrc/"44 runSrcPrefix = "runsrc/"
@@ -507,6 +509,7 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
507 guarded[j.Repo] = true509 guarded[j.Repo] = true
508 }510 }
509 }511 }
512+ c.stageRepoTokens(ctx, b, w, guarded)
510 // Checks are derived from the jobs, written in the same batch and under513 // Checks are derived from the jobs, written in the same batch and under
511 // the lock of their run: they are upserted without a precondition.514 // the lock of their run: they are upserted without a precondition.
512 for _, ck := range w.Checks {515 for _, ck := range w.Checks {
@@ -828,6 +831,7 @@ func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, l
828 // runKeys lists every key of a run except its record: source, jobs, indexes.831 // runKeys lists every key of a run except its record: source, jobs, indexes.
829 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {832 func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
830 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}833 keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
834+ keys = append(keys, repoTokenKeys(ctx, snap, runID)...)
831 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {835 for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
832 keys = append(keys, slices.Clone(key))836 keys = append(keys, slices.Clone(key))
833 if j, err := decodeJob(item); err == nil {837 if j, err := decodeJob(item); err == nil {
A internal/adapters/kvstore/ci_token.go +177 -0
@@ -0,0 +1,177 @@
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> empty: the tokens of a job
26+// actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution
27+//
28+// Stored tokens use token/ and tokenhash/: the two families never meet, so a
29+// job token is never listed, revoked or managed as a stored token.
30+const (
31+ repoTokenPrefix = "ghtoken/"
32+ repoTokenHashPrefix = "ghtokenhash/"
33+ repoTokenJobPrefix = "ghtokenjob/"
34+ resolutionPrefix = "actres/"
35+)
36+
37+var _ ports.CIRepoTokenStore = (*CI)(nil)
38+
39+func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) }
40+func repoTokenHashKey(h domain.TokenHash) []byte {
41+ return []byte(repoTokenHashPrefix + h.String())
42+}
43+func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string {
44+ return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID)
45+}
46+func repoTokenJobKey(t domain.CIRepoToken) []byte {
47+ return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)
48+}
49+func resolutionKey(runID int64, attempt int32, key string) []byte {
50+ sum := sha256.Sum256([]byte(key))
51+ return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))
52+}
53+
54+func encodeRepoToken(t domain.CIRepoToken) []byte {
55+ return mustMarshal(&storagepb.CIRepoToken{
56+ Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
57+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt),
58+ })
59+}
60+
61+func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
62+ var pb storagepb.CIRepoToken
63+ if err := proto.Unmarshal(val, &pb); err != nil {
64+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
65+ }
66+ repo, err := domain.ParseRepoName(pb.GetRepo())
67+ if err != nil {
68+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
69+ }
70+ t := domain.CIRepoToken{
71+ ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
72+ Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
73+ }
74+ copy(t.Hash[:], pb.GetHash())
75+ return t, nil
76+}
77+
78+// stageRepoTokens stages the creation of the tokens, the deletion of the
79+// tokens of the jobs to revoke, and the resolutions to keep.
80+func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) {
81+ snap := c.db.Snapshot(ctx)
82+ for _, ref := range w.RevokeJobs {
83+ for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) {
84+ b.Delete(append([]byte(nil), key...))
85+ id := string(item.Value)
86+ if id == "" {
87+ id = string(key[strings.LastIndexByte(string(key), '/')+1:])
88+ }
89+ b.Delete(repoTokenKey(id))
90+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
91+ if t, err := decodeRepoToken(it.Value); err == nil {
92+ b.Delete(repoTokenHashKey(t.Hash))
93+ }
94+ }
95+ }
96+ }
97+ for _, t := range w.RepoTokens {
98+ b.RequireAbsent(repoTokenKey(t.ID))
99+ b.RequireAbsent(repoTokenHashKey(t.Hash))
100+ b.Put(repoTokenKey(t.ID), encodeRepoToken(t))
101+ b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))
102+ b.Put(repoTokenJobKey(t), []byte(t.ID))
103+ guarded[t.Repo] = true
104+ }
105+ for _, r := range w.Resolutions {
106+ b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{
107+ Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2
108+ }))
109+ }
110+}
111+
112+// FindRepoTokenByHash resolves the hash index, then reads the token.
113+func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) {
114+ snap := c.db.Snapshot(ctx)
115+ idx, ok := snap.Get(ctx, repoTokenHashKey(hash))
116+ if !ok {
117+ return domain.CIRepoToken{}, domain.ErrNotFound
118+ }
119+ it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value)))
120+ if !ok {
121+ return domain.CIRepoToken{}, domain.ErrNotFound
122+ }
123+ t, err := decodeRepoToken(it.Value)
124+ if err != nil {
125+ return domain.CIRepoToken{}, err
126+ }
127+ if t.Hash != hash {
128+ return domain.CIRepoToken{}, domain.ErrNotFound
129+ }
130+ return t, nil
131+}
132+
133+// GetRepoToken reads ghtoken/<id>.
134+func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) {
135+ if !validTokenID(id) {
136+ return domain.CIRepoToken{}, domain.ErrNotFound
137+ }
138+ it, ok := c.db.Get(ctx, repoTokenKey(id))
139+ if !ok {
140+ return domain.CIRepoToken{}, domain.ErrNotFound
141+ }
142+ return decodeRepoToken(it.Value)
143+}
144+
145+// GetActionResolution reads the resolution kept for the key.
146+func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) {
147+ it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key))
148+ if !ok {
149+ return domain.CIActionResolution{}, domain.ErrNotFound
150+ }
151+ var pb storagepb.CIActionResolution
152+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
153+ return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err)
154+ }
155+ return domain.CIActionResolution{
156+ RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(),
157+ ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()),
158+ }, nil
159+}
160+
161+// repoTokenKeys lists the keys of the repository tokens and action
162+// resolutions of a run, for its deletion.
163+func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
164+ var keys [][]byte
165+ for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {
166+ keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value)))
167+ if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok {
168+ if t, err := decodeRepoToken(it.Value); err == nil {
169+ keys = append(keys, repoTokenHashKey(t.Hash))
170+ }
171+ }
172+ }
173+ for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {
174+ keys = append(keys, append([]byte(nil), key...))
175+ }
176+ return keys
177+}
@@ -0,0 +1,177 @@
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> empty: the tokens of a job
26+// actres/<run:020d>/<attempt:010d>/<sha256(key)> CIActionResolution
27+//
28+// Stored tokens use token/ and tokenhash/: the two families never meet, so a
29+// job token is never listed, revoked or managed as a stored token.
30+const (
31+ repoTokenPrefix = "ghtoken/"
32+ repoTokenHashPrefix = "ghtokenhash/"
33+ repoTokenJobPrefix = "ghtokenjob/"
34+ resolutionPrefix = "actres/"
35+)
36+
37+var _ ports.CIRepoTokenStore = (*CI)(nil)
38+
39+func repoTokenKey(id string) []byte { return []byte(repoTokenPrefix + id) }
40+func repoTokenHashKey(h domain.TokenHash) []byte {
41+ return []byte(repoTokenHashPrefix + h.String())
42+}
43+func repoTokenJobPrefixOf(runID int64, attempt int32, jobID string) string {
44+ return fmt.Sprintf("%s%020d/%010d/%s/", repoTokenJobPrefix, runID, attempt, jobID)
45+}
46+func repoTokenJobKey(t domain.CIRepoToken) []byte {
47+ return []byte(repoTokenJobPrefixOf(t.RunID, t.RunAttempt, t.JobID) + t.ID)
48+}
49+func resolutionKey(runID int64, attempt int32, key string) []byte {
50+ sum := sha256.Sum256([]byte(key))
51+ return []byte(fmt.Sprintf("%s%020d/%010d/%s", resolutionPrefix, runID, attempt, hex.EncodeToString(sum[:])))
52+}
53+
54+func encodeRepoToken(t domain.CIRepoToken) []byte {
55+ return mustMarshal(&storagepb.CIRepoToken{
56+ Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
57+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt),
58+ })
59+}
60+
61+func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
62+ var pb storagepb.CIRepoToken
63+ if err := proto.Unmarshal(val, &pb); err != nil {
64+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
65+ }
66+ repo, err := domain.ParseRepoName(pb.GetRepo())
67+ if err != nil {
68+ return domain.CIRepoToken{}, fmt.Errorf("decode repo token: %w", err)
69+ }
70+ t := domain.CIRepoToken{
71+ ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
72+ Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
73+ }
74+ copy(t.Hash[:], pb.GetHash())
75+ return t, nil
76+}
77+
78+// stageRepoTokens stages the creation of the tokens, the deletion of the
79+// tokens of the jobs to revoke, and the resolutions to keep.
80+func (c *CI) stageRepoTokens(ctx context.Context, b *kv.Batch, w ports.CIWrite, guarded map[domain.RepoName]bool) {
81+ snap := c.db.Snapshot(ctx)
82+ for _, ref := range w.RevokeJobs {
83+ for key, item := range snap.Prefix(ctx, []byte(repoTokenJobPrefixOf(ref.RunID, ref.RunAttempt, ref.JobID))) {
84+ b.Delete(append([]byte(nil), key...))
85+ id := string(item.Value)
86+ if id == "" {
87+ id = string(key[strings.LastIndexByte(string(key), '/')+1:])
88+ }
89+ b.Delete(repoTokenKey(id))
90+ if it, ok := snap.Get(ctx, repoTokenKey(id)); ok {
91+ if t, err := decodeRepoToken(it.Value); err == nil {
92+ b.Delete(repoTokenHashKey(t.Hash))
93+ }
94+ }
95+ }
96+ }
97+ for _, t := range w.RepoTokens {
98+ b.RequireAbsent(repoTokenKey(t.ID))
99+ b.RequireAbsent(repoTokenHashKey(t.Hash))
100+ b.Put(repoTokenKey(t.ID), encodeRepoToken(t))
101+ b.Put(repoTokenHashKey(t.Hash), []byte(t.ID))
102+ b.Put(repoTokenJobKey(t), []byte(t.ID))
103+ guarded[t.Repo] = true
104+ }
105+ for _, r := range w.Resolutions {
106+ b.Put(resolutionKey(r.RunID, r.RunAttempt, r.Key), mustMarshal(&storagepb.CIActionResolution{
107+ Key: r.Key, NameWithOwner: r.NameWithOwner, ResolvedSha: r.ResolvedSHA, Source: int32(r.Source), //nolint:gosec // 1 or 2
108+ }))
109+ }
110+}
111+
112+// FindRepoTokenByHash resolves the hash index, then reads the token.
113+func (c *CI) FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error) {
114+ snap := c.db.Snapshot(ctx)
115+ idx, ok := snap.Get(ctx, repoTokenHashKey(hash))
116+ if !ok {
117+ return domain.CIRepoToken{}, domain.ErrNotFound
118+ }
119+ it, ok := snap.Get(ctx, repoTokenKey(string(idx.Value)))
120+ if !ok {
121+ return domain.CIRepoToken{}, domain.ErrNotFound
122+ }
123+ t, err := decodeRepoToken(it.Value)
124+ if err != nil {
125+ return domain.CIRepoToken{}, err
126+ }
127+ if t.Hash != hash {
128+ return domain.CIRepoToken{}, domain.ErrNotFound
129+ }
130+ return t, nil
131+}
132+
133+// GetRepoToken reads ghtoken/<id>.
134+func (c *CI) GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error) {
135+ if !validTokenID(id) {
136+ return domain.CIRepoToken{}, domain.ErrNotFound
137+ }
138+ it, ok := c.db.Get(ctx, repoTokenKey(id))
139+ if !ok {
140+ return domain.CIRepoToken{}, domain.ErrNotFound
141+ }
142+ return decodeRepoToken(it.Value)
143+}
144+
145+// GetActionResolution reads the resolution kept for the key.
146+func (c *CI) GetActionResolution(ctx context.Context, runID int64, attempt int32, key string) (domain.CIActionResolution, error) {
147+ it, ok := c.db.Get(ctx, resolutionKey(runID, attempt, key))
148+ if !ok {
149+ return domain.CIActionResolution{}, domain.ErrNotFound
150+ }
151+ var pb storagepb.CIActionResolution
152+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
153+ return domain.CIActionResolution{}, fmt.Errorf("decode action resolution: %w", err)
154+ }
155+ return domain.CIActionResolution{
156+ RunID: runID, RunAttempt: attempt, Key: pb.GetKey(), NameWithOwner: pb.GetNameWithOwner(),
157+ ResolvedSHA: pb.GetResolvedSha(), Source: domain.CIActionSource(pb.GetSource()),
158+ }, nil
159+}
160+
161+// repoTokenKeys lists the keys of the repository tokens and action
162+// resolutions of a run, for its deletion.
163+func repoTokenKeys(ctx context.Context, snap *kv.Snapshot, runID int64) [][]byte {
164+ var keys [][]byte
165+ for key, item := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", repoTokenJobPrefix, runID))) {
166+ keys = append(keys, append([]byte(nil), key...), repoTokenKey(string(item.Value)))
167+ if it, ok := snap.Get(ctx, repoTokenKey(string(item.Value))); ok {
168+ if t, err := decodeRepoToken(it.Value); err == nil {
169+ keys = append(keys, repoTokenHashKey(t.Hash))
170+ }
171+ }
172+ }
173+ for key := range snap.Prefix(ctx, []byte(fmt.Sprintf("%s%020d/", resolutionPrefix, runID))) {
174+ keys = append(keys, append([]byte(nil), key...))
175+ }
176+ return keys
177+}
A internal/adapters/kvstore/ci_token_test.go +142 -0
@@ -0,0 +1,142 @@
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+}
@@ -0,0 +1,142 @@
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+}
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"
@@ -28,7 +28,7 @@ const MaxRequestBytes = 1 << 20
28 // Features are the optional capabilities of the contract that this server28 // Features are the optional capabilities of the contract that this server
29 // always implements, as GetVersion lists them. FeaturePasskeys comes on top29 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
30 // when Config.Passkeys is set.30 // when Config.Passkeys is set.
31-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}31+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}
32 32
33 // FeaturePasskeys is listed by GetVersion when passkeys are available.33 // FeaturePasskeys is listed by GetVersion when passkeys are available.
34 const FeaturePasskeys = "passkeys"34 const FeaturePasskeys = "passkeys"
M internal/adapters/rpc/ci.go +16 -4
@@ -172,10 +172,22 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb
172172 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
173173 }
174174
175-// ResolveActions is replaced by the real call in the commit that adds action
176-// resolution.
177-func (s *runnerService) ResolveActions(context.Context, *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) {
178- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.ResolveActions is not implemented"))
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
179191 }
180192
181193 // UploadSummary is the E2 work (summaries storage): until the server lists
@@ -172,10 +172,22 @@ func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb
172 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil172 return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
173 }173 }
174 174
175-// ResolveActions is replaced by the real call in the commit that adds action175+//
176-// resolution.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(context.Context, *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) {177+func (s *runnerService) ResolveActions(ctx context.Context, req *connect.Request[pb.ResolveActionsRequest]) (*connect.Response[pb.ResolveActionsResponse], error) {
178- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.RunnerService.ResolveActions is not implemented"))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
179 }191 }
180 192
181 // UploadSummary is the E2 work (summaries storage): until the server lists193 // UploadSummary is the E2 work (summaries storage): until the server lists
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 {
@@ -205,10 +205,25 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
205 JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,205 JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
206 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,206 WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
207 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,207 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
208- Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref,208+ Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
209 }209 }
210 }210 }
211 211
212+func resolvedActionToPB(a ports.ResolvedAction) *pb.ResolvedAction {
213+ out := &pb.ResolvedAction{Ref: a.Ref, Error: a.Error}
214+ if a.Error != "" {
215+ return out
216+ }
217+ out.NameWithOwner, out.Path, out.ResolvedSha, out.TarballUrl, out.Token = a.NameWithOwner, a.Path, a.ResolvedSHA, a.TarballURL, a.Token
218+ switch a.Source {
219+ case domain.CIActionGitHub:
220+ out.Source = pb.ActionSource_ACTION_SOURCE_GITHUB
221+ case domain.CIActionErmite:
222+ out.Source = pb.ActionSource_ACTION_SOURCE_ERMITE
223+ }
224+ return out
225+}
226+
212 func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {227 func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
213 out := make([]ports.StepUpdate, 0, len(steps))228 out := make([]ports.StepUpdate, 0, len(steps))
214 for _, s := range steps {229 for _, s := range steps {
M internal/adapters/rpc/errors.go +2 -0
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
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
@@ -55,6 +55,8 @@ func codeOf(err error) (connect.Code, bool) {
55 return connect.CodeAborted, true55 return connect.CodeAborted, true
56 case errors.Is(err, domain.ErrResourceExhausted):56 case errors.Is(err, domain.ErrResourceExhausted):
57 return connect.CodeResourceExhausted, true57 return connect.CodeResourceExhausted, true
58+ case errors.Is(err, domain.ErrUnavailable):
59+ return connect.CodeUnavailable, true
58 }60 }
59 if domain.IsInvalid(err) {61 if domain.IsInvalid(err) {
60 return connect.CodeInvalidArgument, true62 return connect.CodeInvalidArgument, true
M internal/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 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
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)
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326 ver := decode[versionOut](t, res.stdout)326 ver := decode[versionOut](t, res.stdout)
327 assert.Equal(t, c.url, ver.Server.URL)327 assert.Equal(t, c.url, ver.Server.URL)
328 assert.NotEmpty(t, ver.Server.Version)328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions"}, ver.Server.Features)
330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331 stdout, stderr, code = c.bin(t, secret, "version")331 stdout, stderr, code = c.bin(t, secret, "version")
332 require.Equal(t, 0, code, stderr)332 require.Equal(t, 0, code, stderr)
A internal/domain/ci_token.go +85 -0
@@ -0,0 +1,85 @@
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").
41+ Archive bool
42+ ExpiresAt time.Time
43+ CreatedAt time.Time
44+}
45+
46+// Expired reports whether the token is no longer valid at now.
47+func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
48+
49+// RepoTokenGrace is how long a job repository token outlives the deadline of
50+// its job at the most (a safety net: it is revoked when the job ends).
51+const RepoTokenGrace = 5 * time.Minute
52+
53+// RepoTokenPrincipal returns the principal of the holder of a job repository
54+// token. It is shaped like the principal of a stored token (the scope
55+// repo:read on one repository, an expiry), so that every authorization rule
56+// of the forge applies to it unchanged: it reads that repository, even when
57+// private, and writes nothing. Its TokenID starts with "job:" and matches no
58+// stored token.
59+func RepoTokenPrincipal(t CIRepoToken) Principal {
60+ return TokenPrincipal(Token{
61+ ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
62+ Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
63+ })
64+}
65+
66+// CIActionSource says where an action was found.
67+type CIActionSource int
68+
69+// Action sources, with the numbers of ActionSource in the protocol.
70+const (
71+ CIActionGitHub CIActionSource = 1
72+ CIActionErmite CIActionSource = 2
73+)
74+
75+// CIActionResolution is the resolution of an action reference for one run
76+// attempt: every job of the attempt gets the same commit.
77+type CIActionResolution struct {
78+ RunID int64
79+ RunAttempt int32
80+ // Key is "owner/repo@ref", lower-cased.
81+ Key string
82+ NameWithOwner string
83+ ResolvedSHA string
84+ Source CIActionSource
85+}
@@ -0,0 +1,85 @@
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").
41+ Archive bool
42+ ExpiresAt time.Time
43+ CreatedAt time.Time
44+}
45+
46+// Expired reports whether the token is no longer valid at now.
47+func (t CIRepoToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
48+
49+// RepoTokenGrace is how long a job repository token outlives the deadline of
50+// its job at the most (a safety net: it is revoked when the job ends).
51+const RepoTokenGrace = 5 * time.Minute
52+
53+// RepoTokenPrincipal returns the principal of the holder of a job repository
54+// token. It is shaped like the principal of a stored token (the scope
55+// repo:read on one repository, an expiry), so that every authorization rule
56+// of the forge applies to it unchanged: it reads that repository, even when
57+// private, and writes nothing. Its TokenID starts with "job:" and matches no
58+// stored token.
59+func RepoTokenPrincipal(t CIRepoToken) Principal {
60+ return TokenPrincipal(Token{
61+ ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
62+ Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
63+ })
64+}
65+
66+// CIActionSource says where an action was found.
67+type CIActionSource int
68+
69+// Action sources, with the numbers of ActionSource in the protocol.
70+const (
71+ CIActionGitHub CIActionSource = 1
72+ CIActionErmite CIActionSource = 2
73+)
74+
75+// CIActionResolution is the resolution of an action reference for one run
76+// attempt: every job of the attempt gets the same commit.
77+type CIActionResolution struct {
78+ RunID int64
79+ RunAttempt int32
80+ // Key is "owner/repo@ref", lower-cased.
81+ Key string
82+ NameWithOwner string
83+ ResolvedSHA string
84+ Source CIActionSource
85+}
A internal/domain/ci_token_test.go +53 -0
@@ -0,0 +1,53 @@
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+}
@@ -0,0 +1,53 @@
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+}
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.
@@ -36,6 +36,10 @@ var (
36 // ErrResourceExhausted means a limit of the server is reached, for example36 // ErrResourceExhausted means a limit of the server is reached, for example
37 // the number of event watchers; the caller may retry later.37 // the number of event watchers; the caller may retry later.
38 ErrResourceExhausted = errors.New("resource exhausted")38 ErrResourceExhausted = errors.New("resource exhausted")
39+ // ErrUnavailable means something the operation depends on, outside the
40+ // forge (github.com for an action), could not be reached; the caller may
41+ // retry later.
42+ ErrUnavailable = errors.New("unavailable")
39 )43 )
40 44
41 // invalidErrors are the errors that mean a request was malformed.45 // invalidErrors are the errors that mean a request was malformed.
M internal/ports/ci.go +72 -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)
@@ -200,8 +228,10 @@ type RegisteredRunner struct {
200228
201229 // RunnerJob is a job given to a runner.
202230 type RunnerJob struct {
203- Job domain.CIJob
204- Token string
231+ Job domain.CIJob
232+ Token string
233+ // GithubToken is the job repository token, the "github.token" of the job.
234+ GithubToken string
205235 Repo domain.RepoName
206236 Sha, Ref string
207237 WorkflowPath string
@@ -247,6 +277,38 @@ type CompleteJobResult struct {
247277 Applied bool
248278 }
249279
280+// ResolvedAction is the resolution of one "uses" of ResolveActions: where to
281+// download the action, or Error (then only Ref is set).
282+type ResolvedAction struct {
283+ Ref string
284+ NameWithOwner string
285+ Path string
286+ ResolvedSHA string
287+ TarballURL string
288+ Token string
289+ Source domain.CIActionSource
290+ Error string
291+}
292+
293+// RemoteRef is a ref of a remote repository, as `git ls-remote` lists it.
294+type RemoteRef struct {
295+ // Name is the full ref name ("refs/heads/main", "refs/tags/v1").
296+ Name string
297+ // ID is the object the ref points to (the tag object of an annotated tag).
298+ ID string
299+ // Peeled is the commit an annotated tag points to; empty for other refs.
300+ Peeled string
301+}
302+
303+// GitRemote lists the refs of a repository on github.com, with no
304+// credentials. It returns domain.ErrNotFound for a repository that does not
305+// exist or needs credentials (GitHub answers alike), and an error matching
306+// domain.ErrUnavailable when it cannot tell (network, timeout, too large an
307+// answer).
308+type GitRemote interface {
309+ ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error)
310+}
311+
250312 // JobSignal is one message of WatchJob.
251313 type JobSignal struct {
252314 Keepalive bool
@@ -278,6 +340,11 @@ type RunnerService interface {
278340 // then, because a client's call returns only when they arrive.
279341 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
280342 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
343+ // ResolveActions resolves the "uses" of the job's steps, one answer per
344+ // reference in the same order. A reference that cannot be resolved is not
345+ // an error: its answer carries Error. A local ("./") or Docker
346+ // ("docker://") reference, or more than 100, is domain.ErrInvalid.
347+ ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error)
281348 }
282349
283350 // JobView is a job as owners see it.
@@ -25,10 +25,35 @@ type CIRunDraft struct {
25 // Checks are derived from the jobs and upserted without a precondition.25 // Checks are derived from the jobs and upserted without a precondition.
26 // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the26 // Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the
27 // repository no longer exists.27 // repository no longer exists.
28+//
29+// RepoTokens are job repository tokens created with the batch (they must not
30+// exist yet), RevokeJobs the jobs whose repository tokens are deleted by it,
31+// and Resolutions the action resolutions of a run attempt to keep. They are
32+// written under the lock of the run that owns them.
28 type CIWrite struct {33 type CIWrite struct {
29- Runs []domain.CIRun34+ Runs []domain.CIRun
30- Jobs []domain.CIJob35+ Jobs []domain.CIJob
31- Checks []domain.CICheck36+ Checks []domain.CICheck
37+ RepoTokens []domain.CIRepoToken
38+ RevokeJobs []CIJobRef
39+ Resolutions []domain.CIActionResolution
40+}
41+
42+// CIJobRef names one execution of a job.
43+type CIJobRef struct {
44+ RunID int64
45+ RunAttempt int32
46+ JobID string
47+}
48+
49+// CIRepoTokenStore reads the job repository tokens, for the authentication of
50+// a request. They are written by CIStore.Apply. *kvstore.CI implements it.
51+type CIRepoTokenStore interface {
52+ // FindRepoTokenByHash returns the token whose secret has that hash, or
53+ // domain.ErrNotFound.
54+ FindRepoTokenByHash(ctx context.Context, hash domain.TokenHash) (domain.CIRepoToken, error)
55+ // GetRepoToken returns the token with that id, or domain.ErrNotFound.
56+ GetRepoToken(ctx context.Context, id string) (domain.CIRepoToken, error)
32 }57 }
33 58
34 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes59 // CIStore keeps CI runs, jobs and checks. The store derives its own indexes
@@ -46,6 +71,9 @@ type CIStore interface {
46 Apply(ctx context.Context, w CIWrite) (version uint64, err error)71 Apply(ctx context.Context, w CIWrite) (version uint64, err error)
47 // GetRun returns the run, or domain.ErrNotFound.72 // GetRun returns the run, or domain.ErrNotFound.
48 GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)73 GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)
74+ // GetActionResolution returns the resolution kept for the key in the run
75+ // attempt, or domain.ErrNotFound.
76+ GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error)
49 // GetRunSource returns what the run was planned from, or77 // GetRunSource returns what the run was planned from, or
50 // domain.ErrNotFound. It is how a run is found from its id alone.78 // domain.ErrNotFound. It is how a run is found from its id alone.
51 GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)79 GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)
@@ -200,8 +228,10 @@ type RegisteredRunner struct {
200 228
201 // RunnerJob is a job given to a runner.229 // RunnerJob is a job given to a runner.
202 type RunnerJob struct {230 type RunnerJob struct {
203- Job domain.CIJob231+ Job domain.CIJob
204- Token string232+ Token string
233+ // GithubToken is the job repository token, the "github.token" of the job.
234+ GithubToken string
205 Repo domain.RepoName235 Repo domain.RepoName
206 Sha, Ref string236 Sha, Ref string
207 WorkflowPath string237 WorkflowPath string
@@ -247,6 +277,38 @@ type CompleteJobResult struct {
247 Applied bool277 Applied bool
248 }278 }
249 279
280+// ResolvedAction is the resolution of one "uses" of ResolveActions: where to
281+// download the action, or Error (then only Ref is set).
282+type ResolvedAction struct {
283+ Ref string
284+ NameWithOwner string
285+ Path string
286+ ResolvedSHA string
287+ TarballURL string
288+ Token string
289+ Source domain.CIActionSource
290+ Error string
291+}
292+
293+// RemoteRef is a ref of a remote repository, as `git ls-remote` lists it.
294+type RemoteRef struct {
295+ // Name is the full ref name ("refs/heads/main", "refs/tags/v1").
296+ Name string
297+ // ID is the object the ref points to (the tag object of an annotated tag).
298+ ID string
299+ // Peeled is the commit an annotated tag points to; empty for other refs.
300+ Peeled string
301+}
302+
303+// GitRemote lists the refs of a repository on github.com, with no
304+// credentials. It returns domain.ErrNotFound for a repository that does not
305+// exist or needs credentials (GitHub answers alike), and an error matching
306+// domain.ErrUnavailable when it cannot tell (network, timeout, too large an
307+// answer).
308+type GitRemote interface {
309+ ListRefs(ctx context.Context, owner, repo string) ([]RemoteRef, error)
310+}
311+
250 // JobSignal is one message of WatchJob.312 // JobSignal is one message of WatchJob.
251 type JobSignal struct {313 type JobSignal struct {
252 Keepalive bool314 Keepalive bool
@@ -278,6 +340,11 @@ type RunnerService interface {
278 // then, because a client's call returns only when they arrive.340 // then, because a client's call returns only when they arrive.
279 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error341 WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
280 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)342 CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
343+ // ResolveActions resolves the "uses" of the job's steps, one answer per
344+ // reference in the same order. A reference that cannot be resolved is not
345+ // an error: its answer carries Error. A local ("./") or Docker
346+ // ("docker://") reference, or more than 100, is domain.ErrInvalid.
347+ ResolveActions(ctx context.Context, secret, jobID string, uses []string) ([]ResolvedAction, error)
281 }348 }
282 349
283 // JobView is a job as owners see it.350 // 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+}
@@ -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)
@@ -405,6 +405,84 @@ func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Cont
405 return _c405 return _c
406 }406 }
407 407
408+// GetActionResolution provides a mock function for the type CIStore
409+func (_mock *CIStore) GetActionResolution(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error) {
410+ ret := _mock.Called(ctx, runID, runAttempt, key)
411+
412+ if len(ret) == 0 {
413+ panic("no return value specified for GetActionResolution")
414+ }
415+
416+ var r0 domain.CIActionResolution
417+ var r1 error
418+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) (domain.CIActionResolution, error)); ok {
419+ return returnFunc(ctx, runID, runAttempt, key)
420+ }
421+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) domain.CIActionResolution); ok {
422+ r0 = returnFunc(ctx, runID, runAttempt, key)
423+ } else {
424+ r0 = ret.Get(0).(domain.CIActionResolution)
425+ }
426+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok {
427+ r1 = returnFunc(ctx, runID, runAttempt, key)
428+ } else {
429+ r1 = ret.Error(1)
430+ }
431+ return r0, r1
432+}
433+
434+// CIStore_GetActionResolution_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActionResolution'
435+type CIStore_GetActionResolution_Call struct {
436+ *mock.Call
437+}
438+
439+// GetActionResolution is a helper method to define mock.On call
440+// - ctx context.Context
441+// - runID int64
442+// - runAttempt int32
443+// - key string
444+func (_e *CIStore_Expecter) GetActionResolution(ctx any, runID any, runAttempt any, key any) *CIStore_GetActionResolution_Call {
445+ return &CIStore_GetActionResolution_Call{Call: _e.mock.On("GetActionResolution", ctx, runID, runAttempt, key)}
446+}
447+
448+func (_c *CIStore_GetActionResolution_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32, key string)) *CIStore_GetActionResolution_Call {
449+ _c.Call.Run(func(args mock.Arguments) {
450+ var arg0 context.Context
451+ if args[0] != nil {
452+ arg0 = args[0].(context.Context)
453+ }
454+ var arg1 int64
455+ if args[1] != nil {
456+ arg1 = args[1].(int64)
457+ }
458+ var arg2 int32
459+ if args[2] != nil {
460+ arg2 = args[2].(int32)
461+ }
462+ var arg3 string
463+ if args[3] != nil {
464+ arg3 = args[3].(string)
465+ }
466+ run(
467+ arg0,
468+ arg1,
469+ arg2,
470+ arg3,
471+ )
472+ })
473+ return _c
474+}
475+
476+func (_c *CIStore_GetActionResolution_Call) Return(cIActionResolution domain.CIActionResolution, err error) *CIStore_GetActionResolution_Call {
477+ _c.Call.Return(cIActionResolution, err)
478+ return _c
479+}
480+
481+func (_c *CIStore_GetActionResolution_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32, key string) (domain.CIActionResolution, error)) *CIStore_GetActionResolution_Call {
482+ _c.Call.Return(run)
483+ return _c
484+}
485+
408 // GetJob provides a mock function for the type CIStore486 // GetJob provides a mock function for the type CIStore
409 func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {487 func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
410 ret := _mock.Called(ctx, runID, runAttempt, jobID)488 ret := _mock.Called(ctx, runID, runAttempt, jobID)
A internal/ports/mocks/GitArchive.go +124 -0
@@ -0,0 +1,124 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewGitArchive creates a new instance of GitArchive. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewGitArchive(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *GitArchive {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &GitArchive{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// GitArchive is an autogenerated mock type for the GitArchive type
39+type GitArchive struct {
40+ mock.Mock
41+}
42+
43+type GitArchive_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *GitArchive) EXPECT() *GitArchive_Expecter {
48+ return &GitArchive_Expecter{mock: &_m.Mock}
49+}
50+
51+// Archive provides a mock function for the type GitArchive
52+func (_mock *GitArchive) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
53+ ret := _mock.Called(ctx, repo, commit, prefix, w)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Archive")
57+ }
58+
59+ var r0 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, string, io.Writer) error); ok {
61+ r0 = returnFunc(ctx, repo, commit, prefix, w)
62+ } else {
63+ r0 = ret.Error(0)
64+ }
65+ return r0
66+}
67+
68+// GitArchive_Archive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Archive'
69+type GitArchive_Archive_Call struct {
70+ *mock.Call
71+}
72+
73+// Archive is a helper method to define mock.On call
74+// - ctx context.Context
75+// - repo domain.RepoName
76+// - commit domain.ObjectID
77+// - prefix string
78+// - w io.Writer
79+func (_e *GitArchive_Expecter) Archive(ctx any, repo any, commit any, prefix any, w any) *GitArchive_Archive_Call {
80+ return &GitArchive_Archive_Call{Call: _e.mock.On("Archive", ctx, repo, commit, prefix, w)}
81+}
82+
83+func (_c *GitArchive_Archive_Call) Run(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer)) *GitArchive_Archive_Call {
84+ _c.Call.Run(func(args mock.Arguments) {
85+ var arg0 context.Context
86+ if args[0] != nil {
87+ arg0 = args[0].(context.Context)
88+ }
89+ var arg1 domain.RepoName
90+ if args[1] != nil {
91+ arg1 = args[1].(domain.RepoName)
92+ }
93+ var arg2 domain.ObjectID
94+ if args[2] != nil {
95+ arg2 = args[2].(domain.ObjectID)
96+ }
97+ var arg3 string
98+ if args[3] != nil {
99+ arg3 = args[3].(string)
100+ }
101+ var arg4 io.Writer
102+ if args[4] != nil {
103+ arg4 = args[4].(io.Writer)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ arg3,
110+ arg4,
111+ )
112+ })
113+ return _c
114+}
115+
116+func (_c *GitArchive_Archive_Call) Return(err error) *GitArchive_Archive_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *GitArchive_Archive_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error) *GitArchive_Archive_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
@@ -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 +288 -0
@@ -0,0 +1,288 @@
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+}
@@ -0,0 +1,288 @@
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+}
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+}
@@ -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)
@@ -417,6 +417,86 @@ func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context
417 return _c417 return _c
418 }418 }
419 419
420+// ResolveActions provides a mock function for the type RunnerService
421+func (_mock *RunnerService) ResolveActions(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error) {
422+ ret := _mock.Called(ctx, secret, jobID, uses)
423+
424+ if len(ret) == 0 {
425+ panic("no return value specified for ResolveActions")
426+ }
427+
428+ var r0 []ports.ResolvedAction
429+ var r1 error
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) ([]ports.ResolvedAction, error)); ok {
431+ return returnFunc(ctx, secret, jobID, uses)
432+ }
433+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []string) []ports.ResolvedAction); ok {
434+ r0 = returnFunc(ctx, secret, jobID, uses)
435+ } else {
436+ if ret.Get(0) != nil {
437+ r0 = ret.Get(0).([]ports.ResolvedAction)
438+ }
439+ }
440+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []string) error); ok {
441+ r1 = returnFunc(ctx, secret, jobID, uses)
442+ } else {
443+ r1 = ret.Error(1)
444+ }
445+ return r0, r1
446+}
447+
448+// RunnerService_ResolveActions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveActions'
449+type RunnerService_ResolveActions_Call struct {
450+ *mock.Call
451+}
452+
453+// ResolveActions is a helper method to define mock.On call
454+// - ctx context.Context
455+// - secret string
456+// - jobID string
457+// - uses []string
458+func (_e *RunnerService_Expecter) ResolveActions(ctx any, secret any, jobID any, uses any) *RunnerService_ResolveActions_Call {
459+ return &RunnerService_ResolveActions_Call{Call: _e.mock.On("ResolveActions", ctx, secret, jobID, uses)}
460+}
461+
462+func (_c *RunnerService_ResolveActions_Call) Run(run func(ctx context.Context, secret string, jobID string, uses []string)) *RunnerService_ResolveActions_Call {
463+ _c.Call.Run(func(args mock.Arguments) {
464+ var arg0 context.Context
465+ if args[0] != nil {
466+ arg0 = args[0].(context.Context)
467+ }
468+ var arg1 string
469+ if args[1] != nil {
470+ arg1 = args[1].(string)
471+ }
472+ var arg2 string
473+ if args[2] != nil {
474+ arg2 = args[2].(string)
475+ }
476+ var arg3 []string
477+ if args[3] != nil {
478+ arg3 = args[3].([]string)
479+ }
480+ run(
481+ arg0,
482+ arg1,
483+ arg2,
484+ arg3,
485+ )
486+ })
487+ return _c
488+}
489+
490+func (_c *RunnerService_ResolveActions_Call) Return(resolvedActions []ports.ResolvedAction, err error) *RunnerService_ResolveActions_Call {
491+ _c.Call.Return(resolvedActions, err)
492+ return _c
493+}
494+
495+func (_c *RunnerService_ResolveActions_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, uses []string) ([]ports.ResolvedAction, error)) *RunnerService_ResolveActions_Call {
496+ _c.Call.Return(run)
497+ return _c
498+}
499+
420 // UpdateJob provides a mock function for the type RunnerService500 // UpdateJob provides a mock function for the type RunnerService
421 func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {501 func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
422 ret := _mock.Called(ctx, secret, jobID, steps)502 ret := _mock.Called(ctx, secret, jobID, steps)
M internal/ports/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 {
@@ -200,6 +200,14 @@ type GitBackend interface {
200 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)200 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
201 }201 }
202 202
203+// GitArchive writes the archive of a commit.
204+type GitArchive interface {
205+ // Archive writes the tree of the commit as a gzip-compressed tar to w, all
206+ // the paths below the directory prefix. It writes nothing and returns
207+ // domain.ErrNotFound when the repository or the commit does not exist.
208+ Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error
209+}
210+
203 // TokenStore keeps the stored tokens and the index from secret hash to token.211 // TokenStore keeps the stored tokens and the index from secret hash to token.
204 // The secret itself is never stored.212 // The secret itself is never stored.
205 type TokenStore interface {213 type TokenStore interface {
M internal/ports/services.go +18 -0
@@ -55,6 +55,24 @@ 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. begin is called once, with the file name of the
71+ // archive, before the first byte and not at all when the ref does not
72+ // resolve (domain.ErrNotFound).
73+ Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
74+}
75+
5876 // BrowseService is the read side of a repository's content. *usecase.Browse
5977 // implements it.
6078 type BrowseService interface {
@@ -55,6 +55,24 @@ type TokenService interface {
55 Revoke(ctx context.Context, p domain.Principal, id string) error55 Revoke(ctx context.Context, p domain.Principal, id string) error
56 }56 }
57 57
58+// GitHubAPIService is the subset of GitHub's REST API that ermite serves under
59+// /-/github/api, for the actions that run on it (ADR 0025, CI-2). owner is the
60+// owner of the URL: only ERMITE_OWNER names repositories, any other owner is
61+// domain.ErrNotFound, as is a repository the principal may not read.
62+// *usecase.CI implements it.
63+type GitHubAPIService interface {
64+ // GetRepo returns the JSON of GET /repos/{owner}/{repo}.
65+ GetRepo(ctx context.Context, p domain.Principal, owner, repo string) ([]byte, error)
66+ // HashAlgorithm returns the object format of the repository, "sha1".
67+ HashAlgorithm(ctx context.Context, p domain.Principal, owner, repo string) (string, error)
68+ // Tarball resolves ref (a branch, a tag or a commit id; empty means the
69+ // default branch) and writes the gzip-compressed tar of the commit to the
70+ // writer that begin returns. begin is called once, with the file name of the
71+ // archive, before the first byte and not at all when the ref does not
72+ // resolve (domain.ErrNotFound).
73+ Tarball(ctx context.Context, p domain.Principal, owner, repo, ref string, begin func(filename string) io.Writer) error
74+}
75+
58 // BrowseService is the read side of a repository's content. *usecase.Browse76 // BrowseService is the read side of a repository's content. *usecase.Browse
59 // implements it.77 // implements it.
60 type BrowseService interface {78 type BrowseService interface {
M internal/usecase/ci.go +29 -9
@@ -56,6 +56,11 @@ type CIConfig struct {
5656 Clock ports.Clock
5757 // Auth re-validates the token of a log stream; nil skips it.
5858 Auth ports.Revalidator
59+ // Remote lists the refs of github.com repositories for ResolveActions; nil
60+ // answers every github.com action with unavailable.
61+ Remote ports.GitRemote
62+ // Archive serves the tarballs of the REST subset.
63+ Archive ports.GitArchive
5964 // Owner is the owner login of the github context.
6065 Owner string
6166 // PublicURL is the base URL of the instance (github.server_url).
@@ -122,9 +127,14 @@ type CI struct {
122127 heartbeats map[string]time.Time // job reference -> last heartbeat
123128 seen map[string]time.Time // runner id -> last seen
124129 polls map[string]pollRecord
125- streams int
126- perClient map[string]int
127- closed bool
130+ // archiveTokens are the secrets of the tokens given for the archives of
131+ // ermite actions, per job reference then repository, so that a job that
132+ // resolves the same repository twice gets one token (memory only: a
133+ // restart issues new ones).
134+ archiveTokens map[string]map[domain.RepoName]string
135+ streams int
136+ perClient map[string]int
137+ closed bool
128138 }
129139
130140 var (
@@ -137,10 +147,11 @@ var (
137147 // pollRecord remembers the assignment a poll made, for the retry of a lost
138148 // response (FetchJobRequest.poll_id). The token is kept in memory only.
139149 type pollRecord struct {
140- pollID string
141- ref string
142- token string
143- hash domain.TokenHash
150+ pollID string
151+ ref string
152+ token string
153+ ghToken string
154+ hash domain.TokenHash
144155 }
145156
146157 // NewCI builds the CI use cases. ctx carries the logger; its cancellation is
@@ -155,7 +166,8 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
155166 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
156167 ctx: run, stop: stop, flows: newWfCache(64),
157168 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
158- perClient: map[string]int{},
169+ archiveTokens: map[string]map[domain.RepoName]string{},
170+ perClient: map[string]int{},
159171 }
160172 }
161173
@@ -258,6 +270,12 @@ type runState struct {
258270 // chk marks jobs whose check must be written even when the job itself did
259271 // not change (a rerun moves every check to the new attempt).
260272 checks map[string]bool
273+ // tokens are the job repository tokens to create, revoke the jobs whose
274+ // tokens are deleted, with the next flush.
275+ tokens []domain.CIRepoToken
276+ revoke []ports.CIJobRef
277+ // resolutions are the action resolutions to keep with the next flush.
278+ resolutions []domain.CIActionResolution
261279 }
262280
263281 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)
@@ -341,7 +359,8 @@ func (rs *runState) flush(ctx context.Context) error {
341359 if rs.runDirty {
342360 w.Runs = []domain.CIRun{rs.run}
343361 }
344- if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 {
362+ w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions
363+ 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 {
345364 return nil
346365 }
347366 v, err := c.cfg.Store.Apply(ctx, w)
@@ -356,6 +375,7 @@ func (rs *runState) flush(ctx context.Context) error {
356375 }
357376 changedRun := rs.runDirty
358377 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
378+ rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
359379
360380 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
361381 if err != nil {
@@ -56,6 +56,11 @@ type CIConfig struct {
56 Clock ports.Clock56 Clock ports.Clock
57 // Auth re-validates the token of a log stream; nil skips it.57 // Auth re-validates the token of a log stream; nil skips it.
58 Auth ports.Revalidator58 Auth ports.Revalidator
59+ // Remote lists the refs of github.com repositories for ResolveActions; nil
60+ // answers every github.com action with unavailable.
61+ Remote ports.GitRemote
62+ // Archive serves the tarballs of the REST subset.
63+ Archive ports.GitArchive
59 // Owner is the owner login of the github context.64 // Owner is the owner login of the github context.
60 Owner string65 Owner string
61 // PublicURL is the base URL of the instance (github.server_url).66 // PublicURL is the base URL of the instance (github.server_url).
@@ -122,9 +127,14 @@ type CI struct {
122 heartbeats map[string]time.Time // job reference -> last heartbeat127 heartbeats map[string]time.Time // job reference -> last heartbeat
123 seen map[string]time.Time // runner id -> last seen128 seen map[string]time.Time // runner id -> last seen
124 polls map[string]pollRecord129 polls map[string]pollRecord
125- streams int130+ // archiveTokens are the secrets of the tokens given for the archives of
126- perClient map[string]int131+ // ermite actions, per job reference then repository, so that a job that
127- closed bool132+ // resolves the same repository twice gets one token (memory only: a
133+ // restart issues new ones).
134+ archiveTokens map[string]map[domain.RepoName]string
135+ streams int
136+ perClient map[string]int
137+ closed bool
128 }138 }
129 139
130 var (140 var (
@@ -137,10 +147,11 @@ var (
137 // pollRecord remembers the assignment a poll made, for the retry of a lost147 // pollRecord remembers the assignment a poll made, for the retry of a lost
138 // response (FetchJobRequest.poll_id). The token is kept in memory only.148 // response (FetchJobRequest.poll_id). The token is kept in memory only.
139 type pollRecord struct {149 type pollRecord struct {
140- pollID string150+ pollID string
141- ref string151+ ref string
142- token string152+ token string
143- hash domain.TokenHash153+ ghToken string
154+ hash domain.TokenHash
144 }155 }
145 156
146 // NewCI builds the CI use cases. ctx carries the logger; its cancellation is157 // NewCI builds the CI use cases. ctx carries the logger; its cancellation is
@@ -155,7 +166,8 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
155 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),166 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
156 ctx: run, stop: stop, flows: newWfCache(64),167 ctx: run, stop: stop, flows: newWfCache(64),
157 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},168 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
158- perClient: map[string]int{},169+ archiveTokens: map[string]map[domain.RepoName]string{},
170+ perClient: map[string]int{},
159 }171 }
160 }172 }
161 173
@@ -258,6 +270,12 @@ type runState struct {
258 // chk marks jobs whose check must be written even when the job itself did270 // chk marks jobs whose check must be written even when the job itself did
259 // not change (a rerun moves every check to the new attempt).271 // not change (a rerun moves every check to the new attempt).
260 checks map[string]bool272 checks map[string]bool
273+ // tokens are the job repository tokens to create, revoke the jobs whose
274+ // tokens are deleted, with the next flush.
275+ tokens []domain.CIRepoToken
276+ revoke []ports.CIJobRef
277+ // resolutions are the action resolutions to keep with the next flush.
278+ resolutions []domain.CIActionResolution
261 }279 }
262 280
263 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)281 // withRun locks the run, loads it with the jobs of attempt (0 for the latest)
@@ -341,7 +359,8 @@ func (rs *runState) flush(ctx context.Context) error {
341 if rs.runDirty {359 if rs.runDirty {
342 w.Runs = []domain.CIRun{rs.run}360 w.Runs = []domain.CIRun{rs.run}
343 }361 }
344- if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 {362+ w.RepoTokens, w.RevokeJobs, w.Resolutions = rs.tokens, rs.revoke, rs.resolutions
363+ if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 && len(w.RepoTokens) == 0 && len(w.RevokeJobs) == 0 && len(w.Resolutions) == 0 {
345 return nil364 return nil
346 }365 }
347 v, err := c.cfg.Store.Apply(ctx, w)366 v, err := c.cfg.Store.Apply(ctx, w)
@@ -356,6 +375,7 @@ func (rs *runState) flush(ctx context.Context) error {
356 }375 }
357 changedRun := rs.runDirty376 changedRun := rs.runDirty
358 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false377 rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
378+ rs.tokens, rs.revoke, rs.resolutions = nil, nil, nil
359 379
360 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)380 repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
361 if err != nil {381 if err != nil {
A internal/usecase/ci_actions.go +359 -0
@@ -0,0 +1,359 @@
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+ j, err := c.jobFor(ctx, secret, jobID)
35+ if err != nil {
36+ return nil, err
37+ }
38+ if j.Ended() {
39+ return nil, endedError(j)
40+ }
41+ if len(uses) > maxActionRefs {
42+ return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs)
43+ }
44+ parsed := make([]actionRef, len(uses))
45+ for i, u := range uses {
46+ if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") {
47+ return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u)
48+ }
49+ if strings.HasPrefix(u, "docker://") {
50+ return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u)
51+ }
52+ parsed[i] = parseActionRef(u)
53+ }
54+
55+ // Resolve what the attempt has not resolved yet, outside the lock of the
56+ // run: a ls-remote takes seconds and must not hold up the job's calls.
57+ found := map[string]domain.CIActionResolution{}
58+ fails := map[string]string{}
59+ remote := newRemoteRefs(c.cfg.Remote)
60+ for _, p := range parsed {
61+ if p.err != "" {
62+ continue
63+ }
64+ if _, done := found[p.key()]; done {
65+ continue
66+ }
67+ if _, bad := fails[p.key()]; bad {
68+ continue
69+ }
70+ if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil {
71+ found[p.key()] = res
72+ continue
73+ } else if !errors.Is(err, domain.ErrNotFound) {
74+ return nil, err
75+ }
76+ res, msg, err := c.resolveAction(ctx, j, p, remote)
77+ if err != nil {
78+ return nil, err
79+ }
80+ if msg != "" {
81+ fails[p.key()] = msg
82+ continue
83+ }
84+ found[p.key()] = res
85+ }
86+
87+ out := make([]ports.ResolvedAction, len(parsed))
88+ err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
89+ if cur.Ended() {
90+ return endedError(*cur)
91+ }
92+ rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt)
93+ if err != nil {
94+ return err
95+ }
96+ own := rs.find(cur.ID)
97+ if own == nil {
98+ return domain.ErrNotFound
99+ }
100+ for i, p := range parsed {
101+ out[i] = ports.ResolvedAction{Ref: p.uses}
102+ if p.err != "" {
103+ out[i].Error = p.err
104+ continue
105+ }
106+ if msg, bad := fails[p.key()]; bad {
107+ out[i].Error = msg
108+ continue
109+ }
110+ // The first resolution stored wins, ours or a concurrent job's.
111+ res, ok := found[p.key()]
112+ if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil {
113+ res, ok = cached, true
114+ } else if !errors.Is(err, domain.ErrNotFound) {
115+ return err
116+ } else if ok {
117+ rs.resolutions = append(rs.resolutions, res)
118+ found[p.key()] = res // two refs of the call with the same key
119+ }
120+ if !ok {
121+ continue
122+ }
123+ ra, err := c.answer(ctx, rs, own, p, res)
124+ if err != nil {
125+ return err
126+ }
127+ out[i] = ra
128+ }
129+ if err := rs.flush(ctx); err != nil {
130+ // The tokens were not stored: forget their secrets.
131+ c.mu.Lock()
132+ delete(c.archiveTokens, jobRef(*cur))
133+ c.mu.Unlock()
134+ return err
135+ }
136+ return nil
137+ })
138+ if err != nil {
139+ return nil, err
140+ }
141+ return out, nil
142+}
143+
144+// maxActionRefs is the most references of one ResolveActions call.
145+const maxActionRefs = 100
146+
147+// answer builds the answer for a resolved reference. The archive token of an
148+// ermite action is the one the job already holds for that repository, else a
149+// new one, written with the next flush.
150+func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) {
151+ ra := ports.ResolvedAction{
152+ Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
153+ }
154+ switch res.Source {
155+ case domain.CIActionGitHub:
156+ // The case the workflow wrote: the stored resolution is shared by
157+ // spellings that differ only by case, and github.com ignores it.
158+ ra.NameWithOwner = p.owner + "/" + p.repo
159+ ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
160+ case domain.CIActionErmite:
161+ name, err := domain.ParseRepoName(strings.ToLower(p.repo))
162+ if err != nil {
163+ return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
164+ }
165+ if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) {
166+ return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
167+ } else if err != nil {
168+ return ports.ResolvedAction{}, err
169+ }
170+ ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
171+ ra.Token = c.archiveToken(rs, j, name)
172+ }
173+ return ra, nil
174+}
175+
176+// archiveToken returns the secret of the read token of the job for the
177+// archives of repo, issuing it when the job has none.
178+func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string {
179+ ref := jobRef(*j)
180+ c.mu.Lock()
181+ tok, ok := c.archiveTokens[ref][repo]
182+ c.mu.Unlock()
183+ if ok {
184+ return tok
185+ }
186+ tok = rs.issueRepoTokenFor(j, repo, true)
187+ c.mu.Lock()
188+ if c.archiveTokens[ref] == nil {
189+ c.archiveTokens[ref] = map[domain.RepoName]string{}
190+ }
191+ c.archiveTokens[ref][repo] = tok
192+ c.mu.Unlock()
193+ return tok
194+}
195+
196+// actionRef is a parsed "uses": owner/repo[/path]@ref.
197+type actionRef struct {
198+ uses, owner, repo, path, ref string
199+ // err is GitHub's message for a reference of the wrong shape.
200+ err string
201+}
202+
203+// key identifies the resolution: GitHub names are case-insensitive, refs are
204+// not.
205+func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref }
206+
207+// parseActionRef splits a "uses" of a repository action. Only the shape
208+// owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and
209+// ignores empty segments (WorkflowTemplateConverter.cs), so do we.
210+func parseActionRef(uses string) actionRef {
211+ a := actionRef{uses: uses}
212+ left, ref, ok := strings.Cut(uses, "@")
213+ segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' })
214+ if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" {
215+ a.err = errActionFormat(uses)
216+ return a
217+ }
218+ for _, s := range segs[2:] {
219+ if s == "." || s == ".." {
220+ a.err = errActionFormat(uses)
221+ return a
222+ }
223+ }
224+ a.owner, a.repo, a.ref = segs[0], segs[1], ref
225+ a.path = strings.Join(segs[2:], "/")
226+ return a
227+}
228+
229+// --- GitHub's texts ---
230+//
231+// All the messages a reference can fail with are here. The first is the
232+// runner's own parser text; the two others are the ones GitHub gives for an
233+// unknown version and an unknown repository, to be confirmed by recording
234+// against github.com (ci2-execution plan, phase A).
235+
236+func errActionFormat(uses string) string {
237+ return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'"
238+}
239+
240+func errUnresolvedRef(a actionRef) string {
241+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`"
242+}
243+
244+func errUnresolvedRepo(a actionRef) string {
245+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found"
246+}
247+
248+// resolveAction resolves one reference. msg is the message of a reference that
249+// does not resolve; err is a failure of the call.
250+func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) {
251+ res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
252+ if strings.EqualFold(a.owner, c.cfg.Owner) {
253+ if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {
254+ if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil {
255+ sha, found, err := c.resolveOnErmite(ctx, name, a.ref)
256+ if err != nil {
257+ return res, "", err
258+ }
259+ if !found {
260+ return res, errUnresolvedRef(a), nil
261+ }
262+ res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite
263+ return res, "", nil
264+ } else if !errors.Is(gerr, domain.ErrNotFound) {
265+ return res, "", gerr
266+ }
267+ }
268+ }
269+ res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
270+ if low := strings.ToLower(a.ref); isHex40(low) {
271+ res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download
272+ return res, "", nil
273+ }
274+ refs, err := remote.list(ctx, a.owner, a.repo)
275+ switch {
276+ case errors.Is(err, domain.ErrNotFound):
277+ return res, errUnresolvedRepo(a), nil
278+ case err != nil:
279+ return res, "", err
280+ }
281+ sha := pickRemoteRef(refs, a.ref)
282+ if sha == "" {
283+ return res, errUnresolvedRef(a), nil
284+ }
285+ res.ResolvedSHA = sha
286+ return res, "", nil
287+}
288+
289+// pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a
290+// remote. An annotated tag gives its peeled commit.
291+func pickRemoteRef(refs []ports.RemoteRef, name string) string {
292+ for _, prefix := range []string{"refs/tags/", "refs/heads/"} {
293+ for _, r := range refs {
294+ if r.Name != prefix+name {
295+ continue
296+ }
297+ if r.Peeled != "" {
298+ return r.Peeled
299+ }
300+ return r.ID
301+ }
302+ }
303+ return ""
304+}
305+
306+// resolveOnErmite resolves a ref on a repository of this server: a tag, else
307+// a branch, else a full commit id that exists.
308+func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) {
309+ refs, err := c.cfg.Reader.ListRefs(ctx, repo)
310+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
311+ return "", false, err
312+ }
313+ for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} {
314+ i := slices.IndexFunc(refs, func(r domain.Ref) bool {
315+ return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero()
316+ })
317+ if i >= 0 {
318+ return refs[i].CommitID.String(), true, nil
319+ }
320+ }
321+ if id, perr := domain.ParseObjectID(ref); perr == nil {
322+ ok, err := c.cfg.Git.HasCommit(ctx, repo, id)
323+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
324+ return "", false, err
325+ }
326+ if ok {
327+ return id.String(), true, nil
328+ }
329+ }
330+ return "", false, nil
331+}
332+
333+// remoteRefs lists the refs of a remote once per repository within a call.
334+type remoteRefs struct {
335+ remote ports.GitRemote
336+ seen map[string]remoteList
337+}
338+
339+type remoteList struct {
340+ refs []ports.RemoteRef
341+ err error
342+}
343+
344+func newRemoteRefs(r ports.GitRemote) *remoteRefs {
345+ return &remoteRefs{remote: r, seen: map[string]remoteList{}}
346+}
347+
348+func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
349+ if r.remote == nil {
350+ return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
351+ }
352+ key := strings.ToLower(owner + "/" + repo)
353+ if l, ok := r.seen[key]; ok {
354+ return l.refs, l.err
355+ }
356+ refs, err := r.remote.ListRefs(ctx, owner, repo)
357+ r.seen[key] = remoteList{refs, err}
358+ return refs, err
359+}
@@ -0,0 +1,359 @@
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+ j, err := c.jobFor(ctx, secret, jobID)
35+ if err != nil {
36+ return nil, err
37+ }
38+ if j.Ended() {
39+ return nil, endedError(j)
40+ }
41+ if len(uses) > maxActionRefs {
42+ return nil, fmt.Errorf("%w: at most %d actions per call", domain.ErrInvalid, maxActionRefs)
43+ }
44+ parsed := make([]actionRef, len(uses))
45+ for i, u := range uses {
46+ if strings.HasPrefix(u, "./") || strings.HasPrefix(u, ".\\") {
47+ return nil, fmt.Errorf("%w: %q is a local action: the runner reads it from the workspace", domain.ErrInvalid, u)
48+ }
49+ if strings.HasPrefix(u, "docker://") {
50+ return nil, fmt.Errorf("%w: %q is a Docker image: the runner pulls it", domain.ErrInvalid, u)
51+ }
52+ parsed[i] = parseActionRef(u)
53+ }
54+
55+ // Resolve what the attempt has not resolved yet, outside the lock of the
56+ // run: a ls-remote takes seconds and must not hold up the job's calls.
57+ found := map[string]domain.CIActionResolution{}
58+ fails := map[string]string{}
59+ remote := newRemoteRefs(c.cfg.Remote)
60+ for _, p := range parsed {
61+ if p.err != "" {
62+ continue
63+ }
64+ if _, done := found[p.key()]; done {
65+ continue
66+ }
67+ if _, bad := fails[p.key()]; bad {
68+ continue
69+ }
70+ if res, err := c.cfg.Store.GetActionResolution(ctx, j.RunID, j.RunAttempt, p.key()); err == nil {
71+ found[p.key()] = res
72+ continue
73+ } else if !errors.Is(err, domain.ErrNotFound) {
74+ return nil, err
75+ }
76+ res, msg, err := c.resolveAction(ctx, j, p, remote)
77+ if err != nil {
78+ return nil, err
79+ }
80+ if msg != "" {
81+ fails[p.key()] = msg
82+ continue
83+ }
84+ found[p.key()] = res
85+ }
86+
87+ out := make([]ports.ResolvedAction, len(parsed))
88+ err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
89+ if cur.Ended() {
90+ return endedError(*cur)
91+ }
92+ rs, err := c.loadRun(ctx, cur.RunID, cur.RunAttempt)
93+ if err != nil {
94+ return err
95+ }
96+ own := rs.find(cur.ID)
97+ if own == nil {
98+ return domain.ErrNotFound
99+ }
100+ for i, p := range parsed {
101+ out[i] = ports.ResolvedAction{Ref: p.uses}
102+ if p.err != "" {
103+ out[i].Error = p.err
104+ continue
105+ }
106+ if msg, bad := fails[p.key()]; bad {
107+ out[i].Error = msg
108+ continue
109+ }
110+ // The first resolution stored wins, ours or a concurrent job's.
111+ res, ok := found[p.key()]
112+ if cached, err := c.cfg.Store.GetActionResolution(ctx, cur.RunID, cur.RunAttempt, p.key()); err == nil {
113+ res, ok = cached, true
114+ } else if !errors.Is(err, domain.ErrNotFound) {
115+ return err
116+ } else if ok {
117+ rs.resolutions = append(rs.resolutions, res)
118+ found[p.key()] = res // two refs of the call with the same key
119+ }
120+ if !ok {
121+ continue
122+ }
123+ ra, err := c.answer(ctx, rs, own, p, res)
124+ if err != nil {
125+ return err
126+ }
127+ out[i] = ra
128+ }
129+ if err := rs.flush(ctx); err != nil {
130+ // The tokens were not stored: forget their secrets.
131+ c.mu.Lock()
132+ delete(c.archiveTokens, jobRef(*cur))
133+ c.mu.Unlock()
134+ return err
135+ }
136+ return nil
137+ })
138+ if err != nil {
139+ return nil, err
140+ }
141+ return out, nil
142+}
143+
144+// maxActionRefs is the most references of one ResolveActions call.
145+const maxActionRefs = 100
146+
147+// answer builds the answer for a resolved reference. The archive token of an
148+// ermite action is the one the job already holds for that repository, else a
149+// new one, written with the next flush.
150+func (c *CI) answer(ctx context.Context, rs *runState, j *domain.CIJob, p actionRef, res domain.CIActionResolution) (ports.ResolvedAction, error) {
151+ ra := ports.ResolvedAction{
152+ Ref: p.uses, NameWithOwner: res.NameWithOwner, Path: p.path, ResolvedSHA: res.ResolvedSHA, Source: res.Source,
153+ }
154+ switch res.Source {
155+ case domain.CIActionGitHub:
156+ // The case the workflow wrote: the stored resolution is shared by
157+ // spellings that differ only by case, and github.com ignores it.
158+ ra.NameWithOwner = p.owner + "/" + p.repo
159+ ra.TarballURL = "https://codeload.github.com/" + ra.NameWithOwner + "/tar.gz/" + res.ResolvedSHA
160+ case domain.CIActionErmite:
161+ name, err := domain.ParseRepoName(strings.ToLower(p.repo))
162+ if err != nil {
163+ return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
164+ }
165+ if _, err := c.cfg.Meta.Get(ctx, name); errors.Is(err, domain.ErrNotFound) {
166+ return ports.ResolvedAction{Ref: p.uses, Error: errUnresolvedRepo(p)}, nil
167+ } else if err != nil {
168+ return ports.ResolvedAction{}, err
169+ }
170+ ra.TarballURL = c.cfg.PublicURL + apiPath + "/repos/" + res.NameWithOwner + "/tarball/" + res.ResolvedSHA
171+ ra.Token = c.archiveToken(rs, j, name)
172+ }
173+ return ra, nil
174+}
175+
176+// archiveToken returns the secret of the read token of the job for the
177+// archives of repo, issuing it when the job has none.
178+func (c *CI) archiveToken(rs *runState, j *domain.CIJob, repo domain.RepoName) string {
179+ ref := jobRef(*j)
180+ c.mu.Lock()
181+ tok, ok := c.archiveTokens[ref][repo]
182+ c.mu.Unlock()
183+ if ok {
184+ return tok
185+ }
186+ tok = rs.issueRepoTokenFor(j, repo, true)
187+ c.mu.Lock()
188+ if c.archiveTokens[ref] == nil {
189+ c.archiveTokens[ref] = map[domain.RepoName]string{}
190+ }
191+ c.archiveTokens[ref][repo] = tok
192+ c.mu.Unlock()
193+ return tok
194+}
195+
196+// actionRef is a parsed "uses": owner/repo[/path]@ref.
197+type actionRef struct {
198+ uses, owner, repo, path, ref string
199+ // err is GitHub's message for a reference of the wrong shape.
200+ err string
201+}
202+
203+// key identifies the resolution: GitHub names are case-insensitive, refs are
204+// not.
205+func (a actionRef) key() string { return strings.ToLower(a.owner+"/"+a.repo) + "@" + a.ref }
206+
207+// parseActionRef splits a "uses" of a repository action. Only the shape
208+// owner/repo[/path]@ref is valid; GitHub splits the path on '/' and '\' and
209+// ignores empty segments (WorkflowTemplateConverter.cs), so do we.
210+func parseActionRef(uses string) actionRef {
211+ a := actionRef{uses: uses}
212+ left, ref, ok := strings.Cut(uses, "@")
213+ segs := strings.FieldsFunc(left, func(r rune) bool { return r == '/' || r == '\\' })
214+ if !ok || strings.Contains(ref, "@") || len(segs) < 2 || ref == "" {
215+ a.err = errActionFormat(uses)
216+ return a
217+ }
218+ for _, s := range segs[2:] {
219+ if s == "." || s == ".." {
220+ a.err = errActionFormat(uses)
221+ return a
222+ }
223+ }
224+ a.owner, a.repo, a.ref = segs[0], segs[1], ref
225+ a.path = strings.Join(segs[2:], "/")
226+ return a
227+}
228+
229+// --- GitHub's texts ---
230+//
231+// All the messages a reference can fail with are here. The first is the
232+// runner's own parser text; the two others are the ones GitHub gives for an
233+// unknown version and an unknown repository, to be confirmed by recording
234+// against github.com (ci2-execution plan, phase A).
235+
236+func errActionFormat(uses string) string {
237+ return "Expected format {org}/{repo}[/path]@ref. Actual '" + uses + "'"
238+}
239+
240+func errUnresolvedRef(a actionRef) string {
241+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, unable to find version `" + a.ref + "`"
242+}
243+
244+func errUnresolvedRepo(a actionRef) string {
245+ return "Unable to resolve action `" + a.owner + "/" + a.repo + "@" + a.ref + "`, repository not found"
246+}
247+
248+// resolveAction resolves one reference. msg is the message of a reference that
249+// does not resolve; err is a failure of the call.
250+func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, remote *remoteRefs) (res domain.CIActionResolution, msg string, err error) {
251+ res = domain.CIActionResolution{RunID: j.RunID, RunAttempt: j.RunAttempt, Key: a.key()}
252+ if strings.EqualFold(a.owner, c.cfg.Owner) {
253+ if name, perr := domain.ParseRepoName(strings.ToLower(a.repo)); perr == nil {
254+ if _, gerr := c.cfg.Meta.Get(ctx, name); gerr == nil {
255+ sha, found, err := c.resolveOnErmite(ctx, name, a.ref)
256+ if err != nil {
257+ return res, "", err
258+ }
259+ if !found {
260+ return res, errUnresolvedRef(a), nil
261+ }
262+ res.NameWithOwner, res.ResolvedSHA, res.Source = c.cfg.Owner+"/"+name.String(), sha, domain.CIActionErmite
263+ return res, "", nil
264+ } else if !errors.Is(gerr, domain.ErrNotFound) {
265+ return res, "", gerr
266+ }
267+ }
268+ }
269+ res.NameWithOwner, res.Source = a.owner+"/"+a.repo, domain.CIActionGitHub
270+ if low := strings.ToLower(a.ref); isHex40(low) {
271+ res.ResolvedSHA = low // a commit id is not checked: a missing one fails the download
272+ return res, "", nil
273+ }
274+ refs, err := remote.list(ctx, a.owner, a.repo)
275+ switch {
276+ case errors.Is(err, domain.ErrNotFound):
277+ return res, errUnresolvedRepo(a), nil
278+ case err != nil:
279+ return res, "", err
280+ }
281+ sha := pickRemoteRef(refs, a.ref)
282+ if sha == "" {
283+ return res, errUnresolvedRef(a), nil
284+ }
285+ res.ResolvedSHA = sha
286+ return res, "", nil
287+}
288+
289+// pickRemoteRef finds the commit of a tag, else of a branch, in the refs of a
290+// remote. An annotated tag gives its peeled commit.
291+func pickRemoteRef(refs []ports.RemoteRef, name string) string {
292+ for _, prefix := range []string{"refs/tags/", "refs/heads/"} {
293+ for _, r := range refs {
294+ if r.Name != prefix+name {
295+ continue
296+ }
297+ if r.Peeled != "" {
298+ return r.Peeled
299+ }
300+ return r.ID
301+ }
302+ }
303+ return ""
304+}
305+
306+// resolveOnErmite resolves a ref on a repository of this server: a tag, else
307+// a branch, else a full commit id that exists.
308+func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref string) (sha string, found bool, err error) {
309+ refs, err := c.cfg.Reader.ListRefs(ctx, repo)
310+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
311+ return "", false, err
312+ }
313+ for _, kind := range []domain.RefKind{domain.RefKindTag, domain.RefKindBranch} {
314+ i := slices.IndexFunc(refs, func(r domain.Ref) bool {
315+ return r.Kind == kind && r.ShortName == ref && !r.CommitID.IsZero()
316+ })
317+ if i >= 0 {
318+ return refs[i].CommitID.String(), true, nil
319+ }
320+ }
321+ if id, perr := domain.ParseObjectID(ref); perr == nil {
322+ ok, err := c.cfg.Git.HasCommit(ctx, repo, id)
323+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
324+ return "", false, err
325+ }
326+ if ok {
327+ return id.String(), true, nil
328+ }
329+ }
330+ return "", false, nil
331+}
332+
333+// remoteRefs lists the refs of a remote once per repository within a call.
334+type remoteRefs struct {
335+ remote ports.GitRemote
336+ seen map[string]remoteList
337+}
338+
339+type remoteList struct {
340+ refs []ports.RemoteRef
341+ err error
342+}
343+
344+func newRemoteRefs(r ports.GitRemote) *remoteRefs {
345+ return &remoteRefs{remote: r, seen: map[string]remoteList{}}
346+}
347+
348+func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
349+ if r.remote == nil {
350+ return nil, fmt.Errorf("%w: this server cannot reach github.com", domain.ErrUnavailable)
351+ }
352+ key := strings.ToLower(owner + "/" + repo)
353+ if l, ok := r.seen[key]; ok {
354+ return l.refs, l.err
355+ }
356+ refs, err := r.remote.ListRefs(ctx, owner, repo)
357+ r.seen[key] = remoteList{refs, err}
358+ return refs, err
359+}
A internal/usecase/ci_actions_test.go +61 -0
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+func TestParseActionRef(t *testing.T) {
14+ t.Parallel()
15+ tests := []struct {
16+ uses string
17+ owner, repo, path, ref, fail string
18+ }{
19+ {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"},
20+ {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"},
21+ {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"},
22+ {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators
23+ {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments
24+ {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"},
25+ {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"},
26+ {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"},
27+ {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"},
28+ {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
29+ {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
30+ {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
31+ {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.uses, func(t *testing.T) {
35+ t.Parallel()
36+ a := parseActionRef(tt.uses)
37+ assert.Equal(t, tt.fail, a.err)
38+ if tt.fail == "" {
39+ assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref})
40+ }
41+ })
42+ }
43+ assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive")
44+ assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not")
45+}
46+
47+func TestPickRemoteRef(t *testing.T) {
48+ t.Parallel()
49+ refs := []ports.RemoteRef{
50+ {Name: "refs/heads/v1", ID: "b1"},
51+ {Name: "refs/tags/v1", ID: "t1"},
52+ {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"},
53+ {Name: "refs/heads/main", ID: "m"},
54+ }
55+ assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch")
56+ assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag")
57+ assert.Equal(t, "m", pickRemoteRef(refs, "main"))
58+ assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only")
59+ assert.Empty(t, pickRemoteRef(refs, "nope"))
60+ assert.Empty(t, pickRemoteRef(nil, "v1"))
61+}
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+func TestParseActionRef(t *testing.T) {
14+ t.Parallel()
15+ tests := []struct {
16+ uses string
17+ owner, repo, path, ref, fail string
18+ }{
19+ {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"},
20+ {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"},
21+ {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"},
22+ {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators
23+ {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments
24+ {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"},
25+ {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"},
26+ {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"},
27+ {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"},
28+ {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
29+ {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
30+ {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
31+ {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.uses, func(t *testing.T) {
35+ t.Parallel()
36+ a := parseActionRef(tt.uses)
37+ assert.Equal(t, tt.fail, a.err)
38+ if tt.fail == "" {
39+ assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref})
40+ }
41+ })
42+ }
43+ assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive")
44+ assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not")
45+}
46+
47+func TestPickRemoteRef(t *testing.T) {
48+ t.Parallel()
49+ refs := []ports.RemoteRef{
50+ {Name: "refs/heads/v1", ID: "b1"},
51+ {Name: "refs/tags/v1", ID: "t1"},
52+ {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"},
53+ {Name: "refs/heads/main", ID: "m"},
54+ }
55+ assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch")
56+ assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag")
57+ assert.Equal(t, "m", pickRemoteRef(refs, "main"))
58+ assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only")
59+ assert.Empty(t, pickRemoteRef(refs, "nope"))
60+ assert.Empty(t, pickRemoteRef(nil, "v1"))
61+}
A internal/usecase/ci_repotoken.go +69 -0
@@ -0,0 +1,69 @@
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, archive bool) string {
40+ return rs.issueRepoTokenFor(j, rs.run.Repo, archive)
41+}
42+
43+// issueRepoTokenFor is issueRepoToken for a token that reads repo.
44+func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string {
45+ c := rs.c
46+ now := c.now()
47+ at := *j
48+ if at.AssignedAt.IsZero() {
49+ at.AssignedAt = now
50+ }
51+ secret := newToken(domain.RepoTokenPrefix)
52+ t := c.repoTokenExpiry(at)
53+ t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
54+ t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now
55+ rs.tokens = append(rs.tokens, t)
56+ return secret
57+}
58+
59+// revokeTokens deletes, with the next flush, every repository token of j, and
60+// forgets the archive tokens kept in memory.
61+func (rs *runState) revokeTokens(j *domain.CIJob) {
62+ ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID}
63+ if !slices.Contains(rs.revoke, ref) {
64+ rs.revoke = append(rs.revoke, ref)
65+ }
66+ rs.c.mu.Lock()
67+ delete(rs.c.archiveTokens, jobRef(*j))
68+ rs.c.mu.Unlock()
69+}
@@ -0,0 +1,69 @@
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, archive bool) string {
40+ return rs.issueRepoTokenFor(j, rs.run.Repo, archive)
41+}
42+
43+// issueRepoTokenFor is issueRepoToken for a token that reads repo.
44+func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, archive bool) string {
45+ c := rs.c
46+ now := c.now()
47+ at := *j
48+ if at.AssignedAt.IsZero() {
49+ at.AssignedAt = now
50+ }
51+ secret := newToken(domain.RepoTokenPrefix)
52+ t := c.repoTokenExpiry(at)
53+ t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
54+ t.RunID, t.RunAttempt, t.JobID, t.Archive, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, archive, now
55+ rs.tokens = append(rs.tokens, t)
56+ return secret
57+}
58+
59+// revokeTokens deletes, with the next flush, every repository token of j, and
60+// forgets the archive tokens kept in memory.
61+func (rs *runState) revokeTokens(j *domain.CIJob) {
62+ ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID}
63+ if !slices.Contains(rs.revoke, ref) {
64+ rs.revoke = append(rs.revoke, ref)
65+ }
66+ rs.c.mu.Lock()
67+ delete(rs.c.archiveTokens, jobRef(*j))
68+ rs.c.mu.Unlock()
69+}
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, false)
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
@@ -207,16 +207,16 @@ func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID stri
207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {207 if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208 return errNoJob208 return errNoJob
209 }209 }
210- out = c.runnerJob(rs, *j, rec.token)210+ out = c.runnerJob(rs, *j, rec.token, rec.ghToken)
211 return nil211 return nil
212 })212 })
213 return out, err == nil && out != nil213 return out, err == nil && out != nil
214 }214 }
215 215
216 // runnerJob is the job as the runner gets it.216 // runnerJob is the job as the runner gets it.
217-func (c *CI) runnerJob(rs *runState, j domain.CIJob, token string) *ports.RunnerJob {217+func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string) *ports.RunnerJob {
218 return &ports.RunnerJob{218 return &ports.RunnerJob{
219- Job: j, Token: token, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,219+ Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,220 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
221 }221 }
222 }222 }
@@ -241,6 +241,7 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
241 return errNoJob241 return errNoJob
242 }242 }
243 token := newToken(domain.JobTokenPrefix)243 token := newToken(domain.JobTokenPrefix)
244+ ghToken := rs.issueRepoToken(j, false)
244 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()245 j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
245 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}246 j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
246 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation247 j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
@@ -248,10 +249,10 @@ func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []str
248 if err := rs.flush(ctx); err != nil {249 if err := rs.flush(ctx); err != nil {
249 return err250 return err
250 }251 }
251- out = c.runnerJob(rs, *j, token)252+ out = c.runnerJob(rs, *j, token, ghToken)
252 if pollID != "" {253 if pollID != "" {
253 c.mu.Lock()254 c.mu.Lock()
254- c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, hash: j.TokenHash}255+ c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, ghToken: ghToken, hash: j.TokenHash}
255 c.mu.Unlock()256 c.mu.Unlock()
256 }257 }
257 return nil258 return nil
M internal/usecase/ci_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
@@ -189,6 +189,7 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
189 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}189 j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone190 j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
191 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}191 j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
192+ rs.revokeTokens(j) // the next assignment gets new ones
192 c.forgetPoll(jobRef(*j))193 c.forgetPoll(jobRef(*j))
193 if j.CancelReason != domain.CICancelNone {194 if j.CancelReason != domain.CICancelNone {
194 // Cancelled (by the owner, fail-fast or the time budget) before the195 // Cancelled (by the owner, fail-fast or the time budget) before the
M internal/usecase/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+}
@@ -273,6 +273,9 @@ type Auth struct {
273 root domain.TokenHash273 root domain.TokenHash
274 tokens ports.TokenStore274 tokens ports.TokenStore
275 clock ports.Clock275 clock ports.Clock
276+ // repoTokens resolves the job repository tokens (domain.RepoTokenPrefix);
277+ // nil when CI is not wired.
278+ repoTokens ports.CIRepoTokenStore
276 }279 }
277 280
278 var (281 var (
@@ -286,6 +289,17 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo
286 return &Auth{root: rootHash, tokens: tokens, clock: clock}289 return &Auth{root: rootHash, tokens: tokens, clock: clock}
287 }290 }
288 291
292+// WithRepoTokens makes Authenticate accept the job repository tokens of CI
293+// (the "github.token" of a job, ADR 0025). They resolve to a principal shaped
294+// like a stored token (repo:read on one repository, with an expiry), so no
295+// rule of the forge knows them; they are stored apart from the stored tokens,
296+// so ListTokens never shows them and token:admin never manages them. Call it
297+// once while wiring.
298+func (a *Auth) WithRepoTokens(s ports.CIRepoTokenStore) *Auth {
299+ a.repoTokens = s
300+ return a
301+}
302+
289 // Revalidate checks that the holder of a principal obtained earlier is still303 // Revalidate checks that the holder of a principal obtained earlier is still
290 // authorized: root always is; a stored token must still exist and not have304 // authorized: root always is; a stored token must still exist and not have
291 // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the305 // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the
@@ -298,6 +312,9 @@ func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error {
298 if p.IsAnonymous() {312 if p.IsAnonymous() {
299 return domain.ErrUnauthenticated313 return domain.ErrUnauthenticated
300 }314 }
315+ if id := p.TokenID(); domain.IsRepoTokenID(id) {
316+ return a.revalidateRepoToken(ctx, strings.TrimPrefix(id, "job:"))
317+ }
301 tok, err := a.tokens.Get(ctx, p.TokenID())318 tok, err := a.tokens.Get(ctx, p.TokenID())
302 if errors.Is(err, domain.ErrNotFound) {319 if errors.Is(err, domain.ErrNotFound) {
303 return domain.ErrUnauthenticated320 return domain.ErrUnauthenticated
@@ -325,6 +342,9 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
325 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {342 if subtle.ConstantTimeCompare(h[:], a.root[:]) == 1 {
326 return domain.Root(), nil343 return domain.Root(), nil
327 }344 }
345+ if domain.IsRepoToken(secret) {
346+ return a.authenticateRepoToken(ctx, h)
347+ }
328 tok, err := a.tokens.FindByHash(ctx, h)348 tok, err := a.tokens.FindByHash(ctx, h)
329 if errors.Is(err, domain.ErrNotFound) {349 if errors.Is(err, domain.ErrNotFound) {
330 return domain.Principal{}, domain.ErrUnauthenticated350 return domain.Principal{}, domain.ErrUnauthenticated
@@ -344,3 +364,41 @@ func (a *Auth) Authenticate(ctx context.Context, secret string) (domain.Principa
344 }364 }
345 return domain.TokenPrincipal(tok), nil365 return domain.TokenPrincipal(tok), nil
346 }366 }
367+
368+// authenticateRepoToken resolves a job repository token: it exists (it is
369+// deleted when its job ends, is cancelled or lost) and has not expired.
370+func (a *Auth) authenticateRepoToken(ctx context.Context, h domain.TokenHash) (domain.Principal, error) {
371+ if a.repoTokens == nil {
372+ return domain.Principal{}, domain.ErrUnauthenticated
373+ }
374+ t, err := a.repoTokens.FindRepoTokenByHash(ctx, h)
375+ if errors.Is(err, domain.ErrNotFound) {
376+ return domain.Principal{}, domain.ErrUnauthenticated
377+ }
378+ if err != nil {
379+ return domain.Principal{}, fmt.Errorf("find repository token: %w", err)
380+ }
381+ if t.Expired(a.clock.Now().UTC()) {
382+ return domain.Principal{}, domain.ErrUnauthenticated
383+ }
384+ return domain.RepoTokenPrincipal(t), nil
385+}
386+
387+// revalidateRepoToken is Revalidate for the principal of a job repository
388+// token.
389+func (a *Auth) revalidateRepoToken(ctx context.Context, id string) error {
390+ if a.repoTokens == nil {
391+ return domain.ErrUnauthenticated
392+ }
393+ t, err := a.repoTokens.GetRepoToken(ctx, id)
394+ if errors.Is(err, domain.ErrNotFound) {
395+ return domain.ErrUnauthenticated
396+ }
397+ if err != nil {
398+ return fmt.Errorf("find repository token: %w", err)
399+ }
400+ if t.Expired(a.clock.Now().UTC()) {
401+ return domain.ErrUnauthenticated
402+ }
403+ return nil
404+}
M internal/usecase/tokens_test.go +72 -0
@@ -668,3 +668,75 @@ func TestTokensCreateBranchPrefixes(t *testing.T) {
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+}
@@ -668,3 +668,75 @@ func TestTokensCreateBranchPrefixes(t *testing.T) {
668 assert.Equal(t, "mine", got[0].ID)668 assert.Equal(t, "mine", got[0].ID)
669 })669 })
670 }670 }
671+
672+// The job repository tokens of CI: Authenticate resolves them to a principal
673+// shaped like a stored token, and only while they exist and have not expired.
674+func TestAuthRepoTokens(t *testing.T) {
675+ t.Parallel()
676+ ctx := context.Background()
677+ secret := domain.RepoTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen)
678+ h := domain.HashTokenSecret(secret)
679+ repo := name(t, "proj")
680+ tok := domain.CIRepoToken{ID: "id1", Hash: h, Repo: repo, ExpiresAt: now.Add(time.Minute)}
681+
682+ t.Run("a live token reads its repository", func(t *testing.T) {
683+ t.Parallel()
684+ f := newTokenFixture(t)
685+ rt := mocks.NewCIRepoTokenStore(t)
686+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(tok, nil)
687+ f.auth.WithRepoTokens(rt)
688+ p, err := f.auth.Authenticate(ctx, secret)
689+ require.NoError(t, err)
690+ assert.True(t, p.Allows(domain.ScopeRepoRead, repo))
691+ assert.False(t, p.Allows(domain.ScopeRepoWrite, repo))
692+ assert.False(t, p.Allows(domain.ScopeTokenAdmin, repo))
693+ })
694+ t.Run("the stored tokens are never asked", func(t *testing.T) {
695+ t.Parallel()
696+ f := newTokenFixture(t) // its TokenStore mock fails on any call
697+ rt := mocks.NewCIRepoTokenStore(t)
698+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, domain.ErrNotFound)
699+ f.auth.WithRepoTokens(rt)
700+ _, err := f.auth.Authenticate(ctx, secret)
701+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked or unknown token")
702+ })
703+ t.Run("expired", func(t *testing.T) {
704+ t.Parallel()
705+ f := newTokenFixture(t)
706+ rt := mocks.NewCIRepoTokenStore(t)
707+ old := tok
708+ old.ExpiresAt = now
709+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(old, nil)
710+ f.auth.WithRepoTokens(rt)
711+ _, err := f.auth.Authenticate(ctx, secret)
712+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
713+ })
714+ t.Run("a store failure is not unauthenticated", func(t *testing.T) {
715+ t.Parallel()
716+ f := newTokenFixture(t)
717+ rt := mocks.NewCIRepoTokenStore(t)
718+ boom := errors.New("disk")
719+ rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, boom)
720+ f.auth.WithRepoTokens(rt)
721+ _, err := f.auth.Authenticate(ctx, secret)
722+ assert.ErrorIs(t, err, boom)
723+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
724+ })
725+ t.Run("without CI wired the shape is refused", func(t *testing.T) {
726+ t.Parallel()
727+ f := newTokenFixture(t)
728+ _, err := f.auth.Authenticate(ctx, secret)
729+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
730+ })
731+ t.Run("revalidate follows the token, not the stored tokens", func(t *testing.T) {
732+ t.Parallel()
733+ f := newTokenFixture(t)
734+ rt := mocks.NewCIRepoTokenStore(t)
735+ rt.EXPECT().GetRepoToken(ctx, "id1").Return(tok, nil).Once()
736+ rt.EXPECT().GetRepoToken(ctx, "id1").Return(domain.CIRepoToken{}, domain.ErrNotFound).Once()
737+ f.auth.WithRepoTokens(rt)
738+ p := domain.RepoTokenPrincipal(tok)
739+ assert.NoError(t, f.auth.Revalidate(ctx, p))
740+ assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated, "revoked since")
741+ })
742+}
M proto/ermite/storage/ci.proto +32 -0
@@ -220,3 +220,35 @@ message CIRunner {
220220 google.protobuf.Timestamp registered_at = 8;
221221 bytes token_hash = 9;
222222 }
223+
224+// CIRepoToken is a job repository token (the "github.token" of a job, or the
225+// token of one action archive), stored under "ghtoken/<id>". The secret is
226+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and
227+// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists
228+// the tokens of a job so that they are revoked together.
229+message CIRepoToken {
230+ string id = 1;
231+ bytes hash = 2;
232+ // The one repository the token reads.
233+ string repo = 3;
234+ // The job the token belongs to: it is revoked when the job ends.
235+ int64 run_id = 4;
236+ int32 run_attempt = 5;
237+ string job_id = 6;
238+ // Set for the token of an action archive, unset for the job's own token.
239+ bool archive = 7;
240+ google.protobuf.Timestamp expires_at = 8;
241+ google.protobuf.Timestamp created_at = 9;
242+}
243+
244+// CIActionResolution is the resolution of an action reference for a run
245+// attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
246+// so that every job of the attempt gets the same commit.
247+message CIActionResolution {
248+ // "owner/repo@ref", lower-cased owner and repository.
249+ string key = 1;
250+ string name_with_owner = 2;
251+ string resolved_sha = 3;
252+ // 1 = github.com, 2 = this server (the numbers of ActionSource).
253+ int32 source = 4;
254+}
@@ -220,3 +220,35 @@ message CIRunner {
220 google.protobuf.Timestamp registered_at = 8;220 google.protobuf.Timestamp registered_at = 8;
221 bytes token_hash = 9;221 bytes token_hash = 9;
222 }222 }
223+
224+// CIRepoToken is a job repository token (the "github.token" of a job, or the
225+// token of one action archive), stored under "ghtoken/<id>". The secret is
226+// never stored: "ghtokenhash/<sha256 hex>" maps its hash to the id, and
227+// "ghtokenrun/<run:020d>/<run attempt:010d>/<job id>/<id>" (value: empty) lists
228+// the tokens of a job so that they are revoked together.
229+message CIRepoToken {
230+ string id = 1;
231+ bytes hash = 2;
232+ // The one repository the token reads.
233+ string repo = 3;
234+ // The job the token belongs to: it is revoked when the job ends.
235+ int64 run_id = 4;
236+ int32 run_attempt = 5;
237+ string job_id = 6;
238+ // Set for the token of an action archive, unset for the job's own token.
239+ bool archive = 7;
240+ google.protobuf.Timestamp expires_at = 8;
241+ google.protobuf.Timestamp created_at = 9;
242+}
243+
244+// CIActionResolution is the resolution of an action reference for a run
245+// attempt, stored under "actres/<run:020d>/<run attempt:010d>/<sha256 of key>"
246+// so that every job of the attempt gets the same commit.
247+message CIActionResolution {
248+ // "owner/repo@ref", lower-cased owner and repository.
249+ string key = 1;
250+ string name_with_owner = 2;
251+ string resolved_sha = 3;
252+ // 1 = github.com, 2 = this server (the numbers of ActionSource).
253+ int32 source = 4;
254+}

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 42

↑↓ moveenter openesc close