You've already forked goreleaser
							
							
				mirror of
				https://github.com/goreleaser/goreleaser.git
				synced 2025-10-30 23:58:09 +02:00 
			
		
		
		
	feat: Use GitLab Direct Asset Links (#2219)
* feat: Use GitLab Direct Asset Links Implement the use of Direct Asset Links when uploading artifacts to a GitLab release * fix: Remove ArtifactUploadHash As GitLab support for direct asset linking exists, remove ArtifactUploadHash due to it no longer being required * test: fix unit tests for gitlab urls * fix: Use artifact name during GitLab upload file.Name() included the path to the file, which isn't needed and breaks other areas such as homebrew releases * docs: Require GitLab version v12.9+ Due to newly introduced dependency on direct asset linking
This commit is contained in:
		| @@ -2,11 +2,9 @@ package client | ||||
|  | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/apex/log" | ||||
| 	"github.com/goreleaser/goreleaser/internal/artifact" | ||||
| @@ -18,9 +16,6 @@ import ( | ||||
|  | ||||
| const DefaultGitLabDownloadURL = "https://gitlab.com" | ||||
|  | ||||
| // ErrExtractHashFromFileUploadURL indicates the file upload hash could not ne extracted from the url. | ||||
| var ErrExtractHashFromFileUploadURL = errors.New("could not extract hash from gitlab file upload url") | ||||
|  | ||||
| type gitlabClient struct { | ||||
| 	client *gitlab.Client | ||||
| } | ||||
| @@ -264,7 +259,7 @@ func (c *gitlabClient) CreateRelease(ctx *context.Context, body string) (release | ||||
|  | ||||
| func (c *gitlabClient) ReleaseURLTemplate(ctx *context.Context) (string, error) { | ||||
| 	return fmt.Sprintf( | ||||
| 		"%s/%s/%s/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}", | ||||
| 		"%s/%s/%s/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}", | ||||
| 		ctx.Config.GitLabURLs.Download, | ||||
| 		ctx.Config.Release.GitLab.Owner, | ||||
| 		ctx.Config.Release.GitLab.Name, | ||||
| @@ -299,12 +294,14 @@ func (c *gitlabClient) Upload( | ||||
| 	// projectFile.URL from upload: /uploads/<hash>/filename.txt | ||||
| 	linkURL := gitlabBaseURL + "/" + projectID + projectFile.URL | ||||
| 	name := artifact.Name | ||||
| 	filename := "/" + name | ||||
| 	releaseLink, _, err := c.client.ReleaseLinks.CreateReleaseLink( | ||||
| 		projectID, | ||||
| 		releaseID, | ||||
| 		&gitlab.CreateReleaseLinkOptions{ | ||||
| 			Name: &name, | ||||
| 			URL:  &linkURL, | ||||
| 			Name:     &name, | ||||
| 			URL:      &linkURL, | ||||
| 			FilePath: &filename, | ||||
| 		}) | ||||
| 	if err != nil { | ||||
| 		return RetriableError{err} | ||||
| @@ -312,43 +309,17 @@ func (c *gitlabClient) Upload( | ||||
|  | ||||
| 	log.WithFields(log.Fields{ | ||||
| 		"id":  releaseLink.ID, | ||||
| 		"url": releaseLink.URL, | ||||
| 		"url": releaseLink.DirectAssetURL, | ||||
| 	}).Debug("created release link") | ||||
|  | ||||
| 	fileUploadHash, err := extractProjectFileHashFrom(projectFile.URL) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// for checksums.txt the field is nil, so we initialize it | ||||
| 	if artifact.Extra == nil { | ||||
| 		artifact.Extra = make(map[string]interface{}) | ||||
| 	} | ||||
| 	// we set this hash to be able to download the file | ||||
| 	// in following publish pipes like brew, scoop | ||||
| 	artifact.Extra["ArtifactUploadHash"] = fileUploadHash | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // extractProjectFileHashFrom extracts the hash from the | ||||
| // relative project file url of the format '/uploads/<hash>/filename.ext'. | ||||
| func extractProjectFileHashFrom(projectFileURL string) (string, error) { | ||||
| 	log.WithField("projectFileURL", projectFileURL).Debug("extract file hash from") | ||||
| 	splittedProjectFileURL := strings.Split(projectFileURL, "/") | ||||
| 	if len(splittedProjectFileURL) != 4 { | ||||
| 		log.WithField("projectFileURL", projectFileURL).Debug("could not extract file hash") | ||||
| 		return "", ErrExtractHashFromFileUploadURL | ||||
| 	} | ||||
|  | ||||
| 	fileHash := splittedProjectFileURL[2] | ||||
| 	log.WithFields(log.Fields{ | ||||
| 		"projectFileURL": projectFileURL, | ||||
| 		"fileHash":       fileHash, | ||||
| 	}).Debug("extracted file hash") | ||||
| 	return fileHash, nil | ||||
| } | ||||
|  | ||||
| // getMilestoneByTitle returns a milestone by title. | ||||
| func (c *gitlabClient) getMilestoneByTitle(repo Repo, title string) (*gitlab.Milestone, error) { | ||||
| 	opts := &gitlab.ListMilestonesOptions{ | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package client | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/goreleaser/goreleaser/pkg/config" | ||||
| @@ -9,31 +8,6 @@ import ( | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestExtractHashFromProjectFileURL(t *testing.T) { | ||||
| 	givenHash := "22e8b1508b0f28433b94754a5ea2f4aa" | ||||
| 	projectFileURL := fmt.Sprintf("/uploads/%s/release-testing_0.3.7_Darwin_x86_64.tar.gz", givenHash) | ||||
| 	extractedHash, err := extractProjectFileHashFrom(projectFileURL) | ||||
| 	if err != nil { | ||||
| 		t.Errorf("expexted no error but got: %v", err) | ||||
| 	} | ||||
| 	require.Equal(t, givenHash, extractedHash) | ||||
| } | ||||
|  | ||||
| func TestFailToExtractHashFromProjectFileURL(t *testing.T) { | ||||
| 	givenHash := "22e8b1508b0f28433b94754a5ea2f4aa" | ||||
| 	projectFileURL := fmt.Sprintf("/uploads/%s/new-path/file.ext", givenHash) | ||||
| 	_, err := extractProjectFileHashFrom(projectFileURL) | ||||
| 	if err == nil { | ||||
| 		t.Errorf("expected an error but got none for new-path in url") | ||||
| 	} | ||||
|  | ||||
| 	projectFileURL = fmt.Sprintf("/%s/file.ext", givenHash) | ||||
| 	_, err = extractProjectFileHashFrom(projectFileURL) | ||||
| 	if err == nil { | ||||
| 		t.Errorf("expected an error but got none for path-too-small in url") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestGitLabReleaseURLTemplate(t *testing.T) { | ||||
| 	ctx := context.New(config.Project{ | ||||
| 		GitLabURLs: config.GitLabURLs{ | ||||
| @@ -53,6 +27,6 @@ func TestGitLabReleaseURLTemplate(t *testing.T) { | ||||
| 	urlTpl, err := client.ReleaseURLTemplate(ctx) | ||||
| 	require.NoError(t, err) | ||||
|  | ||||
| 	expectedUrl := "https://gitlab.com/owner/name/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}" | ||||
| 	expectedUrl := "https://gitlab.com/owner/name/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}" | ||||
| 	require.Equal(t, expectedUrl, urlTpl) | ||||
| } | ||||
|   | ||||
| @@ -223,9 +223,8 @@ func TestRunPipe(t *testing.T) { | ||||
| 				Goarch: "amd64", | ||||
| 				Type:   artifact.UploadableArchive, | ||||
| 				Extra: map[string]interface{}{ | ||||
| 					"ID":                 "bar", | ||||
| 					"Format":             "tar.gz", | ||||
| 					"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					"ID":     "bar", | ||||
| 					"Format": "tar.gz", | ||||
| 				}, | ||||
| 			}) | ||||
| 			path := filepath.Join(folder, "bin.tar.gz") | ||||
| @@ -236,9 +235,8 @@ func TestRunPipe(t *testing.T) { | ||||
| 				Goarch: "amd64", | ||||
| 				Type:   artifact.UploadableArchive, | ||||
| 				Extra: map[string]interface{}{ | ||||
| 					"ID":                 "foo", | ||||
| 					"Format":             "tar.gz", | ||||
| 					"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					"ID":     "foo", | ||||
| 					"Format": "tar.gz", | ||||
| 				}, | ||||
| 			}) | ||||
|  | ||||
| @@ -301,9 +299,8 @@ func TestRunPipeNameTemplate(t *testing.T) { | ||||
| 		Goarch: "amd64", | ||||
| 		Type:   artifact.UploadableArchive, | ||||
| 		Extra: map[string]interface{}{ | ||||
| 			"ID":                 "foo", | ||||
| 			"Format":             "tar.gz", | ||||
| 			"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 			"ID":     "foo", | ||||
| 			"Format": "tar.gz", | ||||
| 		}, | ||||
| 	}) | ||||
|  | ||||
| @@ -386,9 +383,8 @@ func TestRunPipeMultipleBrewsWithSkip(t *testing.T) { | ||||
| 		Goarch: "amd64", | ||||
| 		Type:   artifact.UploadableArchive, | ||||
| 		Extra: map[string]interface{}{ | ||||
| 			"ID":                 "foo", | ||||
| 			"Format":             "tar.gz", | ||||
| 			"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 			"ID":     "foo", | ||||
| 			"Format": "tar.gz", | ||||
| 		}, | ||||
| 	}) | ||||
|  | ||||
|   | ||||
| @@ -274,18 +274,12 @@ func Test_doRun(t *testing.T) { | ||||
| 					Goos:   "windows", | ||||
| 					Goarch: "amd64", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					}, | ||||
| 				}, | ||||
| 				{ | ||||
| 					Name:   "foo_1.0.1_windows_386.tar.gz", | ||||
| 					Goos:   "windows", | ||||
| 					Goarch: "386", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			shouldNotErr, | ||||
| @@ -334,18 +328,12 @@ func Test_doRun(t *testing.T) { | ||||
| 					Goos:   "windows", | ||||
| 					Goarch: "amd64", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					}, | ||||
| 				}, | ||||
| 				{ | ||||
| 					Name:   "foo_1.0.1_windows_386.tar.gz", | ||||
| 					Goos:   "windows", | ||||
| 					Goarch: "386", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			shouldNotErr, | ||||
| @@ -904,7 +892,7 @@ func Test_buildManifest(t *testing.T) { | ||||
| 						}, | ||||
| 						Description:           "A run pipe test formula", | ||||
| 						Homepage:              "https://gitlab.com/goreleaser", | ||||
| 						URLTemplate:           "http://gitlab.mycompany.com/foo/bar/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}", | ||||
| 						URLTemplate:           "http://gitlab.mycompany.com/foo/bar/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}", | ||||
| 						CommitMessageTemplate: "chore(scoop): update {{ .ProjectName }} version {{ .Tag }}", | ||||
| 						Persist:               []string{"data.cfg", "etc"}, | ||||
| 					}, | ||||
| @@ -929,7 +917,6 @@ func Test_buildManifest(t *testing.T) { | ||||
| 					Goarch: "amd64", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 						"Builds": []*artifact.Artifact{ | ||||
| 							{ | ||||
| 								Name: "foo.exe", | ||||
| @@ -946,7 +933,6 @@ func Test_buildManifest(t *testing.T) { | ||||
| 					Goarch: "arm", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 						"Builds": []*artifact.Artifact{ | ||||
| 							{ | ||||
| 								Name: "foo.exe", | ||||
| @@ -963,7 +949,6 @@ func Test_buildManifest(t *testing.T) { | ||||
| 					Goarch: "386", | ||||
| 					Path:   file, | ||||
| 					Extra: map[string]interface{}{ | ||||
| 						"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 						"Builds": []*artifact.Artifact{ | ||||
| 							{ | ||||
| 								Name: "foo.exe", | ||||
| @@ -1026,7 +1011,7 @@ func TestWrapInDirectory(t *testing.T) { | ||||
| 				}, | ||||
| 				Description:           "A run pipe test formula", | ||||
| 				Homepage:              "https://gitlab.com/goreleaser", | ||||
| 				URLTemplate:           "http://gitlab.mycompany.com/foo/bar/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}", | ||||
| 				URLTemplate:           "http://gitlab.mycompany.com/foo/bar/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}", | ||||
| 				CommitMessageTemplate: "chore(scoop): update {{ .ProjectName }} version {{ .Tag }}", | ||||
| 				Persist:               []string{"data.cfg", "etc"}, | ||||
| 			}, | ||||
| @@ -1042,8 +1027,7 @@ func TestWrapInDirectory(t *testing.T) { | ||||
| 			Goarch: "amd64", | ||||
| 			Path:   file, | ||||
| 			Extra: map[string]interface{}{ | ||||
| 				"ArtifactUploadHash": "820ead5d9d2266c728dce6d4d55b6460", | ||||
| 				"WrappedIn":          "foo_1.0.1_windows_amd64", | ||||
| 				"WrappedIn": "foo_1.0.1_windows_amd64", | ||||
| 				"Builds": []*artifact.Artifact{ | ||||
| 					{ | ||||
| 						Name: "foo.exe", | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|     "version": "1.0.1", | ||||
|     "architecture": { | ||||
|         "32bit": { | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/uploads/820ead5d9d2266c728dce6d4d55b6460/foo_1.0.1_windows_386.tar.gz", | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/-/releases/v1.0.1/downloads/foo_1.0.1_windows_386.tar.gz", | ||||
|             "bin": [ | ||||
|                 "foo.exe", | ||||
|                 "bar.exe" | ||||
| @@ -10,7 +10,7 @@ | ||||
|             "hash": "5e2bf57d3f40c4b6df69daf1936cb766f832374b4fc0259a7cbff06e2f70f269" | ||||
|         }, | ||||
|         "64bit": { | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/uploads/820ead5d9d2266c728dce6d4d55b6460/foo_1.0.1_windows_amd64.tar.gz", | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/-/releases/v1.0.1/downloads/foo_1.0.1_windows_amd64.tar.gz", | ||||
|             "bin": [ | ||||
|                 "foo.exe", | ||||
|                 "bar.exe" | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|     "version": "1.0.1", | ||||
|     "architecture": { | ||||
|         "64bit": { | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/uploads/820ead5d9d2266c728dce6d4d55b6460/foo_1.0.1_windows_amd64.tar.gz", | ||||
|             "url": "http://gitlab.mycompany.com/foo/bar/-/releases/v1.0.1/downloads/foo_1.0.1_windows_amd64.tar.gz", | ||||
|             "bin": [ | ||||
|                 "foo_1.0.1_windows_amd64/foo.exe", | ||||
|                 "foo_1.0.1_windows_amd64/bar.exe" | ||||
|   | ||||
| @@ -55,9 +55,6 @@ const ( | ||||
| 	artifactName = "ArtifactName" | ||||
| 	artifactPath = "ArtifactPath" | ||||
|  | ||||
| 	// gitlab only. | ||||
| 	artifactUploadHash = "ArtifactUploadHash" | ||||
|  | ||||
| 	// build keys. | ||||
| 	name   = "Name" | ||||
| 	ext    = "Ext" | ||||
| @@ -135,11 +132,6 @@ func (t *Template) WithArtifact(a *artifact.Artifact, replacements map[string]st | ||||
| 	t.fields[binary] = bin.(string) | ||||
| 	t.fields[artifactName] = a.Name | ||||
| 	t.fields[artifactPath] = a.Path | ||||
| 	if val, ok := a.Extra["ArtifactUploadHash"]; ok { | ||||
| 		t.fields[artifactUploadHash] = val | ||||
| 	} else { | ||||
| 		t.fields[artifactUploadHash] = "" | ||||
| 	} | ||||
| 	return t | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -47,7 +47,6 @@ func TestWithArtifact(t *testing.T) { | ||||
| 		"shortcommit":                      "{{.ShortCommit}}", | ||||
| 		"binary":                           "{{.Binary}}", | ||||
| 		"proj":                             "{{.ProjectName}}", | ||||
| 		"":                                 "{{.ArtifactUploadHash}}", | ||||
| 		"github.com/goreleaser/goreleaser": "{{ .ModulePath }}", | ||||
| 	} { | ||||
| 		tmpl := tmpl | ||||
| @@ -72,24 +71,6 @@ func TestWithArtifact(t *testing.T) { | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	t.Run("artifact with gitlab ArtifactUploadHash", func(t *testing.T) { | ||||
| 		t.Parallel() | ||||
| 		uploadHash := "820ead5d9d2266c728dce6d4d55b6460" | ||||
| 		result, err := New(ctx).WithArtifact( | ||||
| 			&artifact.Artifact{ | ||||
| 				Name:   "another-binary", | ||||
| 				Goarch: "amd64", | ||||
| 				Goos:   "linux", | ||||
| 				Goarm:  "6", | ||||
| 				Extra: map[string]interface{}{ | ||||
| 					"ArtifactUploadHash": uploadHash, | ||||
| 				}, | ||||
| 			}, map[string]string{}, | ||||
| 		).Apply("{{ .ArtifactUploadHash }}") | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, uploadHash, result) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("artifact without binary name", func(t *testing.T) { | ||||
| 		t.Parallel() | ||||
| 		result, err := New(ctx).WithArtifact( | ||||
|   | ||||
| @@ -49,7 +49,7 @@ brews: | ||||
|  | ||||
|     # Template for the url which is determined by the given Token (github or gitlab) | ||||
|     # Default for github is "https://github.com/<repo_owner>/<repo_name>/releases/download/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|     # Default for gitlab is "https://gitlab.com/<repo_owner>/<repo_name>/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}" | ||||
|     # Default for gitlab is "https://gitlab.com/<repo_owner>/<repo_name>/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}" | ||||
|     # Default for gitea is "https://gitea.com/<repo_owner>/<repo_name>/releases/download/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|     url_template: "http://github.mycompany.com/foo/bar/releases/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|  | ||||
| @@ -132,7 +132,7 @@ brews: | ||||
|     install: | | ||||
|       bin.install "program" | ||||
|       ... | ||||
|        | ||||
|  | ||||
|     # Custom post_install script for brew. | ||||
|     # Could be used to do any additional work after the "install" script | ||||
|     # Default is empty. | ||||
| @@ -178,7 +178,7 @@ class Program < Formula | ||||
|   def install | ||||
|     bin.install "program" | ||||
|   end | ||||
|    | ||||
|  | ||||
|   def post_install | ||||
|   	etc.install "app-config.conf" | ||||
|   end | ||||
|   | ||||
| @@ -95,7 +95,7 @@ release: | ||||
|     If you use GitLab subgroups, you need to specify it in the `owner` field, e.g. `mygroup/mysubgroup`. | ||||
|  | ||||
| !!! warning | ||||
|     Only GitLab `v11.7+` are supported for releases. | ||||
|     Only GitLab `v12.9+` are supported for releases. | ||||
|  | ||||
| You can also configure the `release` section to upload to a [Gitea](https://gitea.io) instance: | ||||
|  | ||||
|   | ||||
| @@ -13,7 +13,7 @@ the commented example below: | ||||
| scoop: | ||||
|   # Template for the url which is determined by the given Token (github or gitlab) | ||||
|   # Default for github is "https://github.com/<repo_owner>/<repo_name>/releases/download/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|   # Default for gitlab is "https://gitlab.com/<repo_owner>/<repo_name>/uploads/{{ .ArtifactUploadHash }}/{{ .ArtifactName }}" | ||||
|   # Default for gitlab is "https://gitlab.com/<repo_owner>/<repo_name>/-/releases/{{ .Tag }}/downloads/{{ .ArtifactName }}" | ||||
|   # Default for gitea is "https://gitea.com/<repo_owner>/<repo_name>/releases/download/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|   url_template: "http://github.mycompany.com/foo/bar/releases/{{ .Tag }}/{{ .ArtifactName }}" | ||||
|  | ||||
|   | ||||
| @@ -104,9 +104,9 @@ Or, if you released to GitLab, check it out too! | ||||
| </a> | ||||
|  | ||||
| !!! note | ||||
|     Releasing to a private-hosted GitLab CE will only work for version `v11.7+`, | ||||
|     because the release feature was introduced in this | ||||
|     [version](https://docs.gitlab.com/ee/user/project/releases/index.html). | ||||
|     Releasing to a private-hosted GitLab CE will only work for version `v12.9+`, due to dependencies | ||||
|     on [release](https://docs.gitlab.com/ee/user/project/releases/index.html) functionality | ||||
|     and [direct asset linking](https://docs.gitlab.com/ee/user/project/releases/index.html#permanent-links-to-release-assets). | ||||
|  | ||||
| ## Dry run | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user