Skip to content

Commit 3009c40

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 7ebfbc2 commit 3009c40

File tree

3 files changed

+183
-0
lines changed

3 files changed

+183
-0
lines changed

.gitlab-ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
stages:
2+
- bots
23
- lint
34
- checks
45
- build
@@ -11,6 +12,18 @@ variables:
1112
GO_VERSION: "1.24"
1213
DOCKER_VERSION: "26.0"
1314

15+
pipelines-check:
16+
stage: bots
17+
needs: []
18+
image:
19+
name: $CI_REGISTRY_GO/docker:$DOCKER_VERSION-go$GO_VERSION
20+
entrypoint: [""]
21+
rules:
22+
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
23+
tags:
24+
- go
25+
script:
26+
- go run cmd/gitlab-mr-pipelines/main.go
1427
diff:
1528
stage: lint
1629
image:

cmd/gitlab-mr-pipelines/ascii.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
____ _ _ _ _
2+
/ ___(_) |_| | __ _| |__
3+
| | _| | __| |/ _` | '_ \
4+
| |_| | | |_| | (_| | |_) |
5+
\____|_|\__|_|\__,_|_.__/
6+
____ _ _ _ _ _
7+
| _ \(_)_ __ ___| (_)_ __ ___ ___| |__ ___ ___| | __
8+
| |_) | | '_ \ / _ \ | | '_ \ / _ \ / __| '_ \ / _ \/ __| |/ /
9+
| __/| | |_) | __/ | | | | | __/ | (__| | | | __/ (__| <
10+
|_| |_| .__/ \___|_|_|_| |_|\___| \___|_| |_|\___|\___|_|\_\
11+
|_|

cmd/gitlab-mr-pipelines/main.go

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

0 commit comments

Comments
 (0)