How to Patch the Deprecated set-output in GitHub Workflows and in Container Actions
Tags: github, python, java, tutorial
Published: 2022-10-26 15:17:03

__TL;DR:__ There is a new way for GitHub Container Actions, as well as workflow steps to produce outputs. This post explains how to patch container actions, including example Python functions, while maintaining backwards compatibility for non-upgraded self-hosted runners. Post also explains the new approach to workflow step outputs, using a workflow for a Java library deployment as an example.

__Table of Contents:__ This post is organized as follows:
* [Introduction](#introduction)
* [Updated Workflow Step Outputs](#updated-workflow-step-outputs)
  * [Old Way](#old-way)
  * [New Way](#new-way)
* [Example: Version Number for a Java Library Deployment](#example-version-number-for-a-java-library-deployment)
* [Outputs from a Container Action Implemented in Python](#outputs-from-a-container-action-implemented-in-python)
  * [Case 1: One Output](#case-1-one-output)
  * [Case 2: Multiple Outputs](#case-2-multiple-outputs)
  * [Case 3: Multiple Outputs for a GitHub Action Also Usable as a CLI Tool](#case-3-multiple-outputs-for-a-github-action-also-usable-as-a-cli-tool)
* [How to Enable Backwards Compatibility for Self-Hosted Runners](#how-to-enable-backwards-compatibility-for-selfhosted-runners)
* [Repositories Referenced in Examples](#repositories-referenced-in-examples)
* [Where You Can Find Me](#where-you-can-find-me)

## Introduction

Within the last week or so, I started noticing deprecation warnings in the logs of my GitHub Actions workflow runs. At first, I assumed it was just a couple of my repositories, perhaps due to an action I was using. When I got around to investigating, I discovered the issue, which affected 21 repositories in different ways. [GitHub recently deprecated the `set-output` workflow command](https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/) on October 11, which had been the way for workflow steps, as well as for [container actions](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action), to produce outputs that could be consumed by later steps of a workflow. That command will no longer work by the end of May 2023.

I use GitHub Actions to automate a variety of things in nearly all of my repositories, such as running a build and tests during pull-requests and pushes, deploying artifacts to Maven Central, etc for my Java libraries, or to PyPI for a couple Python projects, building my personal website with my custom static site generator, among a variety of other tasks. In addition to using GitHub Actions for workflow automation, I also develop and maintain a few Actions (all implemented in Python), including [jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator), [user-statistician](https://github.com/cicirello/user-statistician), [javadoc-cleanup](https://github.com/cicirello/javadoc-cleanup), and [generate-sitemap](https://github.com/cicirello/generate-sitemap). 

All five GitHub Actions that I maintain were using the `set-output` workflow command, mainly for reporting status, which means that all users of those Actions would have begun to see these deprecation notices (if they were to inspect the logs of their workflow runs). And one of those Actions has almost 1000 users ([jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator)). And in another 16 of my repositories, I was using the `set-output` workflow command in workflows as a way of passing information from one step to later steps. For example, in the deployment workflows for my Java libraries, I have a step that extracts the version from the release tag, setting an output with the version number that is then accessed by subsequent steps.

I patched all of these last week, starting with the five GitHub Actions that I maintain since those affect others. In this post, I explain my approach, including how I've maintained backwards compatibility for users of my actions that use self-hosted runners that still require `set-output` for action outputs. For the cases where I was using `set-output` in workflows, the fix is exactly as indicated in [GitHub's blog post announcing the deprecation](https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/). However, I had to adapt this a bit for the five Actions that I maintain. The updated examples in the [container action documentation](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action) assumes a shell script, but these actions are implemented in Python. Later in this post, I also include Python functions that implement GitHub's replacement for the deprecated `set-output`. The first version of these strictly replaces the deprecated command. But then later I show my trick to maintain backwards compatibility.

## Updated Workflow Step Outputs

I'm going to begin with the simpler case of patching workflows since that applies to more people, essentially anyone that uses GitHub Actions regardless of whether or not they develop and maintain any Actions. If you are more interested in how to patch the deprecated command in container actions that you maintain, jump to the section: [Outputs from a Container Action Implemented in Python](#outputs-from-a-container-action-implemented-in-python). 

A step of a job in a GitHub Actions workflow can produce outputs that can later be consumed by other steps in the workflow.

### Old Way

The old way that is now deprecated involved doing something like (this first one is almost directly from GitHub's blog post, but I've added a step id, which is needed to actually use the output later):

```YAML
    - name: Set output
      id: stepid
      run: echo "::set-output name={name}::{value}"
```

A more specific example is the following which would set an output named `count` to `5`:

```YAML
    - name: Set output
      id: stepid
      run: echo "::set-output name=count::5"
```

Later steps of the workflow can then access the value of the output, provided the step has an `id` as above, with the following:

```YAML
    - name: Use prior output
      run: echo "The count was ${{ steps.stepid.outputs.count }}"
```

### New Way

The replacement for the old `set-output` workflow command involves appending to a file whose path is specified in an environment variable `$GITHUB_OUTPUT`. The earlier example of setting an output named `count` to `5` becomes the following:

```YAML
    - name: Set output
      id: stepid
      run: echo "count=5" >> $GITHUB_OUTPUT
```

Later steps access it in the same way as before with:

```YAML
    - name: Use prior output
      run: echo "The count was ${{ steps.stepid.outputs.count }}"
```

## Example: Version Number for a Java Library Deployment

In my deployment workflows for my Java libraries, I previously had a step that generated a Maven version string from the release tag by removing the `v` from the release tag. That step looked like the following:

```YAML
    - name: Get the release version
      id: get_version
      run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
```

To deal with the deprecation of `set-output`, I've updated it to the following:

```YAML
    - name: Get the release version
      id: get_version
      run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
```

What do I use this for? Well there are a couple steps later in the workflow that use it. For example, one of the steps prior to the actual deployment injects the version for the release into the Maven `pom.xml` with:

```YAML
    - name: Update package version
      run: mvn versions:set -DnewVersion=${{ steps.get_version.outputs.VERSION }}
```

And near the end of the workflow, after the actual deployment to Maven Central and GitHub Packages, I use the GitHub CLI to attach the jar files to the GitHub Release. Having the version available in this way makes it easy to determine the filename of the jar:

```YAML
    - name: Upload jar files to release as release assets
      run: |
        TAG=${GITHUB_REF/refs\/tags\//}
        gh release upload ${TAG} target/${{ env.artifact_name }}-${{ steps.get_version.outputs.VERSION }}.jar
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

The complete workflow file that this example is derived from is [maven-publish.yml](https://github.com/cicirello/Chips-n-Salsa/blob/master/.github/workflows/maven-publish.yml).


## Outputs from a Container Action Implemented in Python

There are two primary ways of implementing a GitHub Action: JavaScript Actions and [Container Actions](https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action). The latter of which enables implementing Actions in any language via a Docker container. My language of choice for implementing GitHub Actions is Python. The purpose of most of these actions is to produce files (e.g., [jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator) produces test coverage badges as SVGs, and [generate-sitemap](https://github.com/cicirello/generate-sitemap) produces an XML sitemap) or to edit files in some way (e.g., [javadoc-cleanup](https://github.com/cicirello/javadoc-cleanup) can insert canonical links and other user-defined elements into the head of javadoc pages). However, all of these also produce workflow step outputs. For example, [generate-sitemap](https://github.com/cicirello/generate-sitemap) has outputs for the number of pages in the sitemap, and the number of pages excluded from the sitemap due to `noindex` or `robots.txt` exclusions; and [jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator) has workflow step outputs for the coverage and branches coverage percentages if a user had some reason to use those in later steps of their workflow.

Here are three variations depending upon the specifics of what you want to do. All three require that we've imported `os` with the following so that we can access environment variables:

```Python
import os
```

### Case 1: One Output

The simplest case is if you have a single output for your Action. The following Python function accomplishes this. Notice that we'll need to open the relevant file for appending. If the Action is run outside of GitHub Actions, this function will do nothing since the relevant environment variable with the path to the relevant file for the outputs won't exist in that case. 

```Python
def set_action_output(output_name, value) :
    """Sets the GitHub Action output.

    Keyword arguments:
    output_name - The name of the output
    value - The value of the output
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            print("{0}={1}".format(output_name, value), file=f)
```

### Case 2: Multiple Outputs

A couple of my GitHub Actions produce multiple outputs. Although I could use the above function and just call it multiple times, I instead use the following to avoid unnecessarily opening the environment file multiple times. In this version, the function is passed a Python dictionary with the names of the outputs as keys, along with the corresponding values.

```Python
def set_action_outputs(output_pairs) :
    """Sets the GitHub Action outputs.

    Keyword arguments:
    output_pairs - Dictionary of outputs with values
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            for key, value in output_pairs.items() :
                print("{0}={1}".format(key, value), file=f)
```

### Case 3: Multiple Outputs for a GitHub Action Also Usable as a CLI Tool

One of the GitHub Actions that I maintain, the [jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator), can also be used as a CLI tool outside of the Actions framework. Before GitHub deprecated the `set-output`, the CLI mode would output the coverage percentages to the console in addition to generating the badges. That prior behavior was really a side-effect of GitHub's old approach to Action outputs (e.g., printing a specially formatted message to standard out). In revising to address the deprecation, I decided to keep a variation of that behavior. If running as a GitHub Action, the outputs are set via an approach much like the above example. But if running in CLI mode, it instead simply outputs them to the console. It uses a simple check of whether an environment variable named `GITHUB_OUTPUT` exists to detect whether running as an Action.

Below is my first attempt at accomplishing this. 

```Python
def set_action_outputs(output_pairs) :
    """Sets the GitHub Action outputs if running as a GitHub Action,
    and otherwise logs these to terminal if running in CLI mode. Note 
    that if the CLI mode is used within a GitHub Actions
    workflow, it will be treated the same as GitHub Actions mode.

    Keyword arguments:
    output_pairs - Dictionary of outputs with values
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            for key, value in output_pairs.items() :
                print("{0}={1}".format(key, value), file=f)
    else :
        for key, value in output_pairs.items() :
            print("{0}={1}".format(key, value))
```

The above seemed to work fine until I discovered via a report from a user of the Action that some self-hosted runners may not support the new approach to outputs yet. I should have noticed this since GitHub's blog post about the deprecation does explicitly state the following: "If you are using self-hosted runners make sure they are updated to version 2.297.0 or greater." In the next section, I explain my trick to maintain backwards compatibility for these users. 


## How to Enable Backwards Compatibility for Self-Hosted Runners

In the above section, you saw my initial fix. Those Python functions will suffice if you only want to support those using your Actions directly on GitHub. If they are using self-hosted runners, then whether the above will work depends upon whether your users have upgraded their runners to a version with the new environment file. If you want to avoid breaking things for those users of your Actions who haven't upgraded their runners, one option might be to delay patching and just deal with deprecation warnings. However, it is actually relatively straightforward to include backwards compatibility to support self-hosted runners.

Here is the approach I've ended up with for the [jacoco-badge-generator](https://github.com/cicirello/jacoco-badge-generator). I really have three cases to support: (1) GitHub Actions with the new `GITHUB_OUTPUT` environment file (when running on GitHub or newer self-hosted runners), (2) GitHub Actions on not-yet-upgraded self-hosted runners, and (3) those using the CLI mode of the utility such as on their local system. I added a parameter to my `set_action_outputs` function to specify if in GitHub Actions mode, and then check for the `GITHUB_OUTPUT` environment variable to determine support for the new approach. It falls back to the deprecated `set-output` workflow command if `GITHUB_OUTPUT` doesn't exist.

```Python
def set_action_outputs(output_pairs, gh_actions_mode) :
    """Sets the GitHub Action outputs if running as a GitHub Action,
    and otherwise logs these to terminal if running in CLI mode. Note 
    that if the CLI mode is used within a GitHub Actions
    workflow, it will be treated the same as GitHub Actions mode.

    Keyword arguments:
    output_pairs - Dictionary of outputs with values
    gh_actions_mode - True if running as a GitHub Action, otherwise pass False 
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            for key, value in output_pairs.items() :
                print("{0}={1}".format(key, value), file=f)
    else :
        output_template = "::set-output name={0}::{1}" if gh_actions_mode else "{0}={1}"
        for key, value in output_pairs.items() :
            print(output_template.format(key, value))
```

If your GitHub Action is strictly a GitHub Action, then you can simplify the above. First, you won't need the parameter `gh_actions_mode`. Simply assume that it is running within GitHub Actions, and use the existence of `GITHUB_OUTPUT` to check if the new approach is supported on the runner. Fall-back to `set-output` otherwise.

```Python
def set_action_outputs(output_pairs) :
    """Sets the GitHub Action outputs, with backwards compatibility for
    self-hosted runners without a GITHUB_OUTPUT environment file.

    Keyword arguments:
    output_pairs - Dictionary of outputs with values
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            for key, value in output_pairs.items() :
                print("{0}={1}".format(key, value), file=f)
    else :
        for key, value in output_pairs.items() :
            print("::set-output name={0}::{1}".format(key, value))
```

And if your Action only has a single output, you can use something like:

```Python
def set_action_output(output_name, value) :
    """Sets the GitHub Action output, with backwards compatibility for
    self-hosted runners without a GITHUB_OUTPUT environment file.

    Keyword arguments:
    output_name - The name of the output
    value - The value of the output
    """
    if "GITHUB_OUTPUT" in os.environ :
        with open(os.environ["GITHUB_OUTPUT"], "a") as f :
            print("{0}={1}".format(output_name, value), file=f)
    else :
        print("::set-output name={0}::{1}".format(output_name, value))
```

## Repositories Referenced in Examples

The Python functions implementing the new approach to GitHub Action outputs, along with backwards compatible support for old runners, from this post are derived from a few different GitHub Actions that I maintain. The most elaborate example for the case of an Action that is designed to also be used outside of Actions as a CLI tool is from the following:

{% github cicirello/jacoco-badge-generator %}

The workflow examples where I use step outputs to pass the release version to later steps is a technique I use in several Java library repositories. The one I specifically referenced in this post is the following (if you'd like to see the full workflow):

{% github cicirello/Chips-n-Salsa %}

## Where You Can Find Me

Follow me here on DEV:

{% user cicirello %}

Follow me on GitHub:

{% github cicirello/cicirello %}

Or visit my website:

{% embed https://www.cicirello.org/ %}