Passing values between steps in Github Actions

We’ve contributed the setup-terminus action to Pantheon as their official method for installing Terminus in GitHub Actions, so you should now switch to using pantheon-systems/terminus-github-actions!

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate running tasks using your code, such as compiling binaries, running test suites, and deploying changes.

Sometimes, you might find yourself wanting to pass values between these steps – this can be particularly useful for composite actions, where you need to compute a default value for an optional input.

This was something I recently needed to do, and decided to write up a post showcasing how I accomplished it.

If you just want to know the “how”, the tl;dr is you can pass values between steps by writing them as <key>=<value> pairs to $GITHUB_ENV

The Backstory

I recently created a composite Github Action called ackama/setup-terminus (which lives here) which (as you might have guessed) handles setting up the latest version of the Terminus CLI for interacting with Pantheon.

The action is pretty straight forward, being made up of two steps:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
runs:
  using: composite
  steps:
    - name: Install Terminus
      shell: bash
      run: |
        mkdir ~/terminus && cd ~/terminus
        TERMINUS_RELEASE=$(curl --silent "https://api.github.com/repos/pantheon-systems/terminus/releases/latest" | perl -nle'print $& while m#"tag_name": "\K[^"]*#g')
        curl -L https://github.com/pantheon-systems/terminus/releases/download/$TERMINUS_RELEASE/terminus.phar --output terminus
        chmod +x terminus
        sudo ln -s ~/terminus/terminus /usr/local/bin/terminus

    - name: Login to Pantheon
      shell: bash
      run: |
        terminus auth:login --machine-token="${{ inputs.pantheon-machine-token }}"

For brevity, I’m going to omit the “Login to Pantheon” step in future snippets, since it doesn’t change

To keep things simple initially, I used the steps provided by Pantheon for installing Terminus as a standalone phar (which means you only require PHP to be able to run it).

While pretty straightforward and robust, it works by grabbing the latest release of the CLI from it’s home on GitHub, and I knew that we were still using the previous major version in some of our workflows so wanted to explore being able to pass the action a specific version as an optional input.

I determined that to support this, the action would have to do the following:

  1. Accept an optional terminus-version input
  2. Install the version of Terminus set by terminus-version, if present
  3. Fallback to installing the latest version of Terminus, if terminus-version isn’t set

Together, these would allow developers to provide the action with a terminus-version to have that version installed:

name: Install Terminus

on:
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '7.4'

      - name: Install Terminus
        uses: ackama/setup-terminus@main
        with:
          pantheon-machine-token: ${{ secrets.PANTHEON_MACHINE_TOKEN }}
          terminus-version: 2.6.5
      - run: |
          terminus --version

1. Accept an optional terminus-version input

This was very easy to do, as the action already takes a required input so it was just a matter of copying that and setting required to false:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
runs:
  using: composite
  steps:
    # ...

While inputs also support a default value, it requires a static string meaning we can’t leverage that to fallback to grabbing the latest version of Terminus. We’ll touch on that later.

2. Install the version of Terminus set by terminus-version, if present

Now that I had the terminus-version input, I wanted to use that in the install step which we can do with an expression that accesses the inputs context to assign the value of the terminus-version input to the TERMINUS_RELEASE variable we’re already using:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
runs:
  using: composite
  steps:
    - name: Install Terminus
      shell: bash
      run: |
        mkdir ~/terminus && cd ~/terminus
        TERMINUS_RELEASE="${{ inputs.terminus-version }}"
        curl -L https://github.com/pantheon-systems/terminus/releases/download/$TERMINUS_RELEASE/terminus.phar --output terminus
        chmod +x terminus
        sudo ln -s ~/terminus/terminus /usr/local/bin/terminus
 
  # ...

If this wasn’t a composite action, the input value would also be available as an environment variable called INPUT_TERMINUS_VERSION, which we could use instead of an expression.

This worked, but left me with an issue: terminus-version is an optional input, so it might not have a value!

I could have fixed this by using setting a default value on the input, like so:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
    default: 3.0.5
runs:
  using: composite
  steps:
    # ...

The issue with this is that it might not be the latest version, so we’d have to update our action every time a new version of Terminus came out if we wanted to default to installing the latest version.

Unfortunately, default only accepts static strings, so I couldn’t just stick the subshell command we were using earlier to grab the latest version…

3. Fallback to installing the latest version of Terminus, if terminus-version isn’t set

This is where the title of this post comes into play – I could have solved this with more Bash script, but that’d be adding more complexity to the install step which would make it harder to follow and more prone to bugs (especially since tools like ShellCheck don’t support checking bash inlined within YAML).

I could have put the script into its own file, which would allow native tools to be used, but it felt excessive to have a whole extra file given the size of the codebase

Instead, I wanted to see if you could have a step output a value that another step could use and it turns out you can! Pretty easily in fact – GitHub Actions provides an environment variable called GITHUB_ENV which points to a file that is unique for each step in a job, and that sets environment variables within the workflow based on its contents (which should be in <key>=<value> form).

Since it’s a file, we can add to it using the “redirect and append” operator (>>) in Bash:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  # ...
runs:
  using: composite
  steps:
    - name: Determine version
      shell: bash
      run: |
        TERMINUS_RELEASE=$(curl --silent "https://api.github.com/repos/pantheon-systems/terminus/releases/latest" | perl -nle'print $& while m#"tag_name": "\K[^"]*#g')
        echo "TERMINUS_RELEASE=$TERMINUS_RELEASE" >> $GITHUB_ENV

    # ...

This creates an environment variable called TERMINUS_RELEASE that is accessible by any step after this one either directly within a script ($TERMINUS_RELEASE) or within an expression via the env context (${{ env.TERMINUS_RELEASE }}).

Now that I had the latest version of Terminus set dynamically and available to other steps, it was just a matter of updating the install step to use that version if the terminus-version input isn’t provided:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
runs:
  using: composite
  steps:
    - name: Determine version
      shell: bash
      run: |
        TERMINUS_RELEASE=$(curl --silent "https://api.github.com/repos/pantheon-systems/terminus/releases/latest" | perl -nle'print $& while m#"tag_name": "\K[^"]*#g')
        echo "TERMINUS_RELEASE=$TERMINUS_RELEASE" >> $GITHUB_ENV

    - name: Install Terminus
      shell: bash
      run: |
        mkdir ~/terminus && cd ~/terminus
        curl -L https://github.com/pantheon-systems/terminus/releases/download/$TERMINUS_RELEASE/terminus.phar --output terminus
        chmod +x terminus
        sudo ln -s ~/terminus/terminus /usr/local/bin/terminus
      env:
        TERMINUS_RELEASE: ${{ inputs.terminus-version || env.TERMINUS_RELEASE }}

    # ...

To help keep things clean, I decided to handle setting the TERMINUS_RELEASE variable using the env map rather than have the expression embedded with the rest of the script, where it would have been harder to spot.

4. Bonus optimisation: only retrieve the latest release if we expect to use it

While the above works well, we actually don’t always need to know what the latest terminus release is – luckily steps in GitHub Actions support an if property that can be used to have a step run conditionally, based on if the property value is true.

I used this to skip the determine version step if the terminus-value input is provided:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
runs:
  using: composite
  steps:
    - name: Determine version
      shell: bash
      if: ${{ ! inputs.terminus-version }}
      run: |
        TERMINUS_RELEASE=$(curl --silent "https://api.github.com/repos/pantheon-systems/terminus/releases/latest" | perl -nle'print $& while m#"tag_name": "\K[^"]*#g')
        echo "TERMINUS_RELEASE=$TERMINUS_RELEASE" >> $GITHUB_ENV

  # ...

All together now

This is what the action now looks like:

name: Setup Terminus
description: 'Installs and configures the Pantheon CLI tool, Terminus.'
inputs:
  pantheon-machine-token:
    description: 'Machine token used to authenticate with Pantheon.'
    required: true
  terminus-version:
    description: |
      The full version of Terminus to install. If omitted, the latest version is used.
    required: false
runs:
  using: composite
  steps:
    - name: Determine version
      shell: bash
      if: ${{ ! inputs.terminus-version }}
      run: |
        TERMINUS_RELEASE=$(curl --silent "https://api.github.com/repos/pantheon-systems/terminus/releases/latest" | perl -nle'print $& while m#"tag_name": "\K[^"]*#g')
        echo "TERMINUS_RELEASE=$TERMINUS_RELEASE" >> $GITHUB_ENV

    - name: Install Terminus
      shell: bash
      run: |
        mkdir ~/terminus && cd ~/terminus
        echo "Installing Terminus v$TERMINUS_RELEASE"
        curl -L https://github.com/pantheon-systems/terminus/releases/download/$TERMINUS_RELEASE/terminus.phar --output terminus
        chmod +x terminus
        sudo ln -s ~/terminus/terminus /usr/local/bin/terminus
      env:
        TERMINUS_RELEASE: ${{ inputs.terminus-version || env.TERMINUS_RELEASE }}

    - name: Login to Pantheon
      shell: bash
      run: |
        terminus auth:login --machine-token="${{ inputs.pantheon-machine-token }}"