diff --git a/cli/secret/secret_add.go b/cli/secret/secret_add.go
index 7cd935396..16b75e0e0 100644
--- a/cli/secret/secret_add.go
+++ b/cli/secret/secret_add.go
@@ -102,5 +102,6 @@ func secretCreate(c *cli.Context) error {
var defaultSecretEvents = []string{
woodpecker.EventPush,
woodpecker.EventTag,
+ woodpecker.EventRelease,
woodpecker.EventDeploy,
}
diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go
index 3380e29fc..9536f0f34 100644
--- a/cmd/server/docs/docs.go
+++ b/cmd/server/docs/docs.go
@@ -3937,6 +3937,9 @@ const docTemplate = `{
"id": {
"type": "integer"
},
+ "is_prerelease": {
+ "type": "boolean"
+ },
"message": {
"type": "string"
},
@@ -4383,6 +4386,7 @@ const docTemplate = `{
"pull_request",
"pull_request_closed",
"tag",
+ "release",
"deployment",
"cron",
"manual"
@@ -4392,6 +4396,7 @@ const docTemplate = `{
"EventPull",
"EventPullClosed",
"EventTag",
+ "EventRelease",
"EventDeploy",
"EventCron",
"EventManual"
diff --git a/docs/docs/20-usage/15-terminiology/index.md b/docs/docs/20-usage/15-terminiology/index.md
index 9f24e4617..4e58b9630 100644
--- a/docs/docs/20-usage/15-terminiology/index.md
+++ b/docs/docs/20-usage/15-terminiology/index.md
@@ -38,6 +38,7 @@
- `pull_request`: A pull request event is triggered when a pull request is opened or a new commit is pushed to it.
- `pull_request_closed`: A pull request closed event is triggered when a pull request is closed or merged.
- `tag`: A tag event is triggered when a tag is pushed.
+- `release`: A release event is triggered when a release is created.
- `manual`: A manual event is triggered when a user manually triggers a pipeline.
- `cron`: A cron event is triggered when a cron job is executed.
diff --git a/docs/docs/20-usage/20-workflow-syntax.md b/docs/docs/20-usage/20-workflow-syntax.md
index bb18e953e..a21360a81 100644
--- a/docs/docs/20-usage/20-workflow-syntax.md
+++ b/docs/docs/20-usage/20-workflow-syntax.md
@@ -269,7 +269,7 @@ when:
#### `event`
-Available events: `push`, `pull_request`, `pull_request_closed`, `tag`, `deployment`, `cron`, `manual`
+Available events: `push`, `pull_request`, `pull_request_closed`, `tag`, `release`, `deployment`, `cron`, `manual`
Execute a step if the build event is a `tag`:
diff --git a/docs/docs/20-usage/50-environment.md b/docs/docs/20-usage/50-environment.md
index 02cee4337..5e45ba0d4 100644
--- a/docs/docs/20-usage/50-environment.md
+++ b/docs/docs/20-usage/50-environment.md
@@ -77,6 +77,7 @@ This is the reference list of all environment variables available to your pipeli
| `CI_COMMIT_AUTHOR` | commit author username |
| `CI_COMMIT_AUTHOR_EMAIL` | commit author email address |
| `CI_COMMIT_AUTHOR_AVATAR` | commit author avatar |
+| `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) |
| | **Current pipeline** |
| `CI_PIPELINE_NUMBER` | pipeline number |
| `CI_PIPELINE_PARENT` | number of parent pipeline |
diff --git a/docs/docs/30-administration/11-forges/10-overview.md b/docs/docs/30-administration/11-forges/10-overview.md
index 897d3d897..bacce1635 100644
--- a/docs/docs/30-administration/11-forges/10-overview.md
+++ b/docs/docs/30-administration/11-forges/10-overview.md
@@ -7,6 +7,7 @@
| Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
+| Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
| Event: Deploy | :white_check_mark: | :x: | :x: | :x: |
| [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |
diff --git a/pipeline/frontend/metadata/const.go b/pipeline/frontend/metadata/const.go
index d7afbea07..a8777ffec 100644
--- a/pipeline/frontend/metadata/const.go
+++ b/pipeline/frontend/metadata/const.go
@@ -20,6 +20,7 @@ const (
EventPull = "pull_request"
EventPullClosed = "pull_request_closed"
EventTag = "tag"
+ EventRelease = "release"
EventDeploy = "deployment"
EventCron = "cron"
EventManual = "manual"
diff --git a/pipeline/frontend/metadata/environment.go b/pipeline/frontend/metadata/environment.go
index b2629a9f7..dbb8ea56b 100644
--- a/pipeline/frontend/metadata/environment.go
+++ b/pipeline/frontend/metadata/environment.go
@@ -125,9 +125,12 @@ func (m *Metadata) Environ() map[string]string {
// TODO Deprecated, remove in 3.x
"CI_COMMIT_URL": m.Curr.ForgeURL,
}
- if m.Curr.Event == EventTag || strings.HasPrefix(m.Curr.Commit.Ref, "refs/tags/") {
+ if m.Curr.Event == EventTag || m.Curr.Event == EventRelease || strings.HasPrefix(m.Curr.Commit.Ref, "refs/tags/") {
params["CI_COMMIT_TAG"] = strings.TrimPrefix(m.Curr.Commit.Ref, "refs/tags/")
}
+ if m.Curr.Event == EventRelease {
+ params["CI_COMMIT_PRERELEASE"] = strconv.FormatBool(m.Curr.Commit.IsPrerelease)
+ }
if m.Curr.Event == EventPull {
params["CI_COMMIT_PULL_REQUEST"] = pullRegexp.FindString(m.Curr.Commit.Ref)
params["CI_COMMIT_PULL_REQUEST_LABELS"] = strings.Join(m.Curr.Commit.PullRequestLabels, ",")
diff --git a/pipeline/frontend/metadata/types.go b/pipeline/frontend/metadata/types.go
index 6f28255c0..c75f321e6 100644
--- a/pipeline/frontend/metadata/types.go
+++ b/pipeline/frontend/metadata/types.go
@@ -69,6 +69,7 @@ type (
Author Author `json:"author,omitempty"`
ChangedFiles []string `json:"changed_files,omitempty"`
PullRequestLabels []string `json:"labels,omitempty"`
+ IsPrerelease bool `json:"is_prerelease,omitempty"`
}
// Author defines runtime metadata for a commit author.
diff --git a/server/api/pipeline.go b/server/api/pipeline.go
index 00a9f843a..b5ed9a11b 100644
--- a/server/api/pipeline.go
+++ b/server/api/pipeline.go
@@ -414,7 +414,7 @@ func PostPipeline(c *gin.Context) {
if event, ok := c.GetQuery("event"); ok {
pl.Event = model.WebhookEvent(event)
- if err := model.ValidateWebhookEvent(pl.Event); err != nil {
+ if err := pl.Event.Validate(); err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
diff --git a/server/forge/gitea/fixtures/hooks.go b/server/forge/gitea/fixtures/hooks.go
index 397c9b40b..a551f91c3 100644
--- a/server/forge/gitea/fixtures/hooks.go
+++ b/server/forge/gitea/fixtures/hooks.go
@@ -1071,3 +1071,79 @@ const HookPullRequestClosed = `
"review": null
}
`
+
+const HookRelease = `
+{
+ "action": "published",
+ "release": {
+ "id": 48,
+ "tag_name": "0.0.5",
+ "target_commitish": "main",
+ "name": "Version 0.0.5",
+ "body": "",
+ "url": "https://git.xxx/api/v1/repos/anbraten/demo/releases/48",
+ "html_url": "https://git.xxx/anbraten/demo/releases/tag/0.0.5",
+ "tarball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz",
+ "zipball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.zip",
+ "draft": false,
+ "prerelease": false,
+ "created_at": "2022-02-09T20:23:05Z",
+ "published_at": "2022-02-09T20:23:05Z",
+ "author": {"id":1,"login":"anbraten","full_name":"Anton Bracke","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"world","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"},
+ "assets": []
+ },
+ "repository": {
+ "id": 77,
+ "owner": {"id":1,"login":"anbraten","full_name":"Anton Bracke","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"world","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"},
+ "name": "demo",
+ "full_name": "anbraten/demo",
+ "description": "",
+ "empty": false,
+ "private": true,
+ "fork": false,
+ "template": false,
+ "parent": null,
+ "mirror": false,
+ "size": 59,
+ "html_url": "https://git.xxx/anbraten/demo",
+ "ssh_url": "ssh://git@git.xxx:22/anbraten/demo.git",
+ "clone_url": "https://git.xxx/anbraten/demo.git",
+ "original_url": "",
+ "website": "",
+ "stars_count": 0,
+ "forks_count": 1,
+ "watchers_count": 1,
+ "open_issues_count": 2,
+ "open_pr_counter": 2,
+ "release_counter": 4,
+ "default_branch": "main",
+ "archived": false,
+ "created_at": "2021-08-30T20:54:13Z",
+ "updated_at": "2022-01-09T01:29:23Z",
+ "permissions": {
+ "admin": true,
+ "push": true,
+ "pull": true
+ },
+ "has_issues": true,
+ "internal_tracker": {
+ "enable_time_tracker": true,
+ "allow_only_contributors_to_track_time": true,
+ "enable_issue_dependencies": true
+ },
+ "has_wiki": false,
+ "has_pull_requests": true,
+ "has_projects": true,
+ "ignore_whitespace_conflicts": false,
+ "allow_merge_commits": true,
+ "allow_rebase": true,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": true,
+ "default_merge_style": "squash",
+ "avatar_url": "",
+ "internal": false,
+ "mirror_interval": ""
+ },
+ "sender": {"id":1,"login":"anbraten","full_name":"Anbraten","email":"anbraten@noreply.xxx","avatar_url":"https://git.xxx/user/avatar/anbraten/-1","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2018-03-21T10:04:48Z","restricted":false,"active":false,"prohibit_login":false,"location":"World","website":"https://xxx","description":"","visibility":"public","followers_count":1,"following_count":1,"starred_repos_count":1,"username":"anbraten"}
+}
+`
diff --git a/server/forge/gitea/gitea.go b/server/forge/gitea/gitea.go
index 084ca497e..30673e4d3 100644
--- a/server/forge/gitea/gitea.go
+++ b/server/forge/gitea/gitea.go
@@ -510,6 +510,15 @@ func (c *Gitea) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.
return nil, nil, err
}
+ if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" {
+ tagName := strings.Split(pipeline.Ref, "/")[2]
+ sha, err := c.getTagCommitSHA(ctx, repo, tagName)
+ if err != nil {
+ return nil, nil, err
+ }
+ pipeline.Commit = sha
+ }
+
if pipeline != nil && (pipeline.Event == model.EventPull || pipeline.Event == model.EventPullClosed) && len(pipeline.ChangedFiles) == 0 {
index, err := strconv.ParseInt(strings.Split(pipeline.Ref, "/")[2], 10, 64)
if err != nil {
@@ -655,6 +664,36 @@ func (c *Gitea) getChangedFilesForPR(ctx context.Context, repo *model.Repo, inde
})
}
+func (c *Gitea) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {
+ _store, ok := store.TryFromContext(ctx)
+ if !ok {
+ log.Error().Msg("could not get store from context")
+ return "", nil
+ }
+
+ repo, err := _store.GetRepoNameFallback(repo.ForgeRemoteID, repo.FullName)
+ if err != nil {
+ return "", err
+ }
+
+ user, err := _store.GetUser(repo.UserID)
+ if err != nil {
+ return "", err
+ }
+
+ client, err := c.newClientToken(ctx, user.Token)
+ if err != nil {
+ return "", err
+ }
+
+ tag, _, err := client.GetTag(repo.Owner, repo.Name, tagName)
+ if err != nil {
+ return "", err
+ }
+
+ return tag.Commit.SHA, nil
+}
+
func (c *Gitea) perPage(ctx context.Context) int {
if c.pageSize == 0 {
client, err := c.newClientToken(ctx, "")
diff --git a/server/forge/gitea/helper.go b/server/forge/gitea/helper.go
index 44dcde56a..4383638c2 100644
--- a/server/forge/gitea/helper.go
+++ b/server/forge/gitea/helper.go
@@ -175,6 +175,25 @@ func pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline {
return pipeline
}
+func pipelineFromRelease(hook *releaseHook) *model.Pipeline {
+ avatar := expandAvatar(
+ hook.Repo.HTMLURL,
+ fixMalformedAvatar(hook.Sender.AvatarURL),
+ )
+
+ return &model.Pipeline{
+ Event: model.EventRelease,
+ Ref: fmt.Sprintf("refs/tags/%s", hook.Release.TagName),
+ ForgeURL: hook.Release.HTMLURL,
+ Branch: hook.Release.Target,
+ Message: fmt.Sprintf("created release %s", hook.Release.Title),
+ Avatar: avatar,
+ Author: hook.Sender.UserName,
+ Sender: hook.Sender.UserName,
+ IsPrerelease: hook.Release.IsPrerelease,
+ }
+}
+
// helper function that parses a push hook from a read closer.
func parsePush(r io.Reader) (*pushHook, error) {
push := new(pushHook)
@@ -188,6 +207,12 @@ func parsePullRequest(r io.Reader) (*pullRequestHook, error) {
return pr, err
}
+func parseRelease(r io.Reader) (*releaseHook, error) {
+ pr := new(releaseHook)
+ err := json.NewDecoder(r).Decode(pr)
+ return pr, err
+}
+
// fixMalformedAvatar is a helper function that fixes an avatar url if malformed
// (currently a known bug with gitea)
func fixMalformedAvatar(url string) string {
diff --git a/server/forge/gitea/parse.go b/server/forge/gitea/parse.go
index e007ab012..1fb8dacc6 100644
--- a/server/forge/gitea/parse.go
+++ b/server/forge/gitea/parse.go
@@ -31,6 +31,7 @@ const (
hookPush = "push"
hookCreated = "create"
hookPullRequest = "pull_request"
+ hookRelease = "release"
actionOpen = "opened"
actionSync = "synchronized"
@@ -40,7 +41,7 @@ const (
refTag = "tag"
)
-// parseHook parses a Gitea hook from an http.Request request and returns
+// parseHook parses a Gitea hook from an http.Request and returns
// Repo and Pipeline detail. If a hook type is unsupported nil values are returned.
func parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) {
hookType := r.Header.Get(hookEvent)
@@ -51,6 +52,8 @@ func parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) {
return parseCreatedHook(r.Body)
case hookPullRequest:
return parsePullRequestHook(r.Body)
+ case hookRelease:
+ return parseReleaseHook(r.Body)
}
log.Debug().Msgf("unsupported hook type: '%s'", hookType)
return nil, nil, &types.ErrIgnoreEvent{Event: hookType}
@@ -118,3 +121,20 @@ func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, erro
pipeline = pipelineFromPullRequest(pr)
return repo, pipeline, err
}
+
+// parseReleaseHook parses a release hook and returns the Repo and Pipeline details.
+func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) {
+ var (
+ repo *model.Repo
+ pipeline *model.Pipeline
+ )
+
+ release, err := parseRelease(payload)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ repo = toRepo(release.Repo)
+ pipeline = pipelineFromRelease(release)
+ return repo, pipeline, err
+}
diff --git a/server/forge/gitea/parse_test.go b/server/forge/gitea/parse_test.go
index 6bca466e7..bda8e6b56 100644
--- a/server/forge/gitea/parse_test.go
+++ b/server/forge/gitea/parse_test.go
@@ -124,6 +124,17 @@ func Test_parser(t *testing.T) {
g.Assert(err).IsNil()
g.Assert(b.Event).Equal(model.EventPullClosed)
})
+ g.It("should handle release hook", func() {
+ buf := bytes.NewBufferString(fixtures.HookRelease)
+ req, _ := http.NewRequest("POST", "/hook", buf)
+ req.Header = http.Header{}
+ req.Header.Set(hookEvent, hookRelease)
+ r, b, err := parseHook(req)
+ g.Assert(err).IsNil()
+ g.Assert(r).IsNotNil()
+ g.Assert(b).IsNotNil()
+ g.Assert(b.Event).Equal(model.EventRelease)
+ })
})
})
}
diff --git a/server/forge/gitea/types.go b/server/forge/gitea/types.go
index b09d5daa1..2c27a324b 100644
--- a/server/forge/gitea/types.go
+++ b/server/forge/gitea/types.go
@@ -43,3 +43,10 @@ type pullRequestHook struct {
Repo *gitea.Repository `json:"repository"`
Sender *gitea.User `json:"sender"`
}
+
+type releaseHook struct {
+ Action string `json:"action"`
+ Repo *gitea.Repository `json:"repository"`
+ Sender *gitea.User `json:"sender"`
+ Release *gitea.Release
+}
diff --git a/server/forge/github/fixtures/hooks.go b/server/forge/github/fixtures/hooks.go
index 1c6403597..bae47d9e1 100644
--- a/server/forge/github/fixtures/hooks.go
+++ b/server/forge/github/fixtures/hooks.go
@@ -1391,3 +1391,178 @@ const HookPullRequestClosed = `
}
}
`
+
+const HookRelease = `
+{
+ "action": "released",
+ "release": {
+ "url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2",
+ "assets_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2/assets",
+ "upload_url": "https://octocoders.github.io/api/uploads/repos/Codertocat/Hello-World/releases/2/assets{?name,label}",
+ "html_url": "https://octocoders.github.io/Codertocat/Hello-World/releases/tag/0.0.1",
+ "id": 2,
+ "node_id": "MDc6UmVsZWFzZTI=",
+ "tag_name": "0.0.1",
+ "target_commitish": "master",
+ "name": null,
+ "draft": false,
+ "author": {
+ "login": "Codertocat",
+ "id": 4,
+ "node_id": "MDQ6VXNlcjQ=",
+ "avatar_url": "https://octocoders.github.io/avatars/u/4?",
+ "gravatar_id": "",
+ "url": "https://octocoders.github.io/api/v3/users/Codertocat",
+ "html_url": "https://octocoders.github.io/Codertocat",
+ "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers",
+ "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}",
+ "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}",
+ "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}",
+ "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions",
+ "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs",
+ "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos",
+ "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}",
+ "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "prerelease": false,
+ "created_at": "2019-05-15T19:37:08Z",
+ "published_at": "2019-05-15T19:38:20Z",
+ "assets": [],
+ "tarball_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tarball/0.0.1",
+ "zipball_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/zipball/0.0.1",
+ "body": null
+ },
+ "repository": {
+ "id": 118,
+ "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=",
+ "name": "Hello-World",
+ "full_name": "Codertocat/Hello-World",
+ "private": false,
+ "owner": {
+ "login": "Codertocat",
+ "id": 4,
+ "node_id": "MDQ6VXNlcjQ=",
+ "avatar_url": "https://octocoders.github.io/avatars/u/4?",
+ "gravatar_id": "",
+ "url": "https://octocoders.github.io/api/v3/users/Codertocat",
+ "html_url": "https://octocoders.github.io/Codertocat",
+ "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers",
+ "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}",
+ "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}",
+ "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}",
+ "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions",
+ "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs",
+ "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos",
+ "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}",
+ "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "html_url": "https://octocoders.github.io/Codertocat/Hello-World",
+ "description": null,
+ "fork": false,
+ "url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World",
+ "forks_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/forks",
+ "keys_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/keys{/key_id}",
+ "collaborators_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/collaborators{/collaborator}",
+ "teams_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/teams",
+ "hooks_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/hooks",
+ "issue_events_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/events{/number}",
+ "events_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/events",
+ "assignees_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/assignees{/user}",
+ "branches_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/branches{/branch}",
+ "tags_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tags",
+ "blobs_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/blobs{/sha}",
+ "git_tags_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/tags{/sha}",
+ "git_refs_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/refs{/sha}",
+ "trees_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/trees{/sha}",
+ "statuses_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/statuses/{sha}",
+ "languages_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/languages",
+ "stargazers_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/stargazers",
+ "contributors_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contributors",
+ "subscribers_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscribers",
+ "subscription_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscription",
+ "commits_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/commits{/sha}",
+ "git_commits_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/commits{/sha}",
+ "comments_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/comments{/number}",
+ "issue_comment_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/comments{/number}",
+ "contents_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contents/{+path}",
+ "compare_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/compare/{base}...{head}",
+ "merges_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/merges",
+ "archive_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/{archive_format}{/ref}",
+ "downloads_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/downloads",
+ "issues_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues{/number}",
+ "pulls_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/pulls{/number}",
+ "milestones_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/milestones{/number}",
+ "notifications_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/notifications{?since,all,participating}",
+ "labels_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/labels{/name}",
+ "releases_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases{/id}",
+ "deployments_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/deployments",
+ "created_at": "2019-05-15T19:37:07Z",
+ "updated_at": "2019-05-15T19:38:15Z",
+ "pushed_at": "2019-05-15T19:38:19Z",
+ "git_url": "git://octocoders.github.io/Codertocat/Hello-World.git",
+ "ssh_url": "git@octocoders.github.io:Codertocat/Hello-World.git",
+ "clone_url": "https://octocoders.github.io/Codertocat/Hello-World.git",
+ "svn_url": "https://octocoders.github.io/Codertocat/Hello-World",
+ "homepage": null,
+ "size": 0,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "Ruby",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": true,
+ "forks_count": 1,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 2,
+ "license": null,
+ "forks": 1,
+ "open_issues": 2,
+ "watchers": 0,
+ "default_branch": "master"
+ },
+ "enterprise": {
+ "id": 1,
+ "slug": "github",
+ "name": "GitHub",
+ "node_id": "MDg6QnVzaW5lc3Mx",
+ "avatar_url": "https://octocoders.github.io/avatars/b/1?",
+ "description": null,
+ "website_url": null,
+ "html_url": "https://octocoders.github.io/businesses/github",
+ "created_at": "2019-05-14T19:31:12Z",
+ "updated_at": "2019-05-14T19:31:12Z"
+ },
+ "sender": {
+ "login": "Codertocat",
+ "id": 4,
+ "node_id": "MDQ6VXNlcjQ=",
+ "avatar_url": "https://octocoders.github.io/avatars/u/4?",
+ "gravatar_id": "",
+ "url": "https://octocoders.github.io/api/v3/users/Codertocat",
+ "html_url": "https://octocoders.github.io/Codertocat",
+ "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers",
+ "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}",
+ "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}",
+ "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}",
+ "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions",
+ "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs",
+ "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos",
+ "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}",
+ "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "installation": {
+ "id": 5,
+ "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNQ=="
+ }
+}
+`
diff --git a/server/forge/github/github.go b/server/forge/github/github.go
index e59e4cdc0..fb89a7448 100644
--- a/server/forge/github/github.go
+++ b/server/forge/github/github.go
@@ -582,6 +582,15 @@ func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model
return nil, nil, err
}
+ if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" {
+ tagName := strings.Split(pipeline.Ref, "/")[2]
+ sha, err := c.getTagCommitSHA(ctx, repo, tagName)
+ if err != nil {
+ return nil, nil, err
+ }
+ pipeline.Commit = sha
+ }
+
if pull != nil && len(pipeline.ChangedFiles) == 0 {
pipeline, err = c.loadChangedFilesFromPullRequest(ctx, pull, repo, pipeline)
if err != nil {
@@ -629,3 +638,49 @@ func (c *client) loadChangedFilesFromPullRequest(ctx context.Context, pull *gith
return pipeline, err
}
+
+func (c *client) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {
+ _store, ok := store.TryFromContext(ctx)
+ if !ok {
+ log.Error().Msg("could not get store from context")
+ return "", nil
+ }
+
+ repo, err := _store.GetRepoNameFallback(repo.ForgeRemoteID, repo.FullName)
+ if err != nil {
+ return "", err
+ }
+
+ user, err := _store.GetUser(repo.UserID)
+ if err != nil {
+ return "", err
+ }
+
+ gh := c.newClientToken(ctx, user.Token)
+ if err != nil {
+ return "", err
+ }
+
+ page := 1
+ var tag *github.RepositoryTag
+ for {
+ tags, _, err := gh.Repositories.ListTags(ctx, repo.Owner, repo.Name, &github.ListOptions{Page: page})
+ if err != nil {
+ return "", err
+ }
+
+ for _, t := range tags {
+ if t.GetName() == tagName {
+ tag = t
+ break
+ }
+ }
+ if tag != nil {
+ break
+ }
+ }
+ if tag == nil {
+ return "", fmt.Errorf("could not find tag %s", tagName)
+ }
+ return tag.GetCommit().GetSHA(), nil
+}
diff --git a/server/forge/github/parse.go b/server/forge/github/parse.go
index fa354a1db..9a69a79d2 100644
--- a/server/forge/github/parse.go
+++ b/server/forge/github/parse.go
@@ -32,9 +32,10 @@ import (
const (
hookField = "payload"
- actionOpen = "opened"
- actionClose = "closed"
- actionSync = "synchronize"
+ actionOpen = "opened"
+ actionClose = "closed"
+ actionSync = "synchronize"
+ actionReleased = "released"
stateOpen = "open"
stateClose = "closed"
@@ -68,6 +69,9 @@ func parseHook(r *http.Request, merge bool) (*github.PullRequest, *model.Repo, *
return nil, repo, pipeline, nil
case *github.PullRequestEvent:
return parsePullHook(hook, merge)
+ case *github.ReleaseEvent:
+ repo, pipeline := parseReleaseHook(hook)
+ return nil, repo, pipeline, nil
default:
return nil, nil, nil, &types.ErrIgnoreEvent{Event: github.Stringify(hook)}
}
@@ -176,6 +180,33 @@ func parsePullHook(hook *github.PullRequestEvent, merge bool) (*github.PullReque
return hook.GetPullRequest(), convertRepo(hook.GetRepo()), pipeline, nil
}
+// parseReleaseHook parses a release hook and returns the Repo and Pipeline
+// details.
+func parseReleaseHook(hook *github.ReleaseEvent) (*model.Repo, *model.Pipeline) {
+ if hook.GetAction() != actionReleased {
+ return nil, nil
+ }
+
+ name := hook.GetRelease().GetName()
+ if name == "" {
+ name = hook.GetRelease().GetTagName()
+ }
+
+ pipeline := &model.Pipeline{
+ Event: model.EventRelease,
+ ForgeURL: hook.GetRelease().GetHTMLURL(),
+ Ref: fmt.Sprintf("refs/tags/%s", hook.GetRelease().GetTagName()),
+ Branch: hook.GetRelease().GetTargetCommitish(),
+ Message: fmt.Sprintf("created release %s", name),
+ Author: hook.GetRelease().GetAuthor().GetLogin(),
+ Avatar: hook.GetRelease().GetAuthor().GetAvatarURL(),
+ Sender: hook.GetSender().GetLogin(),
+ IsPrerelease: hook.GetRelease().GetPrerelease(),
+ }
+
+ return convertRepo(hook.GetRepo()), pipeline
+}
+
func getChangedFilesFromCommits(commits []*github.HeadCommit) []string {
// assume a capacity of 4 changed files per commit
files := make([]string, 0, len(commits)*4)
diff --git a/server/forge/github/parse_test.go b/server/forge/github/parse_test.go
index e3625dbee..bbd4fb57f 100644
--- a/server/forge/github/parse_test.go
+++ b/server/forge/github/parse_test.go
@@ -19,6 +19,7 @@ import (
"bytes"
"net/http"
"sort"
+ "strings"
"testing"
"github.com/franela/goblin"
@@ -30,10 +31,11 @@ import (
)
const (
- hookEvent = "X-GitHub-Event"
- hookDeploy = "deployment"
- hookPush = "push"
- hookPull = "pull_request"
+ hookEvent = "X-GitHub-Event"
+ hookDeploy = "deployment"
+ hookPush = "push"
+ hookPull = "pull_request"
+ hookRelease = "release"
)
func testHookRequest(payload []byte, event string) *http.Request {
@@ -119,5 +121,19 @@ func Test_parser(t *testing.T) {
g.Assert(b.Event).Equal(model.EventDeploy)
})
})
+
+ g.Describe("given a release hook", func() {
+ g.It("should extract repository and build details", func() {
+ req := testHookRequest([]byte(fixtures.HookRelease), hookRelease)
+ p, r, b, err := parseHook(req, false)
+ g.Assert(err).IsNil()
+ g.Assert(r).IsNotNil()
+ g.Assert(b).IsNotNil()
+ g.Assert(p).IsNil()
+ g.Assert(b.Event).Equal(model.EventRelease)
+ g.Assert(len(strings.Split(b.Ref, "/")) == 3).IsTrue()
+ g.Assert(strings.HasPrefix(b.Ref, "refs/tags/")).IsTrue()
+ })
+ })
})
}
diff --git a/server/forge/gitlab/convert.go b/server/forge/gitlab/convert.go
index 2180c447b..da13ac2ef 100644
--- a/server/forge/gitlab/convert.go
+++ b/server/forge/gitlab/convert.go
@@ -240,6 +240,46 @@ func convertTagHook(hook *gitlab.TagEvent) (*model.Repo, *model.Pipeline, error)
return repo, pipeline, nil
}
+func convertReleaseHook(hook *gitlab.ReleaseEvent) (*model.Repo, *model.Pipeline, error) {
+ repo := &model.Repo{}
+
+ var err error
+ if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil {
+ return nil, nil, err
+ }
+
+ repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.Project.ID))
+ repo.Avatar = ""
+ if hook.Project.AvatarURL != nil {
+ repo.Avatar = *hook.Project.AvatarURL
+ }
+ repo.ForgeURL = hook.Project.WebURL
+ repo.Clone = hook.Project.GitHTTPURL
+ repo.CloneSSH = hook.Project.GitSSHURL
+ repo.FullName = hook.Project.PathWithNamespace
+ repo.Branch = hook.Project.DefaultBranch
+ repo.IsSCMPrivate = hook.Project.VisibilityLevel > 10
+
+ pipeline := &model.Pipeline{
+ Event: model.EventRelease,
+ Commit: hook.Commit.ID,
+ ForgeURL: hook.URL,
+ Message: fmt.Sprintf("created release %s", hook.Name),
+ Sender: hook.Commit.Author.Name,
+ Author: hook.Commit.Author.Name,
+ Email: hook.Commit.Author.Email,
+
+ // Tag name here is the ref. We should add the refs/tags, so
+ // it is known it's a tag (git-plugin looks for it)
+ Ref: "refs/tags/" + hook.Tag,
+ }
+ if len(pipeline.Email) != 0 {
+ pipeline.Avatar = getUserAvatar(pipeline.Email)
+ }
+
+ return repo, pipeline, nil
+}
+
func getUserAvatar(email string) string {
hasher := md5.New()
hasher.Write([]byte(email))
diff --git a/server/forge/gitlab/gitlab.go b/server/forge/gitlab/gitlab.go
index 39a7cd5b7..d94da6526 100644
--- a/server/forge/gitlab/gitlab.go
+++ b/server/forge/gitlab/gitlab.go
@@ -625,6 +625,8 @@ func (g *GitLab) Hook(ctx context.Context, req *http.Request) (*model.Repo, *mod
return convertPushHook(event)
case *gitlab.TagEvent:
return convertTagHook(event)
+ case *gitlab.ReleaseEvent:
+ return convertReleaseHook(event)
default:
return nil, nil, &forge_types.ErrIgnoreEvent{Event: string(eventType)}
}
diff --git a/server/forge/gitlab/gitlab_test.go b/server/forge/gitlab/gitlab_test.go
index e6ee0f77c..0c568c434 100644
--- a/server/forge/gitlab/gitlab_test.go
+++ b/server/forge/gitlab/gitlab_test.go
@@ -235,6 +235,23 @@ func Test_GitLab(t *testing.T) {
assert.Len(t, pipeline.ChangedFiles, 0) // see L217
}
})
+
+ g.It("Should parse release request hook", func() {
+ req, _ := http.NewRequest(
+ testdata.ServiceHookMethod,
+ testdata.ServiceHookURL.String(),
+ bytes.NewReader(testdata.WebhookReleaseBody),
+ )
+ req.Header = testdata.ReleaseHookHeaders
+
+ hookRepo, build, err := client.Hook(ctx, req)
+ assert.NoError(t, err)
+ if assert.NotNil(t, hookRepo) && assert.NotNil(t, build) {
+ assert.Equal(t, "refs/tags/0.0.2", build.Ref)
+ assert.Equal(t, "ci", hookRepo.Name)
+ assert.Equal(t, "created release Awesome version 0.0.2", build.Message)
+ }
+ })
})
})
})
diff --git a/server/forge/gitlab/testdata/hooks.go b/server/forge/gitlab/testdata/hooks.go
index d736420c8..7f5fe5569 100644
--- a/server/forge/gitlab/testdata/hooks.go
+++ b/server/forge/gitlab/testdata/hooks.go
@@ -29,6 +29,11 @@ var (
"User-Agent": []string{"GitLab/14.3.0"},
"X-Gitlab-Event": []string{"Service Hook"},
}
+ ReleaseHookHeaders = http.Header{
+ "Content-Type": []string{"application/json"},
+ "User-Agent": []string{"GitLab/14.3.0"},
+ "X-Gitlab-Event": []string{"Release Hook"},
+ }
)
// HookPush is payload of a push event
@@ -599,3 +604,69 @@ var HookPullRequestMerged = []byte(`
}
}
`)
+
+var WebhookReleaseBody = []byte(`
+{
+ "id": 4268085,
+ "created_at": "2022-02-09 20:19:09 UTC",
+ "description": "new version desc",
+ "name": "Awesome version 0.0.2",
+ "released_at": "2022-02-09 20:19:09 UTC",
+ "tag": "0.0.2",
+ "object_kind": "release",
+ "project": {
+ "id": 32521798,
+ "name": "ci",
+ "description": "",
+ "web_url": "https://gitlab.com/anbratens-test/ci",
+ "avatar_url": null,
+ "git_ssh_url": "git@gitlab.com:anbratens-test/ci.git",
+ "git_http_url": "https://gitlab.com/anbratens-test/ci.git",
+ "namespace": "anbratens-test",
+ "visibility_level": 0,
+ "path_with_namespace": "anbratens-test/ci",
+ "default_branch": "main",
+ "ci_config_path": "",
+ "homepage": "https://gitlab.com/anbratens-test/ci",
+ "url": "git@gitlab.com:anbratens-test/ci.git",
+ "ssh_url": "git@gitlab.com:anbratens-test/ci.git",
+ "http_url": "https://gitlab.com/anbratens-test/ci.git"
+ },
+ "url": "https://gitlab.com/anbratens-test/ci/-/releases/0.0.2",
+ "action": "create",
+ "assets": {
+ "count": 4,
+ "links": [
+ ],
+ "sources": [
+ {
+ "format": "zip",
+ "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.zip"
+ },
+ {
+ "format": "tar.gz",
+ "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.gz"
+ },
+ {
+ "format": "tar.bz2",
+ "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.bz2"
+ },
+ {
+ "format": "tar",
+ "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar"
+ }
+ ]
+ },
+ "commit": {
+ "id": "0b8c02955ba445ea70d22824d9589678852e2b93",
+ "message": "Initial commit",
+ "title": "Initial commit",
+ "timestamp": "2022-01-03T10:39:51+00:00",
+ "url": "https://gitlab.com/anbratens-test/ci/-/commit/0b8c02955ba445ea70d22824d9589678852e2b93",
+ "author": {
+ "name": "Anbraten",
+ "email": "2251488-anbraten@users.noreply.gitlab.com"
+ }
+ }
+}
+`)
diff --git a/server/model/const.go b/server/model/const.go
index fc0585896..152956c6c 100644
--- a/server/model/const.go
+++ b/server/model/const.go
@@ -27,6 +27,7 @@ const (
EventPull WebhookEvent = "pull_request"
EventPullClosed WebhookEvent = "pull_request_closed"
EventTag WebhookEvent = "tag"
+ EventRelease WebhookEvent = "release"
EventDeploy WebhookEvent = "deployment"
EventCron WebhookEvent = "cron"
EventManual WebhookEvent = "manual"
@@ -40,9 +41,9 @@ func (wel WebhookEventList) Less(i, j int) bool { return wel[i] < wel[j] }
var ErrInvalidWebhookEvent = errors.New("invalid webhook event")
-func ValidateWebhookEvent(s WebhookEvent) error {
+func (s WebhookEvent) Validate() error {
switch s {
- case EventPush, EventPull, EventTag, EventDeploy, EventCron, EventManual:
+ case EventPush, EventPull, EventPullClosed, EventTag, EventRelease, EventDeploy, EventCron, EventManual:
return nil
default:
return fmt.Errorf("%w: %s", ErrInvalidWebhookEvent, s)
diff --git a/server/model/pipeline.go b/server/model/pipeline.go
index e1eaabc99..410ba3f11 100644
--- a/server/model/pipeline.go
+++ b/server/model/pipeline.go
@@ -50,6 +50,7 @@ type Pipeline struct {
ChangedFiles []string `json:"changed_files,omitempty" xorm:"LONGTEXT 'changed_files'"`
AdditionalVariables map[string]string `json:"variables,omitempty" xorm:"json 'additional_variables'"`
PullRequestLabels []string `json:"pr_labels,omitempty" xorm:"json 'pr_labels'"`
+ IsPrerelease bool `json:"is_prerelease,omitempty" xorm:"is_prerelease"`
} // @name Pipeline
// TableName return database table name for xorm
diff --git a/server/model/secret.go b/server/model/secret.go
index 4a941ca46..d5d97ca8a 100644
--- a/server/model/secret.go
+++ b/server/model/secret.go
@@ -116,7 +116,7 @@ var validDockerImageString = regexp.MustCompile(
// Validate validates the required fields and formats.
func (s *Secret) Validate() error {
for _, event := range s.Events {
- if err := ValidateWebhookEvent(event); err != nil {
+ if err := event.Validate(); err != nil {
return errors.Join(err, ErrSecretEventInvalid)
}
}
diff --git a/server/pipeline/create.go b/server/pipeline/create.go
index 5182a4cbf..da467425c 100644
--- a/server/pipeline/create.go
+++ b/server/pipeline/create.go
@@ -43,7 +43,11 @@ func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline
skipMatch := skipPipelineRegex.FindString(pipeline.Message)
if len(skipMatch) > 0 {
- log.Debug().Str("repo", repo.FullName).Msgf("ignoring pipeline as skip-ci was found in the commit (%s) message '%s'", pipeline.Commit, pipeline.Message)
+ ref := pipeline.Commit
+ if len(ref) == 0 {
+ ref = pipeline.Ref
+ }
+ log.Debug().Str("repo", repo.FullName).Msgf("ignoring pipeline as skip-ci was found in the commit (%s) message '%s'", ref, pipeline.Message)
return nil, ErrFiltered
}
diff --git a/server/pipeline/stepbuilder/metadata.go b/server/pipeline/stepbuilder/metadata.go
index 63643b6a0..2e2a3c106 100644
--- a/server/pipeline/stepbuilder/metadata.go
+++ b/server/pipeline/stepbuilder/metadata.go
@@ -129,6 +129,7 @@ func metadataPipelineFromModelPipeline(pipeline *model.Pipeline, includeParent b
},
ChangedFiles: pipeline.ChangedFiles,
PullRequestLabels: pipeline.PullRequestLabels,
+ IsPrerelease: pipeline.IsPrerelease,
},
Cron: cron,
}
diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index bc9110707..fbb89a8b2 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -267,7 +267,8 @@
"pr": "Pull Request",
"deploy": "Deploy",
"cron": "Cron",
- "manual": "Manual"
+ "manual": "Manual",
+ "release": "Release"
},
"status": {
"status": "Status: {status}",
diff --git a/web/src/components/repo/pipeline/PipelineItem.vue b/web/src/components/repo/pipeline/PipelineItem.vue
index 7023bbfae..262a7585f 100644
--- a/web/src/components/repo/pipeline/PipelineItem.vue
+++ b/web/src/components/repo/pipeline/PipelineItem.vue
@@ -40,7 +40,7 @@