detached 355f5f4

fix(mr): cancel auto-merge when the enabler can no longer merge, even while checks are pending

commit
355f5f46da5ba28cb927c9837283b55c02deb178
author
brice · · 2026-10-20 16:59 UTC
parent
cb71bdd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +16 -5
M docs/merge-requests.md +1 -1
@@ -46,7 +46,7 @@ A merge request can merge by itself once its checks passed.
4646 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
4747 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
4848 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
49-- The merge is made as the person who enabled it. Their token is checked again at merge time: it must still be valid and allow `mr:merge` on the repository and the target branch.
49+- The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
5050 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
5151 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
5252 - A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.
@@ -46,7 +46,7 @@ A merge request can merge by itself once its checks passed.
46 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).46 - The repository setting `allow_auto_merge` is off by default: `ermite repo edit <repo> --allow-auto-merge` (`=false` to turn it off).
47 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.47 - Enable it with `ermite mr merge <repo> <n> --head <commit> --auto --mode squash` (`--head` is required, as for a plain merge; a head that moved is refused with "branch moved"), or with **Merge when ready** in the web merge box. It needs `mr:merge` and a server with CI. Re-enabling replaces the previous mode and enabler.
48 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.48 - The server merges when CI has settled on the head with every run complete and at least one check passed. A failing or unfinished check always blocks, whatever `failing_checks_block` says. The mode must still be allowed.
49-- The merge is made as the person who enabled it. Their token is checked again at merge time: it must still be valid and allow `mr:merge` on the repository and the target branch.49+- The merge is made as the person who enabled it. Their token is checked at every evaluation, even while checks are pending, and again at merge time under the merge lock: it must still be valid and allow `mr:merge` on the repository and the target branch. A token that no longer qualifies cancels the auto-merge.
50 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.50 - A pending auto-merge shows in `mr show` (`auto_merge`), in the web merge box and as `merge_request_auto_merge_enabled` in `ermite watch`. A merge request merged this way has `auto_merged` true.
51 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).51 - A failing, unfinished or conflicting head waits with auto-merge still on. A re-run or a target fix can make it pass; pushing a new source head cancels the recorded intent (the reason names the new head).
52 - A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.52 - A manual merge supersedes auto-merge and publishes `merge_request_merged` with `auto=false`, without a cancelled event. A manual close clears it and publishes `merge_request_closed` followed by `merge_request_auto_merge_cancelled` with reason `closed`.
M internal/usecase/automerge.go +8 -0
@@ -228,6 +228,14 @@ func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.
228228 if head != intent.HeadID {
229229 return cancel("the branch moved to " + head.String()[:7])
230230 }
231+ // An enabler that can no longer merge cancels at once, even while CI is
232+ // pending: the intent must not stay shown as enabled. This check only
233+ // cancels; the merge itself resolves the enabler again under the merge lock.
234+ if fresh, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now()); err != nil {
235+ return err
236+ } else if !valid || !fresh.Allows(domain.ScopeMRMerge, mr.Repo) || !fresh.AllowsRef("refs/heads/"+mr.Target) {
237+ return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target)
238+ }
231239 if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) {
232240 return nil
233241 }
@@ -228,6 +228,14 @@ func (s *AutoMerge) evaluateMR(ctx context.Context, repo domain.Repo, mr domain.
228 if head != intent.HeadID {228 if head != intent.HeadID {
229 return cancel("the branch moved to " + head.String()[:7])229 return cancel("the branch moved to " + head.String()[:7])
230 }230 }
231+ // An enabler that can no longer merge cancels at once, even while CI is
232+ // pending: the intent must not stay shown as enabled. This check only
233+ // cancels; the merge itself resolves the enabler again under the merge lock.
234+ if fresh, valid, err := principalOfPusher(ctx, s.cfg.Tokens, intent.EnabledBy, s.cfg.Clock.Now()); err != nil {
235+ return err
236+ } else if !valid || !fresh.Allows(domain.ScopeMRMerge, mr.Repo) || !fresh.AllowsRef("refs/heads/"+mr.Target) {
237+ return cancel("the principal that enabled auto-merge can no longer merge " + mr.Target)
238+ }
231 if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) {239 if s.cfg.Activity != nil && !s.cfg.Activity.Settled(ctx, mr.Repo, mr.Number, mr.Source, head) {
232 return nil240 return nil
233 }241 }
M internal/usecase/automerge_test.go +7 -4
@@ -208,7 +208,7 @@ func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
208208 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
209209 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
210210 tokens := mocks.NewTokenStore(t)
211- checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)
211+ // The enabler is checked before CI: no check is read.
212212 if expired {
213213 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
214214 } else {
@@ -405,6 +405,10 @@ func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
405405 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
406406 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
407407 tokens := mocks.NewTokenStore(t)
408+ // Valid when the evaluator looks before CI, revoked by the time it holds the
409+ // merge lock: the merge must use the fresh answer.
410+ validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
411+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
408412 tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
409413 reached := make(chan struct{})
410414 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
@@ -414,9 +418,8 @@ func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
414418 require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
415419 go func() { done <- a.Evaluate(r.repo) }()
416420 <-reached
417- // Evaluation has passed its fast-path checks, but cannot resolve the token
418- // until this lock is released. It now sees the revoked token, not old rights.
419- tokens.AssertNotCalled(t, "Get", mock.Anything, "revoked")
421+ // Evaluation has passed its fast-path checks with the then-valid token
422+ // and waits for this lock. The token is revoked meanwhile (second Get).
420423 return nil
421424 }))
422425 require.True(t, <-done)
@@ -208,7 +208,7 @@ func TestAutoMergeCancelsWhenEnablerTokenRevokedOrExpired(t *testing.T) {
208 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}208 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "gone"}
209 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))209 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
210 tokens := mocks.NewTokenStore(t)210 tokens := mocks.NewTokenStore(t)
211- checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil)211+ // The enabler is checked before CI: no check is read.
212 if expired {212 if expired {
213 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)213 tokens.EXPECT().Get(mock.Anything, "gone").Return(domain.Token{ExpiresAt: now.Add(-time.Hour)}, nil)
214 } else {214 } else {
@@ -405,6 +405,10 @@ func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
405 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}405 enabled.AutoMerge.EnabledBy = domain.CIPusher{Kind: domain.CIPusherToken, TokenID: "revoked"}
406 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))406 require.NoError(t, r.store.MergeRequests().Update(r.ctx, enabled))
407 tokens := mocks.NewTokenStore(t)407 tokens := mocks.NewTokenStore(t)
408+ // Valid when the evaluator looks before CI, revoked by the time it holds the
409+ // merge lock: the merge must use the fresh answer.
410+ validTok := domain.Token{ID: "revoked", Scopes: []domain.Scope{domain.ScopeMRMerge}, Repos: []domain.RepoName{r.repo}}
411+ tokens.EXPECT().Get(mock.Anything, "revoked").Return(validTok, nil).Once()
408 tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()412 tokens.EXPECT().Get(mock.Anything, "revoked").Return(domain.Token{}, domain.ErrNotFound).Once()
409 reached := make(chan struct{})413 reached := make(chan struct{})
410 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()414 checks.EXPECT().CommitChecks(mock.Anything, r.repo, r.head().String()).Run(func(context.Context, domain.RepoName, string) { close(reached) }).Return([]domain.CICheck{{Status: domain.CICompleted, Conclusion: domain.CISuccess}}, nil).Once()
@@ -414,9 +418,8 @@ func TestAutoMergeResolvesEnablerAfterMergeGate(t *testing.T) {
414 require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {418 require.NoError(t, r.mrs.WithMergeLock(r.ctx, r.repo, func() error {
415 go func() { done <- a.Evaluate(r.repo) }()419 go func() { done <- a.Evaluate(r.repo) }()
416 <-reached420 <-reached
417- // Evaluation has passed its fast-path checks, but cannot resolve the token421+ // Evaluation has passed its fast-path checks with the then-valid token
418- // until this lock is released. It now sees the revoked token, not old rights.422+ // and waits for this lock. The token is revoked meanwhile (second Get).
419- tokens.AssertNotCalled(t, "Get", mock.Anything, "revoked")
420 return nil423 return nil
421 }))424 }))
422 require.True(t, <-done)425 require.True(t, <-done)

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