Orchestrating Post-Run Execution in GitHub Actions

The architecture of GitHub Actions is designed around a sequence of steps that execute linearly within a job. While standard steps fail immediately upon encountering an error—unless specifically configured otherwise—there is a critical operational requirement for "post-run" logic. Post-run steps are those that must execute after the primary logic of a job has concluded, regardless of whether the job succeeded or failed. This is essential for cleanup tasks, sending notifications, uploading logs, or resetting environment states. However, the native implementation of post-execution logic varies wildly depending on the type of action being used, creating a complex landscape for DevOps engineers.

The fundamental challenge lies in the distinction between JavaScript actions, Docker actions, and Composite actions. JavaScript and Docker actions have native support for a post hook defined in their metadata, allowing the GitHub runner to call a specific entry point after all other steps have finished. In contrast, Composite actions are essentially a collection of shell commands; they lack a native post attribute in their action.yml syntax. This limitation forces developers to seek third-party solutions or implement complex workarounds to ensure a piece of code runs at the end of a workflow.

The Mechanics of Third-Party Post-Run Actions

Because Composite actions do not natively support post-run steps, the community has developed specialized actions to bridge this gap. These tools generally work by registering a command in the GitHub state or using internal runner mechanisms to ensure a specific script is triggered during the post-phase of a job.

Analysis of webiny/action-post-run

The webiny/action-post-run action, specifically version 3.1.0, provides a mechanism to schedule commands that will execute once the workflow job has ended. This is particularly useful for tasks that must be decoupled from the immediate success or failure of the step that defines them.

The configuration requires a command to be passed via the run input. If no command is provided, it defaults to echo "This is a post-run step...".

The implementation allows for multiple post-run commands within a single job. However, a critical behavioral characteristic of this action is the order of execution. When multiple webiny/action-post-run steps are declared, they execute in reverse order (Last-In, First-Out). For example, if a developer defines a step with the ID post-run-command and a subsequent step with the ID another-post-run-command, the command associated with another-post-run-command will execute before post-run-command.

The action also supports a working-directory input, allowing the post-run command to be executed from a specific path in the file system, although this is listed as not required.

Example configuration for webiny/[email protected]:

yaml name: Build on: push: branches: [ master ] env: GH_TOKEN: ${{ secrets.GH_TOKEN }} jobs: something: name: Do something... runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: webiny/[email protected] id: post-run-command with: run: echo "this thing works!" - uses: webiny/[email protected] id: another-post-run-command with: run: echo "this thing works again!" working-directory: not-required-but-you-can-provide-it - name: 'Running an non-existing command will fail...' run: run something that does not exist;

Analysis of gacts/run-and-post-run

The gacts/run-and-post-run action offers a more integrated approach by allowing the user to define both an immediate command and a post-run command within the same step. This reduces the number of steps required in a YAML file and clarifies the relationship between the main task and its corresponding cleanup or reporting task.

This action provides a comprehensive set of inputs to control execution:

Name Type Default Required Description
run string or list no A commands that needs to be run in place
post string or list yes A commands that needs to be run once a workflow job has ended
working-directory string no A working directory from which the command needs to be run
shell string bash no A shell to use for executing run commands
post-shell string no A shell to use for executing post commands. Defaults to value of shell

The post input is mandatory, ensuring that the action always has a cleanup task to perform. The ability to pass a list or a multiline string allows for complex shell scripts to be embedded directly in the workflow file.

Example of a multiline post-run configuration:

yaml - name: Run this action uses: gacts/run-and-post-run@v1 with: run: | echo "First" post: | echo "First post" echo "(can run multiply commands)" ls -la /tmp /bin /opt

Analysis of srz-zumix/post-run-action

The srz-zumix/post-run-action at version 3 is specifically designed to be used within composite actions that cannot perform post-processing on their own. A standout feature of this action is its handling of expression evaluation.

In standard GitHub Action steps, expressions using the ${{ }} syntax are evaluated when the step is first processed. However, srz-zumix/post-run-action evaluates the post-run input during the post-phase (when the script actually runs), not when the step is defined.

This creates a significant impact on environment variable management. If a variable is modified in a step that occurs after the post-run-action is declared, the post-run script will capture the updated value.

For instance, if MY_VAR is initialized as initial_value, and a later step executes echo "MY_VAR=modified_value" >> "$GITHUB_ENV", the post-run script will output modified_value when using both the expression syntax ${{ env.MY_VAR }} and the shell variable syntax $MY_VAR.

This action also supports custom shells via the shell input, allowing for execution in python or specific bash configurations like bash -ex {0}.

Example of dynamic variable evaluation:

yaml env: MY_VAR: initial_value steps: - uses: srz-zumix/post-run-action@v3 with: post-run: | echo "Expression value: ${{ env.MY_VAR }}" echo "Environment value: $MY_VAR" - run: echo "MY_VAR=modified_value" >> "$GITHUB_ENV"

Comparative Technical Specifications of Post-Run Tools

The following table delineates the operational differences between the three primary third-party post-run implementations.

Feature webiny/action-post-run gacts/run-and-post-run srz-zumix/post-run-action
Primary Purpose Pure Post-Run Combined Run + Post Composite Action Support
Execution Order LIFO (Reverse) Standard Post-Phase
Mandatory Inputs run (with default) post post-run
Dynamic Expression Eval Not Specified Not Specified Yes (at runtime)
Custom Shell Support No Yes (run and post shells) Yes (including Python)
Working Directory Supported Supported Not Specified

Implementing Post-Steps via JavaScript in Composite Actions

As identified in community discussions and technical documentation, Composite actions are fundamentally limited because they are just a combination of run steps. They do not support the runs.steps.if conditional for post-steps, nor do they have a native post attribute.

To circumvent this, a developer can use a Node.js-based action to indirectly perform post-steps. This involves creating a small JavaScript wrapper that interacts with the GITHUB_STATE file.

The Logic of the Node.js Wrapper

The wrapper works by checking for a specific state variable. If the environment variable STATE_POST (or a custom key) is present, the script knows it is currently in the post-execution phase of the job. If the variable is absent, it is in the main execution phase.

The implementation involves two primary paths in the main.js file:

  1. Main Phase: The script executes the primary command and writes a marker to the GITHUB_STATE file.
  2. Post Phase: When the GitHub runner identifies a state file, it re-runs the action's post entry point. The script detects the state marker and executes the cleanup command.

The following is the structural implementation of such a wrapper:

action.yml
yaml name: With post step description: "Generic JS Action to execute a main command and set a command as a post step." inputs: main: description: "Main command/script." required: true post: description: "Post command/script." required: true key: description: "Name of the state variable used to detect the post step." required: false default: POST runs: using: "node16" main: "main.js" post: "main.js"

main.js
```javascript
const { spawn } = require("child_process");
const { appendFileSync } = require("fs");
const { EOL } = require("os");

function run(cmd) {
const subprocess = spawn(cmd, { stdio: "inherit", shell: true });
subprocess.on("exit", (exitCode) => {
process.exitCode = exitCode;
});
}

const key = process.env.INPUTKEY.toUpperCase();
if ( process.env[STATE_${key}] !== undefined ) {
run(process.env.INPUT
POST);
} else {
appendFileSync(process.env.GITHUBSTATE, ${key}=true${EOL});
run(process.env.INPUT
MAIN);
}
```

This approach allows a Composite action to effectively "mimic" a JavaScript action's ability to run post-steps, ensuring that the post command executes even if the main command fails.

Distribution and Maintenance of Post-Run Actions

For developers maintaining these types of actions, the release process is critical to ensure stability and versioning.

The general workflow for releasing a new version of a post-run action involves:

  • Building the distribution: This is typically done via make build or npm run build.
  • Committing the dist directory: It is essential that the compiled JavaScript files in the dist directory are committed to the master or main branch, as the GitHub runner often relies on these for performance.
  • Publishing releases: Tags must follow the vX.Y.Z semantic versioning format. Major and minor tags (e.g., v1 or v1.2) are typically updated automatically to point to the latest patch release.

Users are encouraged to use Dependabot to maintain these actions, ensuring they receive updates for security and compatibility.

Critical Analysis of Post-Run Strategies

The choice between these methods depends on the specific requirement for state persistence and failure handling.

Using a pure post-run action like webiny/action-post-run is ideal for simple, decoupled cleanup tasks where the order of execution is not a primary concern or where LIFO execution is actually preferred. However, the reverse execution order can lead to bugs if post-run steps depend on each other.

The gacts/run-and-post-run approach is superior for readability and cohesion. By grouping the "do" and "undo" logic in a single step, the YAML file becomes a more accurate map of the process. This reduces the risk of a developer adding a main step but forgetting to add the corresponding cleanup step elsewhere in the job.

The srz-zumix/post-run-action is the most powerful for dynamic workflows. Because it evaluates expressions at the time of execution, it allows for "late binding" of variables. This is critical in CI/CD pipelines where a post-run step needs to know the final status of a build or a dynamically generated URL that was only created halfway through the job.

Finally, the Node.js wrapper method is the only viable path for those building complex Composite actions that must provide a seamless experience for other users. It solves the fundamental limitation of the Composite syntax by leveraging the JavaScript action's native post hook.

Sources

  1. webiny/action-post-run
  2. gacts/run-and-post-run
  3. srz-zumix/post-run-action
  4. abiwinanda/github-action-adding-post-steps-in-composite-actions
  5. GitHub Community Discussions

Related Posts