detached e08f000

feat(ci): cache the refs of github.com actions across jobs (#50)

commit
e08f000c83f549437d0bd746781420a7aac98fa6
author
brice · · 2026-10-17 19:53 UTC
committer
bnema · · 2026-10-17 19:53 UTC
parent
590f27c
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.18.1
4 files · +223 -6
M internal/usecase/ci.go +2 -1
@@ -252,6 +252,7 @@ type CI struct {
252252 wg sync.WaitGroup
253253
254254 remoteSlots chan struct{} // slots of the git ls-remote processes
255+ remoteCache *refsCache // the refs of github.com repositories, for every job
255256 locks sync.Map // run id -> *sync.Mutex
256257 assign sync.Mutex
257258 jobs notifier // woken when a job changes: key is the job reference
@@ -304,7 +305,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
304305 run, stop := context.WithCancel(context.WithoutCancel(ctx))
305306 return &CI{
306307 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
307- ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),
308+ ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
308309 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
309310 archiveTokens: map[string]map[string]string{},
310311 perClient: map[string]int{},
@@ -252,6 +252,7 @@ type CI struct {
252 wg sync.WaitGroup252 wg sync.WaitGroup
253 253
254 remoteSlots chan struct{} // slots of the git ls-remote processes254 remoteSlots chan struct{} // slots of the git ls-remote processes
255+ remoteCache *refsCache // the refs of github.com repositories, for every job
255 locks sync.Map // run id -> *sync.Mutex256 locks sync.Map // run id -> *sync.Mutex
256 assign sync.Mutex257 assign sync.Mutex
257 jobs notifier // woken when a job changes: key is the job reference258 jobs notifier // woken when a job changes: key is the job reference
@@ -304,7 +305,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI {
304 run, stop := context.WithCancel(context.WithoutCancel(ctx))305 run, stop := context.WithCancel(context.WithoutCancel(ctx))
305 return &CI{306 return &CI{
306 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),307 cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts),
307- ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency),308+ ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(),
308 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},309 heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
309 archiveTokens: map[string]map[string]string{},310 archiveTokens: map[string]map[string]string{},
310 perClient: map[string]int{},311 perClient: map[string]int{},
M internal/usecase/ci_actions.go +98 -4
@@ -8,6 +8,10 @@ import (
88 "fmt"
99 "slices"
1010 "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
1115
1216 "bnema.dev/ermite/internal/domain"
1317 "bnema.dev/ermite/internal/ports"
@@ -74,7 +78,7 @@ func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []st
7478 // run: a ls-remote takes seconds and must not hold up the job's calls.
7579 found := map[string]domain.CIActionResolution{}
7680 fails := map[string]string{}
77- remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)
81+ remote := c.newRemoteRefs(j.RunAttempt)
7882 for _, p := range parsed {
7983 if p.err != "" {
8084 continue
@@ -404,6 +408,9 @@ func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, rem
404408 switch {
405409 case errors.Is(err, domain.ErrNotFound):
406410 return res, errUnresolvedRepo(a), nil
411+ case errors.Is(err, domain.ErrUnavailable) && ctx.Err() == nil:
412+ // The job learns which action and why, not only "unavailable".
413+ return res, "", fmt.Errorf("%w: resolving `%s`: github.com did not answer (%v)", domain.ErrUnavailable, a.uses, err)
407414 case err != nil:
408415 return res, "", err
409416 }
@@ -459,11 +466,18 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri
459466 return "", false, nil
460467 }
461468
462-// remoteRefs lists the refs of a remote once per repository within a call.
469+// remoteRefs lists the refs of a remote once per repository within a call,
470+// through the cache of the server (see refsCache).
463471 type remoteRefs struct {
464472 remote ports.GitRemote
465473 slots chan struct{}
474+ cache *refsCache
475+ now func() time.Time
466476 seen map[string]remoteList
477+ // refresh asks github.com even when the cache is fresh: a re-run resolves
478+ // again (a moved tag is often why it is re-run). The cache still stands
479+ // in when github.com does not answer.
480+ refresh bool
467481 }
468482
469483 type remoteList struct {
@@ -471,8 +485,10 @@ type remoteList struct {
471485 err error
472486 }
473487
474-func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {
475- return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}
488+// newRemoteRefs lists for the jobs of an attempt: a re-run (attempt > 1)
489+// refreshes.
490+func (c *CI) newRemoteRefs(attempt int32) *remoteRefs {
491+ return &remoteRefs{remote: c.cfg.Remote, slots: c.remoteSlots, cache: c.remoteCache, now: c.now, seen: map[string]remoteList{}, refresh: attempt > 1}
476492 }
477493
478494 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
@@ -483,6 +499,10 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
483499 if l, ok := r.seen[key]; ok {
484500 return l.refs, l.err
485501 }
502+ if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh {
503+ r.seen[key] = remoteList{refs, nil}
504+ return refs, nil
505+ }
486506 // At most a few ls-remote at once on the whole server, whatever the number
487507 // of jobs resolving: a process each, and github.com is slow to answer.
488508 select {
@@ -491,10 +511,84 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
491511 case <-ctx.Done():
492512 return nil, ctx.Err()
493513 }
514+ // Another job may have listed it while this one waited for a slot.
515+ if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh {
516+ r.seen[key] = remoteList{refs, nil}
517+ return refs, nil
518+ }
494519 refs, err := r.remote.ListRefs(ctx, owner, repo)
495520 if ctx.Err() != nil {
496521 return nil, ctx.Err()
497522 }
523+ switch {
524+ case err == nil:
525+ r.cache.put(key, refs, r.now())
526+ case errors.Is(err, domain.ErrUnavailable):
527+ // github.com did not answer: the last list it gave is better than a
528+ // failed job, for a while (refsStale).
529+ if old, ok := r.cache.stale(key, r.now()); ok {
530+ log := zerowrap.FromCtx(ctx)
531+ log.Warn().Err(err).Str("repo", key).Msg("github.com did not answer: using the refs listed earlier")
532+ refs, err = old, nil
533+ }
534+ }
498535 r.seen[key] = remoteList{refs, err}
499536 return refs, err
500537 }
538+
539+const (
540+ // refsFresh is how long a list of the refs of a repository of github.com
541+ // is used without asking again: the jobs of the next minutes resolve
542+ // actions/checkout@v4 without a ls-remote. A tag or branch moved meanwhile
543+ // is seen after at most that long.
544+ refsFresh = 5 * time.Minute
545+ // refsStale is how long a list is kept for when github.com does not answer.
546+ refsStale = 24 * time.Hour
547+ // refsMax bounds the repositories in the cache.
548+ refsMax = 256
549+)
550+
551+// refsCache keeps the refs of the repositories of github.com the actions come
552+// from, for the whole server. Safe for concurrent use.
553+type refsCache struct {
554+ mu sync.Mutex
555+ m map[string]cachedRefs
556+}
557+
558+type cachedRefs struct {
559+ refs []ports.RemoteRef
560+ at time.Time
561+}
562+
563+func newRefsCache() *refsCache { return &refsCache{m: map[string]cachedRefs{}} }
564+
565+// get returns the list of key and whether it is fresh.
566+func (c *refsCache) get(key string, now time.Time) ([]ports.RemoteRef, bool) {
567+ c.mu.Lock()
568+ defer c.mu.Unlock()
569+ e, ok := c.m[key]
570+ return e.refs, ok && now.Sub(e.at) < refsFresh
571+}
572+
573+// stale returns the list of key when it is not older than refsStale.
574+func (c *refsCache) stale(key string, now time.Time) ([]ports.RemoteRef, bool) {
575+ c.mu.Lock()
576+ defer c.mu.Unlock()
577+ e, ok := c.m[key]
578+ return e.refs, ok && now.Sub(e.at) < refsStale
579+}
580+
581+func (c *refsCache) put(key string, refs []ports.RemoteRef, now time.Time) {
582+ c.mu.Lock()
583+ defer c.mu.Unlock()
584+ if _, ok := c.m[key]; !ok && len(c.m) >= refsMax {
585+ var oldest string
586+ for k, e := range c.m {
587+ if oldest == "" || e.at.Before(c.m[oldest].at) {
588+ oldest = k
589+ }
590+ }
591+ delete(c.m, oldest)
592+ }
593+ c.m[key] = cachedRefs{refs: refs, at: now}
594+}
@@ -8,6 +8,10 @@ import (
8 "fmt"8 "fmt"
9 "slices"9 "slices"
10 "strings"10 "strings"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
11 15
12 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
@@ -74,7 +78,7 @@ func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []st
74 // run: a ls-remote takes seconds and must not hold up the job's calls.78 // run: a ls-remote takes seconds and must not hold up the job's calls.
75 found := map[string]domain.CIActionResolution{}79 found := map[string]domain.CIActionResolution{}
76 fails := map[string]string{}80 fails := map[string]string{}
77- remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots)81+ remote := c.newRemoteRefs(j.RunAttempt)
78 for _, p := range parsed {82 for _, p := range parsed {
79 if p.err != "" {83 if p.err != "" {
80 continue84 continue
@@ -404,6 +408,9 @@ func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, rem
404 switch {408 switch {
405 case errors.Is(err, domain.ErrNotFound):409 case errors.Is(err, domain.ErrNotFound):
406 return res, errUnresolvedRepo(a), nil410 return res, errUnresolvedRepo(a), nil
411+ case errors.Is(err, domain.ErrUnavailable) && ctx.Err() == nil:
412+ // The job learns which action and why, not only "unavailable".
413+ return res, "", fmt.Errorf("%w: resolving `%s`: github.com did not answer (%v)", domain.ErrUnavailable, a.uses, err)
407 case err != nil:414 case err != nil:
408 return res, "", err415 return res, "", err
409 }416 }
@@ -459,11 +466,18 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri
459 return "", false, nil466 return "", false, nil
460 }467 }
461 468
462-// remoteRefs lists the refs of a remote once per repository within a call.469+// remoteRefs lists the refs of a remote once per repository within a call,
470+// through the cache of the server (see refsCache).
463 type remoteRefs struct {471 type remoteRefs struct {
464 remote ports.GitRemote472 remote ports.GitRemote
465 slots chan struct{}473 slots chan struct{}
474+ cache *refsCache
475+ now func() time.Time
466 seen map[string]remoteList476 seen map[string]remoteList
477+ // refresh asks github.com even when the cache is fresh: a re-run resolves
478+ // again (a moved tag is often why it is re-run). The cache still stands
479+ // in when github.com does not answer.
480+ refresh bool
467 }481 }
468 482
469 type remoteList struct {483 type remoteList struct {
@@ -471,8 +485,10 @@ type remoteList struct {
471 err error485 err error
472 }486 }
473 487
474-func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs {488+// newRemoteRefs lists for the jobs of an attempt: a re-run (attempt > 1)
475- return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}}489+// refreshes.
490+func (c *CI) newRemoteRefs(attempt int32) *remoteRefs {
491+ return &remoteRefs{remote: c.cfg.Remote, slots: c.remoteSlots, cache: c.remoteCache, now: c.now, seen: map[string]remoteList{}, refresh: attempt > 1}
476 }492 }
477 493
478 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {494 func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) {
@@ -483,6 +499,10 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
483 if l, ok := r.seen[key]; ok {499 if l, ok := r.seen[key]; ok {
484 return l.refs, l.err500 return l.refs, l.err
485 }501 }
502+ if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh {
503+ r.seen[key] = remoteList{refs, nil}
504+ return refs, nil
505+ }
486 // At most a few ls-remote at once on the whole server, whatever the number506 // At most a few ls-remote at once on the whole server, whatever the number
487 // of jobs resolving: a process each, and github.com is slow to answer.507 // of jobs resolving: a process each, and github.com is slow to answer.
488 select {508 select {
@@ -491,10 +511,84 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo
491 case <-ctx.Done():511 case <-ctx.Done():
492 return nil, ctx.Err()512 return nil, ctx.Err()
493 }513 }
514+ // Another job may have listed it while this one waited for a slot.
515+ if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh {
516+ r.seen[key] = remoteList{refs, nil}
517+ return refs, nil
518+ }
494 refs, err := r.remote.ListRefs(ctx, owner, repo)519 refs, err := r.remote.ListRefs(ctx, owner, repo)
495 if ctx.Err() != nil {520 if ctx.Err() != nil {
496 return nil, ctx.Err()521 return nil, ctx.Err()
497 }522 }
523+ switch {
524+ case err == nil:
525+ r.cache.put(key, refs, r.now())
526+ case errors.Is(err, domain.ErrUnavailable):
527+ // github.com did not answer: the last list it gave is better than a
528+ // failed job, for a while (refsStale).
529+ if old, ok := r.cache.stale(key, r.now()); ok {
530+ log := zerowrap.FromCtx(ctx)
531+ log.Warn().Err(err).Str("repo", key).Msg("github.com did not answer: using the refs listed earlier")
532+ refs, err = old, nil
533+ }
534+ }
498 r.seen[key] = remoteList{refs, err}535 r.seen[key] = remoteList{refs, err}
499 return refs, err536 return refs, err
500 }537 }
538+
539+const (
540+ // refsFresh is how long a list of the refs of a repository of github.com
541+ // is used without asking again: the jobs of the next minutes resolve
542+ // actions/checkout@v4 without a ls-remote. A tag or branch moved meanwhile
543+ // is seen after at most that long.
544+ refsFresh = 5 * time.Minute
545+ // refsStale is how long a list is kept for when github.com does not answer.
546+ refsStale = 24 * time.Hour
547+ // refsMax bounds the repositories in the cache.
548+ refsMax = 256
549+)
550+
551+// refsCache keeps the refs of the repositories of github.com the actions come
552+// from, for the whole server. Safe for concurrent use.
553+type refsCache struct {
554+ mu sync.Mutex
555+ m map[string]cachedRefs
556+}
557+
558+type cachedRefs struct {
559+ refs []ports.RemoteRef
560+ at time.Time
561+}
562+
563+func newRefsCache() *refsCache { return &refsCache{m: map[string]cachedRefs{}} }
564+
565+// get returns the list of key and whether it is fresh.
566+func (c *refsCache) get(key string, now time.Time) ([]ports.RemoteRef, bool) {
567+ c.mu.Lock()
568+ defer c.mu.Unlock()
569+ e, ok := c.m[key]
570+ return e.refs, ok && now.Sub(e.at) < refsFresh
571+}
572+
573+// stale returns the list of key when it is not older than refsStale.
574+func (c *refsCache) stale(key string, now time.Time) ([]ports.RemoteRef, bool) {
575+ c.mu.Lock()
576+ defer c.mu.Unlock()
577+ e, ok := c.m[key]
578+ return e.refs, ok && now.Sub(e.at) < refsStale
579+}
580+
581+func (c *refsCache) put(key string, refs []ports.RemoteRef, now time.Time) {
582+ c.mu.Lock()
583+ defer c.mu.Unlock()
584+ if _, ok := c.m[key]; !ok && len(c.m) >= refsMax {
585+ var oldest string
586+ for k, e := range c.m {
587+ if oldest == "" || e.at.Before(c.m[oldest].at) {
588+ oldest = k
589+ }
590+ }
591+ delete(c.m, oldest)
592+ }
593+ c.m[key] = cachedRefs{refs: refs, at: now}
594+}
M internal/usecase/ci_actions_test.go +122 -0
@@ -3,11 +3,17 @@
33 package usecase
44
55 import (
6+ "context"
7+ "fmt"
68 "strings"
9+ "sync"
710 "testing"
11+ "time"
812
913 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
1015
16+ "bnema.dev/ermite/internal/domain"
1117 "bnema.dev/ermite/internal/ports"
1218 )
1319
@@ -66,3 +72,119 @@ func TestPickRemoteRef(t *testing.T) {
6672 assert.Empty(t, pickRemoteRef(refs, "nope"))
6773 assert.Empty(t, pickRemoteRef(nil, "v1"))
6874 }
75+
76+// countingRemote answers ListRefs from a function and counts the calls.
77+type countingRemote struct {
78+ calls int
79+ answer func() ([]ports.RemoteRef, error)
80+}
81+
82+func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
83+ r.calls++
84+ return r.answer()
85+}
86+
87+// The refs of a github.com repository are listed once for the jobs of the next
88+// minutes; when github.com does not answer, the last list is used.
89+func TestRemoteRefsCache(t *testing.T) {
90+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
91+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
92+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
93+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
94+ list := func() ([]ports.RemoteRef, error) {
95+ r := c.newRemoteRefs(1)
96+ r.now = func() time.Time { return now }
97+ return r.list(context.Background(), "Actions", "checkout")
98+ }
99+
100+ got, err := list()
101+ require.NoError(t, err)
102+ assert.Equal(t, v4, got)
103+ now = now.Add(refsFresh - time.Second)
104+ _, err = list()
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
107+ rerun := c.newRemoteRefs(2)
108+ rerun.now = func() time.Time { return now }
109+ _, err = rerun.list(context.Background(), "actions", "checkout")
110+ require.NoError(t, err)
111+ assert.Equal(t, 2, remote.calls, "a re-run asks again")
112+
113+ now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
114+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
115+ got, err = list()
116+ require.NoError(t, err, "github.com did not answer: the last list is used")
117+ assert.Equal(t, v4, got)
118+ assert.Equal(t, 3, remote.calls, "an old list is asked again")
119+
120+ now = now.Add(refsStale)
121+ _, err = list()
122+ require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
123+
124+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
125+ c.remoteCache = newRefsCache()
126+ _, err = list()
127+ require.ErrorIs(t, err, domain.ErrNotFound)
128+ _, err = list()
129+ require.ErrorIs(t, err, domain.ErrNotFound)
130+ assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
131+}
132+
133+// A stale list stands in without becoming younger, the oldest repository makes
134+// room for a new one, and the cache is safe for jobs resolving at once.
135+func TestRefsCacheBounds(t *testing.T) {
136+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
137+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
139+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
140+ list := func(repo string) error {
141+ r := c.newRemoteRefs(1)
142+ r.now = func() time.Time { return now }
143+ _, err := r.list(context.Background(), "actions", repo)
144+ return err
145+ }
146+ require.NoError(t, list("checkout"))
147+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable }
148+ now = now.Add(refsStale - time.Hour)
149+ require.NoError(t, list("checkout"), "stands in")
150+ now = now.Add(time.Hour)
151+ require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger")
152+
153+ cache := newRefsCache()
154+ for i := range refsMax {
155+ cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second))
156+ }
157+ cache.put("o/new", v4, now.Add(time.Hour))
158+ _, ok := cache.stale("o/r0", now)
159+ assert.False(t, ok, "the oldest is evicted")
160+ _, ok = cache.stale("o/r1", now.Add(time.Second))
161+ assert.True(t, ok)
162+ assert.Len(t, cache.m, refsMax)
163+
164+ var wg sync.WaitGroup
165+ for i := range 8 {
166+ wg.Go(func() {
167+ key := fmt.Sprintf("o/r%d", i%3)
168+ cache.put(key, v4, now)
169+ cache.get(key, now)
170+ cache.stale(key, now)
171+ })
172+ }
173+ wg.Wait()
174+}
175+
176+// An unanswered ls-remote fails the call with the action named.
177+func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
178+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
179+ return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
180+ }}
181+ c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
182+ _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
183+ require.ErrorIs(t, err, domain.ErrUnavailable)
184+ assert.Contains(t, err.Error(), "actions/checkout@v4")
185+ assert.Contains(t, err.Error(), "github.com did not answer")
186+}
187+
188+type stillClock struct{}
189+
190+func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
@@ -3,11 +3,17 @@
3 package usecase3 package usecase
4 4
5 import (5 import (
6+ "context"
7+ "fmt"
6 "strings"8 "strings"
9+ "sync"
7 "testing"10 "testing"
11+ "time"
8 12
9 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
10 15
16+ "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
12 )18 )
13 19
@@ -66,3 +72,119 @@ func TestPickRemoteRef(t *testing.T) {
66 assert.Empty(t, pickRemoteRef(refs, "nope"))72 assert.Empty(t, pickRemoteRef(refs, "nope"))
67 assert.Empty(t, pickRemoteRef(nil, "v1"))73 assert.Empty(t, pickRemoteRef(nil, "v1"))
68 }74 }
75+
76+// countingRemote answers ListRefs from a function and counts the calls.
77+type countingRemote struct {
78+ calls int
79+ answer func() ([]ports.RemoteRef, error)
80+}
81+
82+func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
83+ r.calls++
84+ return r.answer()
85+}
86+
87+// The refs of a github.com repository are listed once for the jobs of the next
88+// minutes; when github.com does not answer, the last list is used.
89+func TestRemoteRefsCache(t *testing.T) {
90+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
91+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
92+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
93+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
94+ list := func() ([]ports.RemoteRef, error) {
95+ r := c.newRemoteRefs(1)
96+ r.now = func() time.Time { return now }
97+ return r.list(context.Background(), "Actions", "checkout")
98+ }
99+
100+ got, err := list()
101+ require.NoError(t, err)
102+ assert.Equal(t, v4, got)
103+ now = now.Add(refsFresh - time.Second)
104+ _, err = list()
105+ require.NoError(t, err)
106+ assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
107+ rerun := c.newRemoteRefs(2)
108+ rerun.now = func() time.Time { return now }
109+ _, err = rerun.list(context.Background(), "actions", "checkout")
110+ require.NoError(t, err)
111+ assert.Equal(t, 2, remote.calls, "a re-run asks again")
112+
113+ now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
114+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
115+ got, err = list()
116+ require.NoError(t, err, "github.com did not answer: the last list is used")
117+ assert.Equal(t, v4, got)
118+ assert.Equal(t, 3, remote.calls, "an old list is asked again")
119+
120+ now = now.Add(refsStale)
121+ _, err = list()
122+ require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
123+
124+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
125+ c.remoteCache = newRefsCache()
126+ _, err = list()
127+ require.ErrorIs(t, err, domain.ErrNotFound)
128+ _, err = list()
129+ require.ErrorIs(t, err, domain.ErrNotFound)
130+ assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
131+}
132+
133+// A stale list stands in without becoming younger, the oldest repository makes
134+// room for a new one, and the cache is safe for jobs resolving at once.
135+func TestRefsCacheBounds(t *testing.T) {
136+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
137+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
139+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
140+ list := func(repo string) error {
141+ r := c.newRemoteRefs(1)
142+ r.now = func() time.Time { return now }
143+ _, err := r.list(context.Background(), "actions", repo)
144+ return err
145+ }
146+ require.NoError(t, list("checkout"))
147+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable }
148+ now = now.Add(refsStale - time.Hour)
149+ require.NoError(t, list("checkout"), "stands in")
150+ now = now.Add(time.Hour)
151+ require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger")
152+
153+ cache := newRefsCache()
154+ for i := range refsMax {
155+ cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second))
156+ }
157+ cache.put("o/new", v4, now.Add(time.Hour))
158+ _, ok := cache.stale("o/r0", now)
159+ assert.False(t, ok, "the oldest is evicted")
160+ _, ok = cache.stale("o/r1", now.Add(time.Second))
161+ assert.True(t, ok)
162+ assert.Len(t, cache.m, refsMax)
163+
164+ var wg sync.WaitGroup
165+ for i := range 8 {
166+ wg.Go(func() {
167+ key := fmt.Sprintf("o/r%d", i%3)
168+ cache.put(key, v4, now)
169+ cache.get(key, now)
170+ cache.stale(key, now)
171+ })
172+ }
173+ wg.Wait()
174+}
175+
176+// An unanswered ls-remote fails the call with the action named.
177+func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
178+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
179+ return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
180+ }}
181+ c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
182+ _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
183+ require.ErrorIs(t, err, domain.ErrUnavailable)
184+ assert.Contains(t, err.Error(), "actions/checkout@v4")
185+ assert.Contains(t, err.Error(), "github.com did not answer")
186+}
187+
188+type stillClock struct{}
189+
190+func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
M internal/usecase/ci_reusable.go +1 -1
@@ -158,7 +158,7 @@ func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall,
158158 res = stored
159159 case errors.Is(err, domain.ErrNotFound):
160160 var msg string
161- res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by)
161+ res, msg, err = c.resolveAction(ctx, rec, ar, c.newRemoteRefs(rec.RunAttempt), by)
162162 if err != nil {
163163 return f, err
164164 }
@@ -158,7 +158,7 @@ func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall,
158 res = stored158 res = stored
159 case errors.Is(err, domain.ErrNotFound):159 case errors.Is(err, domain.ErrNotFound):
160 var msg string160 var msg string
161- res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by)161+ res, msg, err = c.resolveAction(ctx, rec, ar, c.newRemoteRefs(rec.RunAttempt), by)
162 if err != nil {162 if err != nil {
163 return f, err163 return f, err
164 }164 }

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 41

↑↓ moveenter openesc close