Shellcheck Scripts Embedded in GitLab CI YAML

I’m a big fan of linters. They detect problems earlier (also known as “shifting to the left”), and the earlier problem detection is, the more efficient remediation is. Therefore, I want to lint as much as possible. Lately, I’ve been working a lot with GitLab CI YAML which oftentimes has shell script embedded in it which naturally would benefit from linting. So I figured out how to do so using a combination of yq and shellcheck as demonstrated by this GitLab CI job:

shellcheck:
  image: alpine:3.19.1
  script:
    - |
      # shellcheck shell=sh
      # Check *.sh
      git ls-files --exclude='*.sh' --ignored -c -z | xargs -0r shellcheck -P SCRIPTDIR -x
      # Check files with a shell shebang
      git ls-files -c | while IFS= read -r file; do
        if head -n1 "${file}" |grep -q "^#\\! \?/.\+\(ba|d|k\)\?sh" ; then
          shellcheck -P SCRIPTDIR -x "${file}"
        fi
      done
      # Check script embedded in GitLab CI YAML named *.gitlab-ci.yml
      newline="$(printf '\nq')"
      newline=${newline%q}
      git ls-files --exclude='*.gitlab-ci.yml' --ignored -c | while IFS= read -r file; do
        documentCount=$(yq eval-all '[.] | length' "${file}")
        documentIndex=-1
        while [ "$documentIndex" -lt "$documentCount" ]; do
          true $((documentIndex=documentIndex+1))
          yq eval 'select(documentIndex == '${documentIndex}') | .[] | select(tag=="!!map") | (.before_script,.script,.after_script) | select(. != null ) | path | "select(documentIndex == '${documentIndex}') | .[\"" + join("\"].[\"") + "\"]"' "${file}" | while IFS= read -r selector; do
            script=$(yq eval "${selector} | join(\"${newline}\")" "${file}")
            if ! printf '%s' "${script}" | shellcheck -x -; then
              >&2 printf "\nError in %s in the script specified in %s:\n%s\n" "${file}" "${selector}" "${script}"
              exit 1
            fi
          done
        done
      done

This GitLab CI job will run shellcheck on:

  • *.sh files
  • files with a shell shebang
  • script embedded in GitLab CI YAML in script, before_script, and after_script (which are which are all the places where shell script can appear in GitLab CI YAML). GitLab CI YAML files are expected to follow the naming convention *.gitlab-ci.yml.

This job will not handle !reference tags in GitLab CI YAML, so on the off chance that that feature is used in a before_script, after_script, or script, the results won’t be correct.

Happy linting!

CC BY-SA 4.0 Shellcheck Scripts Embedded in GitLab CI YAML by Craig Andrews is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.

6 thoughts on “Shellcheck Scripts Embedded in GitLab CI YAML

  1. Hi Craig Andrews, is there any commands available to implement linting on shell-script present in yaml file using shellcheck and yq, rather than this CI job.

    1. The commands to implement linting on shell script present in GitLab CI yaml files using shellcheck and yq are in the job, here they are:

      newline="$(printf '\nq')"
      newline=${newline%q}
      git ls-files --exclude='*.gitlab-ci.yml' --ignored -c | while IFS= read -r file; do
        yq eval '.[] | select(tag=="!!map") | (.before_script,.script,.after_script) | select(. != null ) | path | ".[\"" + join("\"].[\"") + "\"]"' "${file}" | while IFS= read -r selector; do
          script=$(yq eval "${selector} | join(\"${newline}\")" "${file}")
          if ! printf '%s' "${script}" | shellcheck -; then
        >&2 printf "\nError in %s in the script specified in %s:\n%s\n" "${file}" "${selector}" "${script}"
        exit 1
          fi
        done
      done
      
  2. Hi Craig,
    Thanks for your inspiring post.

    Unfortunately, try as I might, I have not been able to get this line to work even when invoked on the command line independently of GitLab and your full script:

    yq eval ‘.[] | select(tag==”!!map”) | (.before_script,.script,.after_script) | select(. != null ) | path | “.[\”” + join(“\”].[\””) + “\”]”‘ “${file}”

    I cannot find any reference to `select(tag==”!!map”)` anywhere so I cannot figure out what it is doing, and `path` also seems to not be correct, at least not for jq. jq has `paths` and `path(.)` but not `path` AFAICT.

    Is there any chance this link managed to get typos into it when you published? Would you mind testing this again, and if it works as-is may I ask you to explain those two aspects?

    Thank you very much in advance.

    1. There are two yq projects unfortunately – I suspect you’re looking at the wrong one. Also, it’s yq (which is for YAML) and not jq (which is for JSON). Here’s the documentation for map in the yq used in this post: https://mikefarah.gitbook.io/yq/operators/map

      You can see a working example here:
      https://gitlab.com/candrews/jumpstart/-/blob/6c9fa0b5c6c8805aeebf964bf9be9c4342e0650a/.gitlab-ci.yml#L26

      And here’s the GitHub Actions version:
      https://github.com/candrews/jumpstart/blob/6c9fa0b5c6c8805aeebf964bf9be9c4342e0650a/.github/workflows/build.yml#L17

      1. Thanks for the quick reply, and thanks for the details.

        Indeed, I am using the other one, the one installed by NixShell, which is the one I have hassle-free access to. That one is a wrapper around `jq` which is why I mentioned `jq`.

        I did see the docs for those `map` docs — though I wasn’t sure which `yq` you were using — but I didn’t see anything explaining the double exclamations you used in `!!map`.

        Anyway, at this point I think I am going to just write the logic in Go rather than waste more time trying to get this Bash script to behave. Still, thanks for showing me how to get started with spellcheck for GitLab; that definitely saved me from having to trial-and-error it.

  3. Piping into while read without checking for empty is risky behavior, bash will grab the outer context. I once found passwords this way. It depends on a lot of factors, but I’d avoid it.

    Use xargs or check that the result is not null before running while read on it.

    For a fast git command like yours this might be enough:

    [ $(git ls-files -c | wc -l) -gt 0] && git ls-files -c | while read etc.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.