I maintain many repositories using Gitea Actions, and I’ve run into a lot of pipeline failures due to the smallest of issues. Most commonly it is whitespace differences in YAML files that cause unexpected parsing or complete failures. Recently, I was helping someone debug a workflow where a copy and paste from a different workflow brought in spaces for some lines instead of tabs leading to the pipeline to fail.

The Problem with YAML

YAML is extremely sensitive to whitespace, and that can lead to subtle issues that are hard to debug. Especially when you can’t see the differences visually between tabs and spaces.

The Solution: Nix

In my case, as is the case with all my problems, the solution was to use Nix. Recently, I was using Terranix to manage some infrastructure (which is a tool that translates Nix to JSON for Terraform), I realized a similar approach could work for Gitea Actions. Both JSON and YAML represent data as key-value pairs, making them easily convertable between each other, meaning I can use the same approach to convert Nix to JSON to YAML.

Benefits of Using Nix for Workflows

  1. Elimination of whitespace-related errors
  2. Nix features, like conditionals, loops, and functions
  3. Improved code reuse and maintainability
  4. Ability to split workflows into smaller, manageable components

Implementing Nix-based Workflows

Step 1: Creating a Basic Step Function

The most common component of a workflow is a step. So we could start by defining a function that creates a step.

# steps.nix
let
  mkStep = { name, uses, run, with' }:
    let
      step = {
        name = if name != null then name else null;
        uses = if uses != null then uses else null;
        run = if run != null then run else null;
        with = if with' != null then with' else null;
      };
    in
      builtins.removeAttrs (builtins.filterAttrs (a: v: v != null) step) [ "name" "uses" "run" "with" ];
in
  mkStep

This mkStep function creates a step object and removes any null fields, resulting in cleaner YAML output.

Note: In Nix, with is a reserved keyword in Nix, so I used with' instead.

Step 2: Creating a Checkout Function

Building on the mkStep function, the most common step in every workflow is the checkout step. So we can create a function to create a checkout step which uses the mkStep function.

# steps.nix
# ... keep the previous code and add the new `mkCheckout` function
  mkCheckout = { name, uses ? "actions/checkout@v4", with' }:
    mkStep {
      name = name;
      uses = uses;
      with' = with';
    };

# Export both functions
{
    mkStep = mkStep;
    mkCheckout = mkCheckout;
}

This approach can be extended to other common actions, and potentially even generating functions automatically from action.yaml files.

Step 3: Creating a Workflow

Using the functions above, we can create a complete workflow.

# workflow.nix
let
  steps = import ./steps.nix { };

  mySteps = [
    (steps.mkCheckout { with = { fetch-depth = 0; }; })
    (steps.mkStep { run = "echo 'Hello, World!'" })
    # More steps could be added here
  ];

in
  {
    name = "Example Workflow";
    on = "push";
    jobs = {
      build = {
        runs-on = "ubuntu-latest";
        steps = mySteps;
      };
    };
  }

Generating the Gitea Actions YAML Workflow

To convert our Nix workflow to YAML, we can use the following command:

nix-instantiate --json ./workflow.nix | yq eval -P - > example.yaml

This command generates the YAML file (assuming you have the yq command installed).

Future Possibilities

The benefits can be immediatly seen, especially for larger projects. For example, in the Gitea project, there are two workflows, one to build release artifacts for a nightly release, and one to build release artifacts for a tagged release. Both of these workflows are very similar, and so arguments and conditionals could be used to reduce output by half.

Nix Flakes could potentially be used to define the inputs for an Action’s step, and then be used by the workflow. This would allow for a more declarative and modular approach to workflows.

Conclusion

By using Nix, I can now write workflows in a more declarative and modular way, and all of my pain points with YAML have been resolved.

Editor’s note: The Nix above hasn’t been tested, and is just a rough draft of what I’m thinking. If you do attempt this as an approach I’d love to hear, and update this post with any feedback you might have. I am also a maintainer of the Gitea project.