detached de33daa

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

- the refs of a github.com repository are listed once for 5 minutes for
  every job; a re-run still asks again
- when github.com does not answer, the last list (up to 24 h old) stands in
  instead of failing the job
- an unanswered ls-remote names the action in the error
commit
de33daa3c48e3ba0dfccdfadb70fd3c0e7560cf1
author
brice · · 2026-10-17 19:38 UTC
parent
590f27c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +179 -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 +78 -0
@@ -3,11 +3,16 @@
33 package usecase
44
55 import (
6+ "context"
7+ "fmt"
68 "strings"
79 "testing"
10+ "time"
811
912 "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
1014
15+ "bnema.dev/ermite/internal/domain"
1116 "bnema.dev/ermite/internal/ports"
1217 )
1318
@@ -66,3 +71,76 @@ func TestPickRemoteRef(t *testing.T) {
6671 assert.Empty(t, pickRemoteRef(refs, "nope"))
6772 assert.Empty(t, pickRemoteRef(nil, "v1"))
6873 }
74+
75+// countingRemote answers ListRefs from a function and counts the calls.
76+type countingRemote struct {
77+ calls int
78+ answer func() ([]ports.RemoteRef, error)
79+}
80+
81+func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
82+ r.calls++
83+ return r.answer()
84+}
85+
86+// The refs of a github.com repository are listed once for the jobs of the next
87+// minutes; when github.com does not answer, the last list is used.
88+func TestRemoteRefsCache(t *testing.T) {
89+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
90+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
91+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
92+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
93+ list := func() ([]ports.RemoteRef, error) {
94+ r := c.newRemoteRefs(1)
95+ r.now = func() time.Time { return now }
96+ return r.list(context.Background(), "Actions", "checkout")
97+ }
98+
99+ got, err := list()
100+ require.NoError(t, err)
101+ assert.Equal(t, v4, got)
102+ now = now.Add(refsFresh - time.Second)
103+ _, err = list()
104+ require.NoError(t, err)
105+ assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
106+ rerun := c.newRemoteRefs(2)
107+ rerun.now = func() time.Time { return now }
108+ _, err = rerun.list(context.Background(), "actions", "checkout")
109+ require.NoError(t, err)
110+ assert.Equal(t, 2, remote.calls, "a re-run asks again")
111+
112+ now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
113+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
114+ got, err = list()
115+ require.NoError(t, err, "github.com did not answer: the last list is used")
116+ assert.Equal(t, v4, got)
117+ assert.Equal(t, 3, remote.calls, "an old list is asked again")
118+
119+ now = now.Add(refsStale)
120+ _, err = list()
121+ require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
122+
123+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
124+ c.remoteCache = newRefsCache()
125+ _, err = list()
126+ require.ErrorIs(t, err, domain.ErrNotFound)
127+ _, err = list()
128+ require.ErrorIs(t, err, domain.ErrNotFound)
129+ assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
130+}
131+
132+// An unanswered ls-remote fails the call with the action named.
133+func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
134+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
135+ return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
136+ }}
137+ c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
138+ _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
139+ require.ErrorIs(t, err, domain.ErrUnavailable)
140+ assert.Contains(t, err.Error(), "actions/checkout@v4")
141+ assert.Contains(t, err.Error(), "github.com did not answer")
142+}
143+
144+type stillClock struct{}
145+
146+func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
@@ -3,11 +3,16 @@
3 package usecase3 package usecase
4 4
5 import (5 import (
6+ "context"
7+ "fmt"
6 "strings"8 "strings"
7 "testing"9 "testing"
10+ "time"
8 11
9 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
10 14
15+ "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"16 "bnema.dev/ermite/internal/ports"
12 )17 )
13 18
@@ -66,3 +71,76 @@ func TestPickRemoteRef(t *testing.T) {
66 assert.Empty(t, pickRemoteRef(refs, "nope"))71 assert.Empty(t, pickRemoteRef(refs, "nope"))
67 assert.Empty(t, pickRemoteRef(nil, "v1"))72 assert.Empty(t, pickRemoteRef(nil, "v1"))
68 }73 }
74+
75+// countingRemote answers ListRefs from a function and counts the calls.
76+type countingRemote struct {
77+ calls int
78+ answer func() ([]ports.RemoteRef, error)
79+}
80+
81+func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
82+ r.calls++
83+ return r.answer()
84+}
85+
86+// The refs of a github.com repository are listed once for the jobs of the next
87+// minutes; when github.com does not answer, the last list is used.
88+func TestRemoteRefsCache(t *testing.T) {
89+ v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
90+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
91+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
92+ c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
93+ list := func() ([]ports.RemoteRef, error) {
94+ r := c.newRemoteRefs(1)
95+ r.now = func() time.Time { return now }
96+ return r.list(context.Background(), "Actions", "checkout")
97+ }
98+
99+ got, err := list()
100+ require.NoError(t, err)
101+ assert.Equal(t, v4, got)
102+ now = now.Add(refsFresh - time.Second)
103+ _, err = list()
104+ require.NoError(t, err)
105+ assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
106+ rerun := c.newRemoteRefs(2)
107+ rerun.now = func() time.Time { return now }
108+ _, err = rerun.list(context.Background(), "actions", "checkout")
109+ require.NoError(t, err)
110+ assert.Equal(t, 2, remote.calls, "a re-run asks again")
111+
112+ now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
113+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
114+ got, err = list()
115+ require.NoError(t, err, "github.com did not answer: the last list is used")
116+ assert.Equal(t, v4, got)
117+ assert.Equal(t, 3, remote.calls, "an old list is asked again")
118+
119+ now = now.Add(refsStale)
120+ _, err = list()
121+ require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
122+
123+ remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
124+ c.remoteCache = newRefsCache()
125+ _, err = list()
126+ require.ErrorIs(t, err, domain.ErrNotFound)
127+ _, err = list()
128+ require.ErrorIs(t, err, domain.ErrNotFound)
129+ assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
130+}
131+
132+// An unanswered ls-remote fails the call with the action named.
133+func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
134+ remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
135+ return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
136+ }}
137+ c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
138+ _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
139+ require.ErrorIs(t, err, domain.ErrUnavailable)
140+ assert.Contains(t, err.Error(), "actions/checkout@v4")
141+ assert.Contains(t, err.Error(), "github.com did not answer")
142+}
143+
144+type stillClock struct{}
145+
146+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