├── .github └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── gitlab-ci-validate.go ├── go.mod └── go.sum /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: Basic quality checks 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - name: Checkout code 8 | uses: actions/checkout@v2 9 | with: 10 | fetch-depth: 0 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: 1.15 15 | - name: Fmt 16 | run: gofmt -d ./ 17 | - name: Vet 18 | run: go vet ./ 19 | - name: Build 20 | run: go build 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ['*'] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Install Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: 1.15 17 | - name: Run GoReleaser 18 | uses: goreleaser/goreleaser-action@v2.4.1 19 | with: 20 | version: v0.155.2 21 | args: release --rm-dist 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Push Docker image 25 | uses: docker/build-push-action@v1 26 | with: 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | registry: docker.pkg.github.com 30 | repository: code0x58/gitlab-ci-validate/gitlab-ci-validate 31 | tag_with_ref: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gitlab-ci-validate 2 | /dist/ 3 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - windows 7 | - darwin 8 | archives: 9 | - id: binary 10 | replacements: 11 | darwin: Darwin 12 | linux: Linux 13 | windows: Windows 14 | 386: i386 15 | amd64: x86_64 16 | format: binary 17 | - id: compressed 18 | replacements: 19 | darwin: Darwin 20 | linux: Linux 21 | windows: Windows 22 | 386: i386 23 | amd64: x86_64 24 | format: tar.gz 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | checksum: 29 | name_template: 'checksums.txt' 30 | snapshot: 31 | name_template: "{{ .Tag }}-next" 32 | changelog: 33 | sort: asc 34 | filters: 35 | exclude: 36 | - '^docs:' 37 | - '^test:' 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as build-step 2 | 3 | WORKDIR /build 4 | COPY . . 5 | 6 | RUN apk add --no-cache ca-certificates && \ 7 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -tags netgo -ldflags '-w' ./gitlab-ci-validate.go 8 | 9 | 10 | FROM scratch 11 | 12 | WORKDIR /yaml 13 | COPY --from=build-step /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 14 | COPY --from=build-step /build/gitlab-ci-validate /gitlab-ci-validate 15 | 16 | ENV GITLAB_HOST=https://gitlab.com 17 | 18 | ENTRYPOINT [ "/gitlab-ci-validate" ] 19 | CMD [ ".gitlab-ci.yml" ] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Oliver Bristow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## gitlab-ci-validate 2 | 3 | This tool uses GitLab's CI [config validation API endpoint](https://docs.gitlab.com/ce/api/lint.html) to validate local config files. 4 | 5 | If you don't want to use the command line, you can paste your config into `https://gitlab.com//-/ci/lint` [[ref](https://docs.gitlab.com/ee/ci/yaml/#validate-the-gitlab-ciyml)] 6 | 7 | ### Usage 8 | 9 | > :warning: Since GitLab 13.7.7 (2021-02-11) authentication is required, so you will need to use `--token=$ACCESS_TOKEN` or `--host=http://$USERNAME:$PASSWORD@gitlab.com` 10 | 11 | One or more `.gitlab-ci.yml` are passed as arguments on the command line. Any errors will result in a non-zero exit code. The filename must end in `.yml` to pass, but doesn't have to be `.gitlab-ci.yml`. 12 | 13 | An access token must be provided in order to authenticate with the gitlab API. You can see your access tokens through [your profile settings](https://gitlab.com/-/profile/personal_access_tokens). The token must have at least the "api" scope. 14 | 15 | ```text 16 | $ gitlab-ci-validate --token=ACCESS_TOKEN ./good.yml ./maybe-good.yml ./bad.yml 17 | PASS: ./good.yml 18 | SOFT FAIL: ./maybe-good.yml 19 | - Post https://gitlab.com/api/v4/ci/lint: dial tcp: lookup gitlab.com on 127.0.0.53:53: read udp 127.0.0.1:41487->127.0.0.53:53: i/o timeout 20 | HARD FAIL: ./bad.yml 21 | - jobs:storage config contains unknown keys: files 22 | ``` 23 | 24 | Each input file will be validated and one of 3 results will be printed for it: 25 | 26 | - _PASS_ - the file passed all checks 27 | - _SOFT FAIL_ - the file is acessable and contains valid YAML, but there was an error contacting the validation API 28 | - _HARD FAIL_ - the file failed any checks 29 | 30 | The exit code will be: 31 | 32 | - 0 if all files are valid (all _PASS_) 33 | - 1 if any files are invalid (any _HARD FAIL_) 34 | - 2 if there was any _SOFT FAIL_ and no _HARD FAIL_ 35 | 36 | ### Using private GitLab host 37 | 38 | You can also use a private GitLab host both as a flag or as an environment variable. 39 | The following are equivalent. 40 | 41 | ``` 42 | gitlab-ci-validate --token=$ACCESS_TOKEN --host=http://$USERNAME:$PASSWORD@127.0.0.1:8080 .gitlab-ci.yml 43 | ``` 44 | 45 | ``` 46 | export GITLAB_HOST=http://user:pass@127.0.0.1:8080 47 | export GITLAB_TOKEN=$ACCESS_TOKEN 48 | gitlab-ci-validate .gitlab-ci.yml 49 | ``` 50 | 51 | The flag has precedence over the environment variable. 52 | When not specified the host used is by default `https://gitlab.com` 53 | 54 | ### Installation 55 | 56 | You can either use a premade binary from the [releases page](https://github.com/Code0x58/gitlab-ci-validate/releases) or you can install it using `go get`: 57 | 58 | ```sh 59 | go get -u github.com/Code0x58/gitlab-ci-validate 60 | ``` 61 | 62 | #### Usage with Docker containers 63 | 64 | You can use the Dockerfile to build your own image, or use the pre-built version available at the [GitHub Container Registry](https://github.com/Code0x58/gitlab-ci-validate/packages/) - you will need to be logged in first (see [docs](https://docs.github.com/en/packages/guides/configuring-docker-for-use-with-github-packages#authenticating-to-github-packages)). 65 | 66 | The default argument given to `gitlab-ci-validate` in the container is `.gitlab-ci.yml`, so the following will check that file from the current working directory: 67 | 68 | ```sh 69 | docker run -i --rm \ 70 | -v "${PWD}":/yaml \ 71 | docker.pkg.github.com/code0x58/gitlab-ci-validate/gitlab-ci-validate:$VERSION 72 | ``` 73 | -------------------------------------------------------------------------------- /gitlab-ci-validate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "github.com/ghodss/yaml" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "runtime" 14 | "strings" 15 | ) 16 | 17 | type lintResponse struct { 18 | Status string `json:"status"` 19 | Errors []string `json:"errors"` 20 | } 21 | 22 | type Validation int 23 | 24 | const ( 25 | // file passed remote validation 26 | PASS Validation = iota 27 | // file couldn't be remotely validated 28 | SOFT_FAIL 29 | // file failed local or remote validation 30 | HARD_FAIL 31 | ) 32 | 33 | var version string 34 | var userAgent string 35 | 36 | func init() { 37 | userAgent = fmt.Sprintf("gitlab-ci-validate/%s go/%s %s/%s", version, runtime.Version(), runtime.GOOS, runtime.GOARCH) 38 | } 39 | 40 | // Validate the given file 41 | func ValidateFile(hostUrl url.URL, path string) (Validation, []error) { 42 | content, err := ioutil.ReadFile(path) 43 | if err != nil { 44 | return HARD_FAIL, []error{err} 45 | } 46 | 47 | if !strings.HasSuffix(path, ".yml") { 48 | return HARD_FAIL, []error{fmt.Errorf("file name does not end with .yml - only .gitlab-ci.yaml is allowed by GitLab")} 49 | } 50 | 51 | data, err := yaml.YAMLToJSON(content) 52 | if err != nil { 53 | return HARD_FAIL, []error{err} 54 | } 55 | 56 | values := url.Values{"content": {string(data)}} 57 | hostUrl.Path = "/api/v4/ci/lint" 58 | request, err := http.NewRequest("POST", hostUrl.String(), strings.NewReader(values.Encode())) 59 | if err != nil { 60 | return SOFT_FAIL, []error{err} 61 | } 62 | request.Header.Set("User-Agent", userAgent) 63 | response, err := http.DefaultClient.Do(request) 64 | 65 | if err != nil { 66 | return SOFT_FAIL, []error{err} 67 | } 68 | defer response.Body.Close() 69 | if response.StatusCode == 401 || response.StatusCode == 403 { 70 | fmt.Printf("HTTP %d recieved from %s, authentication is required. See usage on how to provide an identity if you have not already, otherwise double check your basic auth or token.\n", response.StatusCode, hostUrl.Host) 71 | responseBytes, err := ioutil.ReadAll(response.Body) 72 | if err == nil { 73 | fmt.Printf("message from server: %s\n", responseBytes) 74 | } 75 | os.Exit(1) 76 | } 77 | if response.StatusCode != 200 { 78 | return SOFT_FAIL, []error{fmt.Errorf("Non-200 status from %s for %s: %d", hostUrl.Host, hostUrl.Path, response.StatusCode)} 79 | } 80 | 81 | responseBytes, err := ioutil.ReadAll(response.Body) 82 | if err != nil { 83 | return SOFT_FAIL, []error{err} 84 | } 85 | 86 | var summary lintResponse 87 | json.Unmarshal(responseBytes, &summary) 88 | if summary.Status != "valid" { 89 | errs := make([]error, len(summary.Errors)) 90 | for i, err := range summary.Errors { 91 | errs[i] = fmt.Errorf(err) 92 | } 93 | return HARD_FAIL, errs 94 | } 95 | return PASS, nil 96 | } 97 | 98 | func getEnv(key, fallback string) string { 99 | if value, ok := os.LookupEnv(key); ok { 100 | return value 101 | } 102 | return fallback 103 | } 104 | 105 | func main() { 106 | flag.Usage = func() { 107 | fmt.Printf("Usage: %s [-host=string] [-token=string] file ...\n", os.Args[0]) 108 | flag.PrintDefaults() 109 | } 110 | 111 | token := flag.String("token", getEnv("GITLAB_TOKEN", ""), "GitLab API access token") 112 | 113 | host := flag.String("host", getEnv("GITLAB_HOST", "https://gitlab.com"), "GitLab instance used to validate the config files") 114 | flag.Parse() 115 | 116 | baseUrl, err := url.Parse(*host) 117 | if err != nil { 118 | fmt.Printf("host is not valid URL: %s\n", *host) 119 | os.Exit(1) 120 | } 121 | if baseUrl.Scheme == "" { 122 | baseUrl.Scheme = "https" 123 | // this is because the baseUrl.Host is not set when the scheme is no present 124 | baseUrl, err = url.Parse(baseUrl.String()) 125 | if err != nil { 126 | fmt.Printf("host is not a valid URL: %s\n", *host) 127 | } 128 | } 129 | if *token != "" { 130 | params := url.Values{"private_token": {*token}} 131 | baseUrl.RawQuery = params.Encode() 132 | } 133 | 134 | l := log.New(os.Stderr, "", 0) 135 | if flag.NArg() < 1 { 136 | flag.Usage() 137 | os.Exit(1) 138 | } 139 | 140 | var result Validation 141 | for _, source := range flag.Args() { 142 | validation, errs := ValidateFile(*baseUrl, source) 143 | if validation > result { 144 | result = validation 145 | } 146 | if errs == nil { 147 | l.Printf("PASS: %s\n", source) 148 | } else { 149 | var status string 150 | if validation == SOFT_FAIL { 151 | status = "SOFT" 152 | } else { 153 | status = "HARD" 154 | } 155 | l.Printf("%s FAIL: %s\n", status, source) 156 | for _, err := range errs { 157 | l.Printf(" - %s\n", err) 158 | } 159 | } 160 | } 161 | if result == HARD_FAIL { 162 | os.Exit(1) 163 | } else if result == SOFT_FAIL { 164 | os.Exit(2) 165 | } else { 166 | os.Exit(0) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Code0x58/gitlab-ci-validate 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/ghodss/yaml v1.0.0 7 | gopkg.in/yaml.v2 v2.2.2 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 2 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 3 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 4 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 5 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 6 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 7 | --------------------------------------------------------------------------------