Skip to content

Commit d5f6f0f

Browse files
committed
BUILD/MINOR: ci: cancel duplicate pipelines on forked project
gitlab is not good in detecting duplicate pipelines, so this is ensuring that we do not run duplicate jobs for same merge request, but still allows running if you do not open merge request
1 parent d6063fa commit d5f6f0f

File tree

2 files changed

+165
-12
lines changed

2 files changed

+165
-12
lines changed

.gitlab-ci.yml

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,30 @@ variables:
1515
DOCKER_DRIVER: overlay2
1616
GO_VERSION: "1.24"
1717
DOCKER_VERSION: "28.1"
18+
pipelines-check:
19+
stage: bots
20+
needs: []
21+
image:
22+
name: $CI_REGISTRY_GO/docker:$DOCKER_VERSION-go$GO_VERSION
23+
entrypoint: [ "" ]
24+
rules:
25+
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
26+
tags:
27+
- go
28+
script:
29+
- go run cmd/gitlab-mr-pipelines/main.go
30+
mr-backport-question:
31+
stage: bots
32+
needs: []
33+
image:
34+
name: $CI_REGISTRY_GO/docker:$DOCKER_VERSION-go$GO_VERSION
35+
entrypoint: [ "" ]
36+
rules:
37+
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH
38+
tags:
39+
- go
40+
script:
41+
- go run cmd/gitlab-mr-checker/main.go
1842
diff:
1943
stage: diff
2044
rules:
@@ -47,18 +71,6 @@ diff-crd:
4771
- git diff
4872
- test -z "$(git diff 2> /dev/null)" || exit "CRD generation was not generated, issue \`make cr_generate\` and commit the result"
4973
- test -z "$(git ls-files --others --exclude-standard 2> /dev/null)" || exit "CRD generation created untracked files, cannot proceed"
50-
mr-backport-question:
51-
stage: bots
52-
needs: []
53-
image:
54-
name: $CI_REGISTRY_GO/docker:$DOCKER_VERSION-go$GO_VERSION
55-
entrypoint: [ "" ]
56-
rules:
57-
- if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH
58-
tags:
59-
- go
60-
script:
61-
- go run cmd/gitlab-mr-checker/main.go
6274
tidy:
6375
stage: lint
6476
needs: []

cmd/gitlab-mr-pipelines/main.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
//nolint:forbidigo
14+
func main() {
15+
// Check if we are in a merge request context
16+
mrIID := os.Getenv("CI_MERGE_REQUEST_IID")
17+
if mrIID == "" {
18+
fmt.Println("Not a merge request. Exiting.")
19+
os.Exit(0)
20+
}
21+
22+
// Get necessary environment variables
23+
gitlabAPIURL := os.Getenv("CI_API_V4_URL")
24+
projectID := os.Getenv("CI_PROJECT_ID")
25+
sourceProjectID := os.Getenv("CI_MERGE_REQUEST_SOURCE_PROJECT_ID")
26+
gitlabToken := os.Getenv("GITLAB_TOKEN")
27+
28+
if gitlabAPIURL == "" || projectID == "" || sourceProjectID == "" {
29+
fmt.Println("Missing required GitLab CI/CD environment variables.")
30+
os.Exit(1)
31+
}
32+
33+
if gitlabToken == "" {
34+
fmt.Print("GitLab token not found in environment variable.\n")
35+
os.Exit(1)
36+
}
37+
38+
// 1. Get all old pipelines for this Merge Request
39+
pipelinesToCancel, err := getOldMergeRequestPipelines(gitlabAPIURL, projectID, mrIID, gitlabToken)
40+
if err != nil {
41+
fmt.Printf("Error getting merge request pipelines: %v\n", err)
42+
os.Exit(1)
43+
}
44+
45+
if len(pipelinesToCancel) == 0 {
46+
fmt.Println("No old, running pipelines found for this merge request.")
47+
os.Exit(0)
48+
}
49+
50+
fmt.Printf("Found %d old pipelines to cancel.\n", len(pipelinesToCancel))
51+
52+
// 2. Cancel all found pipelines
53+
for _, p := range pipelinesToCancel {
54+
fmt.Printf("Canceling pipeline ID %d on project ID %d\n", p.ID, p.ProjectID)
55+
err = cancelPipeline(gitlabAPIURL, strconv.Itoa(p.ProjectID), p.ID, gitlabToken)
56+
if err != nil {
57+
// Log error but continue trying to cancel others
58+
fmt.Printf("Failed to cancel pipeline %d: %v\n", p.ID, err)
59+
} else {
60+
fmt.Printf("Successfully requested cancellation for pipeline %d\n", p.ID)
61+
}
62+
}
63+
}
64+
65+
type pipelineInfo struct {
66+
ID int `json:"id"`
67+
ProjectID int `json:"project_id"`
68+
Status string `json:"status"`
69+
}
70+
71+
func getOldMergeRequestPipelines(apiURL, projectID, mrIID, token string) ([]pipelineInfo, error) {
72+
// Get the current pipeline ID to avoid canceling ourselves
73+
currentPipelineIDStr := os.Getenv("CI_PIPELINE_ID")
74+
var currentPipelineID int
75+
if currentPipelineIDStr != "" {
76+
// a non-integer value will result in 0, which is fine since pipeline IDs are positive
77+
currentPipelineID, _ = strconv.Atoi(currentPipelineIDStr)
78+
}
79+
80+
url := fmt.Sprintf("%s/projects/%s/merge_requests/%s/pipelines", apiURL, projectID, mrIID)
81+
req, err := http.NewRequest("GET", url, nil) //nolint:noctx,usestdlibvars
82+
if err != nil {
83+
return nil, err
84+
}
85+
req.Header.Set("PRIVATE-TOKEN", token) //nolint:canonicalheader
86+
87+
client := &http.Client{}
88+
resp, err := client.Do(req)
89+
if err != nil {
90+
return nil, err
91+
}
92+
defer resp.Body.Close()
93+
94+
if resp.StatusCode != http.StatusOK {
95+
body, _ := io.ReadAll(resp.Body)
96+
return nil, fmt.Errorf("failed to list merge request pipelines: status %d, body: %s", resp.StatusCode, string(body))
97+
}
98+
99+
var pipelines []pipelineInfo
100+
if err := json.NewDecoder(resp.Body).Decode(&pipelines); err != nil {
101+
return nil, err
102+
}
103+
104+
var pipelinesToCancel []pipelineInfo
105+
for _, p := range pipelines {
106+
// Cancel pipelines that are running or pending, and are not the current pipeline
107+
if (p.Status == "running" || p.Status == "pending") && p.ID != currentPipelineID {
108+
pipelinesToCancel = append(pipelinesToCancel, p)
109+
}
110+
}
111+
112+
return pipelinesToCancel, nil
113+
}
114+
115+
func cancelPipeline(apiURL, projectID string, pipelineID int, token string) error {
116+
url := fmt.Sprintf("%s/projects/%s/pipelines/%d/cancel", apiURL, projectID, pipelineID)
117+
req, err := http.NewRequest("POST", url, nil) //nolint:noctx,usestdlibvars
118+
if err != nil {
119+
return err
120+
}
121+
req.Header.Set("PRIVATE-TOKEN", token) //nolint:canonicalheader
122+
123+
client := &http.Client{}
124+
resp, err := client.Do(req)
125+
if err != nil {
126+
return err
127+
}
128+
defer resp.Body.Close()
129+
130+
if resp.StatusCode != http.StatusOK {
131+
body, _ := io.ReadAll(resp.Body)
132+
// It's possible the pipeline is already finished.
133+
if strings.Contains(string(body), "Cannot cancel a pipeline that is not pending or running") {
134+
fmt.Println("Pipeline already finished, nothing to do.") //nolint:forbidigo
135+
return nil
136+
}
137+
return fmt.Errorf("failed to cancel pipeline: status %d, body: %s", resp.StatusCode, string(body))
138+
}
139+
140+
return nil
141+
}

0 commit comments

Comments
 (0)