Skip to content

Commit 09ac330

Browse files
committed
Fix race condition when re-triggering GitHub Actions
When a GitHub Action is re-triggered, GitHub temporarily removes the old check status before the new run starts. This causes a race where Tide may merge the PR during the brief window when the check is missing. This fix tracks previously seen contexts per PR/commit. When a required context disappears, it's treated as PENDING to prevent premature merging. Signed-off-by: Sascha Grunert <[email protected]>
1 parent dc898f4 commit 09ac330

File tree

3 files changed

+169
-11
lines changed

3 files changed

+169
-11
lines changed

pkg/tide/status.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,40 @@ type storedState struct {
6262
PreviousQuery string
6363
}
6464

65+
// contextHistory tracks previously seen contexts for a PR/commit combination
66+
// to detect when required contexts disappear (e.g., when GitHub Actions are re-triggered)
67+
type contextHistory struct {
68+
prCommitContexts map[string]sets.Set[string]
69+
sync.Mutex
70+
}
71+
72+
func newContextHistory() *contextHistory {
73+
return &contextHistory{
74+
prCommitContexts: make(map[string]sets.Set[string]),
75+
}
76+
}
77+
78+
// checkAndUpdate returns contexts that disappeared since last check, then updates history.
79+
// This detects the race condition when GitHub Actions are re-triggered and the check
80+
// status temporarily disappears before the new run starts.
81+
func (ch *contextHistory) checkAndUpdate(pr *CodeReviewCommon, currentContexts []string) []string {
82+
key := fmt.Sprintf("%s/%s#%d:%s", pr.Org, pr.Repo, pr.Number, pr.HeadRefOID)
83+
84+
ch.Lock()
85+
defer ch.Unlock()
86+
87+
var disappeared []string
88+
if previous, exists := ch.prCommitContexts[key]; exists {
89+
currentSet := sets.New[string](currentContexts...)
90+
for ctx := range previous.Difference(currentSet) {
91+
disappeared = append(disappeared, ctx)
92+
}
93+
}
94+
95+
ch.prCommitContexts[key] = sets.New[string](currentContexts...)
96+
return disappeared
97+
}
98+
6599
// statusController is a goroutine runs in the background
66100
type statusController struct {
67101
pjClient ctrlruntimeclient.Client
@@ -106,6 +140,9 @@ type statusUpdate struct {
106140
// newPoolPending is a size 1 chan that signals that the main Tide loop has
107141
// updated the 'poolPRs' field with a freshly updated pool.
108142
newPoolPending chan bool
143+
// contextHistory tracks previously seen contexts to detect disappearing contexts
144+
// when GitHub Actions are re-triggered (addresses issue #337)
145+
contextHistory *contextHistory
109146
}
110147

111148
func (sc *statusController) shutdown() {
@@ -238,7 +275,8 @@ func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (s
238275
log := logrus.WithFields(pr.logFields())
239276
for _, commit := range pr.Commits.Nodes {
240277
if commit.Commit.OID == pr.HeadRefOID {
241-
for _, ctx := range unsuccessfulContexts(append(commit.Commit.Status.Contexts, checkRunNodesToContexts(log, commit.Commit.StatusCheckRollup.Contexts.Nodes)...), cc, log) {
278+
crc := CodeReviewCommonFromPullRequest(pr)
279+
for _, ctx := range unsuccessfulContexts(append(commit.Commit.Status.Contexts, checkRunNodesToContexts(log, commit.Commit.StatusCheckRollup.Contexts.Nodes)...), cc, crc, nil, log) {
242280
contexts = append(contexts, string(ctx.Context))
243281
}
244282
}

pkg/tide/tide.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ func NewController(
406406
statusUpdate := &statusUpdate{
407407
dontUpdateStatus: &threadSafePRSet{},
408408
newPoolPending: make(chan bool),
409+
contextHistory: newContextHistory(),
409410
}
410411

411412
sc, err := newStatusController(ctx, logger, ghcStatus, mgr, gc, cfg, opener, statusURI, mergeChecker, usesGitHubAppsAuth, statusUpdate)
@@ -675,7 +676,11 @@ func (c *syncController) filterSubpools(mergeAllowed func(*CodeReviewCommon) (st
675676
return
676677
}
677678
key := poolKey(sp.org, sp.repo, sp.branch)
678-
if spFiltered := filterSubpool(c.provider, mergeAllowed, sp); spFiltered != nil {
679+
var ch *contextHistory
680+
if c.statusUpdate != nil {
681+
ch = c.statusUpdate.contextHistory
682+
}
683+
if spFiltered := filterSubpool(c.provider, mergeAllowed, sp, ch); spFiltered != nil {
679684
sp.log.WithField("key", key).WithField("pool", spFiltered).Debug("filtered sub-pool")
680685

681686
lock.Lock()
@@ -727,10 +732,10 @@ func (c *syncController) initSubpoolData(sp *subpool) error {
727732
// should be deleted.
728733
//
729734
// This function works for any source code provider.
730-
func filterSubpool(provider provider, mergeAllowed func(*CodeReviewCommon) (string, error), sp *subpool) *subpool {
735+
func filterSubpool(provider provider, mergeAllowed func(*CodeReviewCommon) (string, error), sp *subpool, ch *contextHistory) *subpool {
731736
var toKeep []CodeReviewCommon
732737
for _, pr := range sp.prs {
733-
if !filterPR(provider, mergeAllowed, sp, &pr) {
738+
if !filterPR(provider, mergeAllowed, sp, &pr, ch) {
734739
toKeep = append(toKeep, pr)
735740
}
736741
}
@@ -752,7 +757,7 @@ func filterSubpool(provider provider, mergeAllowed func(*CodeReviewCommon) (stri
752757
// retesting them.)
753758
//
754759
// This function works for any source code provider.
755-
func filterPR(provider provider, mergeAllowed func(*CodeReviewCommon) (string, error), sp *subpool, pr *CodeReviewCommon) bool {
760+
func filterPR(provider provider, mergeAllowed func(*CodeReviewCommon) (string, error), sp *subpool, pr *CodeReviewCommon, ch *contextHistory) bool {
756761
log := sp.log.WithFields(pr.logFields())
757762
// Skip PRs that are known to be unmergeable.
758763
if reason, err := mergeAllowed(pr); err != nil {
@@ -778,7 +783,7 @@ func filterPR(provider provider, mergeAllowed func(*CodeReviewCommon) (string, e
778783
}
779784
return false
780785
}
781-
for _, ctx := range unsuccessfulContexts(contexts, sp.cc[pr.Number], log) {
786+
for _, ctx := range unsuccessfulContexts(contexts, sp.cc[pr.Number], pr, ch, log) {
782787
if ctx.State != githubql.StatusStatePending {
783788
log.WithField("context", ctx.Context).Debug("filtering out PR as unsuccessful context is not pending")
784789
return true
@@ -853,7 +858,11 @@ func (c *syncController) isPassingTests(log *logrus.Entry, pr *CodeReviewCommon,
853858
// If we can't get the status of the commit, assume that it is failing.
854859
return false
855860
}
856-
unsuccessful := unsuccessfulContexts(contexts, cc, log)
861+
var ch *contextHistory
862+
if c.statusUpdate != nil {
863+
ch = c.statusUpdate.contextHistory
864+
}
865+
unsuccessful := unsuccessfulContexts(contexts, cc, pr, ch, log)
857866
return len(unsuccessful) == 0
858867
}
859868

@@ -862,8 +871,12 @@ func (c *syncController) isPassingTests(log *logrus.Entry, pr *CodeReviewCommon,
862871
// If the branchProtection is set to only check for required checks, we will skip
863872
// all non-required tests. If required tests are missing from the list, they will be
864873
// added to the list of failed contexts.
865-
func unsuccessfulContexts(contexts []Context, cc contextChecker, log *logrus.Entry) []Context {
874+
// It also detects when required contexts disappear (e.g., when GitHub Actions are re-triggered)
875+
// and treats them as PENDING to prevent premature merging (addresses issue #337).
876+
func unsuccessfulContexts(contexts []Context, cc contextChecker, pr *CodeReviewCommon, ch *contextHistory, log *logrus.Entry) []Context {
866877
var failed []Context
878+
contextNames := contextsToStrings(contexts)
879+
867880
for _, ctx := range contexts {
868881
if string(ctx.Context) == statusContext {
869882
continue
@@ -875,13 +888,31 @@ func unsuccessfulContexts(contexts []Context, cc contextChecker, log *logrus.Ent
875888
failed = append(failed, ctx)
876889
}
877890
}
878-
for _, c := range cc.MissingRequiredContexts(contextsToStrings(contexts)) {
891+
892+
// Add missing required contexts
893+
for _, c := range cc.MissingRequiredContexts(contextNames) {
879894
failed = append(failed, newExpectedContext(c))
880895
}
881896

897+
// Check for disappeared contexts (race condition fix for issue #337)
898+
// When a GitHub Action is re-triggered, the check may temporarily disappear
899+
// from the status before the new run starts. Detect this and treat disappeared
900+
// required contexts as non-passing to prevent premature merging.
901+
if ch != nil && pr != nil {
902+
for _, c := range ch.checkAndUpdate(pr, contextNames) {
903+
if !cc.IsOptional(c) {
904+
failed = append(failed, Context{
905+
Context: githubql.String(c),
906+
State: githubql.StatusStatePending,
907+
Description: githubql.String("Context disappeared - likely re-triggered"),
908+
})
909+
}
910+
}
911+
}
912+
882913
log.WithFields(logrus.Fields{
883914
"total_context_count": len(contexts),
884-
"context_names": contextsToStrings(contexts),
915+
"context_names": contextNames,
885916
"failed_context_count": len(failed),
886917
"failed_context_names": contextsToStrings(failed),
887918
}).Debug("Filtered out failed contexts")

pkg/tide/tide_test.go

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2908,7 +2908,7 @@ func TestFilterSubpool(t *testing.T) {
29082908
mergeChecker: mmc,
29092909
logger: logrus.WithContext(context.Background()),
29102910
}
2911-
filtered := filterSubpool(provider, mmc.isAllowedToMerge, sp)
2911+
filtered := filterSubpool(provider, mmc.isAllowedToMerge, sp, newContextHistory())
29122912
if len(tc.expectedPRs) == 0 {
29132913
if filtered != nil {
29142914
t.Fatalf("Expected subpool to be pruned, but got: %v", filtered)
@@ -5482,3 +5482,92 @@ func TestSerialRetestingConsidersPRThatIsCurrentlyBeingSRetested(t *testing.T) {
54825482
}
54835483

54845484
}
5485+
5486+
// TestUnsuccessfulContextsWithDisappearedContexts tests the fix for issue #337
5487+
// where re-triggering GitHub Actions causes contexts to temporarily disappear
5488+
func TestUnsuccessfulContextsWithDisappearedContexts(t *testing.T) {
5489+
log := logrus.NewEntry(logrus.StandardLogger())
5490+
5491+
testCases := []struct {
5492+
name string
5493+
contexts []Context
5494+
previousContexts []string
5495+
requiredContexts []string
5496+
expectedFailed int
5497+
}{
5498+
{
5499+
name: "no disappeared contexts",
5500+
contexts: []Context{
5501+
{Context: githubql.String("test-1"), State: githubql.StatusStateSuccess},
5502+
{Context: githubql.String("test-2"), State: githubql.StatusStateSuccess},
5503+
},
5504+
previousContexts: []string{"test-1", "test-2"},
5505+
requiredContexts: []string{"test-1", "test-2"},
5506+
expectedFailed: 0,
5507+
},
5508+
{
5509+
name: "context disappeared - race condition",
5510+
contexts: []Context{
5511+
{Context: githubql.String("test-1"), State: githubql.StatusStateSuccess},
5512+
},
5513+
previousContexts: []string{"test-1", "test-2"},
5514+
requiredContexts: []string{"test-1", "test-2"},
5515+
expectedFailed: 2, // one missing required + one disappeared
5516+
},
5517+
{
5518+
name: "optional context disappeared - should not fail",
5519+
contexts: []Context{
5520+
{Context: githubql.String("test-1"), State: githubql.StatusStateSuccess},
5521+
},
5522+
previousContexts: []string{"test-1", "test-2"},
5523+
requiredContexts: []string{"test-1"},
5524+
expectedFailed: 1, // test-2 is unknown, treated as required by default
5525+
},
5526+
{
5527+
name: "first time seeing PR - no history",
5528+
contexts: []Context{
5529+
{Context: githubql.String("test-1"), State: githubql.StatusStateSuccess},
5530+
},
5531+
previousContexts: nil,
5532+
requiredContexts: []string{"test-1", "test-2"},
5533+
expectedFailed: 1, // only missing required, no disappeared
5534+
},
5535+
{
5536+
name: "context reappeared after disappearing",
5537+
contexts: []Context{
5538+
{Context: githubql.String("test-1"), State: githubql.StatusStateSuccess},
5539+
{Context: githubql.String("test-2"), State: githubql.StatusStatePending},
5540+
},
5541+
previousContexts: []string{"test-1"},
5542+
requiredContexts: []string{"test-1", "test-2"},
5543+
expectedFailed: 1, // test-2 is pending (not disappeared, it's present)
5544+
},
5545+
}
5546+
5547+
for _, tc := range testCases {
5548+
t.Run(tc.name, func(t *testing.T) {
5549+
ch := newContextHistory()
5550+
pr := &CodeReviewCommon{
5551+
Org: "test-org",
5552+
Repo: "test-repo",
5553+
Number: 123,
5554+
HeadRefOID: "abc123",
5555+
}
5556+
5557+
// Set up previous context history if provided
5558+
if tc.previousContexts != nil {
5559+
ch.checkAndUpdate(pr, tc.previousContexts)
5560+
}
5561+
5562+
cc := &config.TideContextPolicy{
5563+
RequiredContexts: tc.requiredContexts,
5564+
}
5565+
5566+
failed := unsuccessfulContexts(tc.contexts, cc, pr, ch, log)
5567+
5568+
if len(failed) != tc.expectedFailed {
5569+
t.Errorf("expected %d failed contexts, got %d: %+v", tc.expectedFailed, len(failed), failed)
5570+
}
5571+
})
5572+
}
5573+
}

0 commit comments

Comments
 (0)