GitLab CI’s script keyword can handle multiple lines of shell commands, but how you format them determines whether they run sequentially as expected or as a single, potentially broken, command.

Let’s see it in action. Imagine you have a simple CI job that checks out code, installs dependencies, and then runs tests. Here’s how you’d write that:

test_job:
  stage: test
  script:
    - git checkout main
    - npm install
    - npm test

This looks straightforward, and it is. Each line starting with a hyphen (- ) is treated as a separate command executed in order. The shell interprets each of these as if you typed them on a single line in your terminal, with a newline character between them.

Now, let’s consider a common pitfall. What if you want to group commands or use shell features like pipes or logical operators? This is where indentation and proper YAML sequencing become critical. If you indent your script lines incorrectly or try to force a multi-line command without the right YAML syntax, you’ll run into trouble.

Consider this incorrect example:

test_job:
  stage: test
  script:
    - echo "Starting build..."
    - if [ "$CI_COMMIT_BRANCH" == "main" ]; then
    -   echo "This is the main branch."
    - else
    -   echo "This is not the main branch."
    - fi
    - echo "Build finished."

In this scenario, GitLab CI will likely try to execute each line starting with - as a separate command. The if [ "$CI_COMMIT_BRANCH" == "main" ]; then will run, but the subsequent lines won’t be interpreted as part of that if statement. Instead, echo "This is the main branch." would be a new, independent command, and the shell would likely error out because it expects a closing fi for the first if, and the subsequent else and fi are orphaned.

The correct way to handle multi-line shell constructs within a GitLab CI script is to use YAML’s literal block scalar (|) or folded block scalar (>). The literal block scalar is generally preferred for scripts as it preserves newlines and indentation exactly as written.

Here’s the corrected version of the previous example using the literal block scalar:

test_job:
  stage: test
  script: |
    echo "Starting build..."
    if [ "$CI_COMMIT_BRANCH" == "main" ]; then
      echo "This is the main branch."
    else
      echo "This is not the main branch."
    fi
    echo "Build finished."

In this corrected version, the entire block of text following script: | is treated as a single multi-line string. GitLab CI passes this entire string to the shell as a single script to execute. The shell then interprets the newlines and indentation within that string, correctly executing the if/else/fi block as intended.

The key difference is how the YAML parser and the CI runner interpret the script content. When you use - for each line, you’re telling the CI runner to execute each item in the list as a separate command. When you use |, you’re telling the CI runner to treat everything after it as a single, verbatim string to be fed to the shell.

This distinction is crucial for more complex scenarios, such as:

  • Piping commands: command1 | command2
  • Logical operations: command1 && command2 or command1 || command2
  • Complex shell logic: Loops, case statements, function definitions.
  • Heredocs: command <<< "some string" or command << EOF ... EOF

For instance, if you needed to pipe the output of one command to another, you’d use the literal block scalar:

analyze_logs:
  stage: analyze
  script: |
    cat server.log | grep "ERROR" | sort > errors.txt
    echo "Found errors:"
    cat errors.txt

This ensures that cat server.log | grep "ERROR" | sort is executed as a single shell command, with the output being redirected to errors.txt. If you tried to break this into separate - lines, the pipe (|) would be interpreted as a literal pipe character within a command that doesn’t expect it, leading to errors.

The folded block scalar (>) also treats its content as a single string but folds newlines into spaces, which is useful for long lines of text that you want to appear as a single paragraph, but it’s generally not suitable for shell scripts where newlines define command boundaries.

The most surprising true thing about GitLab CI’s script keyword is that the way you indent and delineate your commands directly influences whether the underlying shell sees them as individual instructions or as a single, cohesive program. This is less about GitLab CI’s syntax and more about how YAML parsers handle multi-line strings and how shells interpret script execution.

The next concept you’ll likely grapple with is managing and debugging these multi-line scripts effectively, especially when they start behaving unexpectedly across different runner environments.

Want structured learning?

Take the full Gitlab-ci course →