Gitea Actions: Forget about YAML, let's use Nix
YAML has brought me too many headaches, so I'm switching to Nix!
I maintain many repositories using Gitea Actions, and I've run into pipeline failures over small issues. Whitespace differences in YAML files are the most common cause, leading to unexpected parsing or complete failures. Recently, I helped debug a copied workflow where some of the indentation used tabs and the rest used spaces. That was enough to make the pipeline fail.
Update: After writing this post, I discovered a similar approach using Dhall. If you're interested in this concept, check out github.com/regadas/github-actions-dhall, which has already implemented much of what I discuss here with Dhall instead of Nix.
The problem with YAML
YAML is extremely sensitive to whitespace, which can lead to subtle issues that are hard to debug when tabs and spaces look alike.
The solution: Nix
In my case, as is the case with all my problems, the solution was to use Nix. I had been using Terranix to manage some infrastructure. Terranix translates Nix to JSON for Terraform, and I realized a similar approach could work for Gitea Actions. JSON and YAML both represent data as key-value pairs, so I can convert Nix to JSON and then to YAML.
Editor's note: The Nix below is an untested rough draft of the approach. If you try it, I'd be interested in any feedback so I can update this post.
Potential Nix benefits
- Fewer whitespace-related errors in handwritten workflows
- Nix features such as conditionals, loops, and functions
- Code reuse across workflows
- Workflows split into smaller components
Implementing Nix-based workflows
Step 1: Create a basic step function
A step is the most common workflow component, so I'll start with a function that creates one.
# steps.nix
let
mkStep = { name ? null, uses ? null, run ? null, with' ? null }:
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.filterAttrs (a: v: v != null) step;
in
mkStep
This mkStep function creates a step object and removes null fields from the YAML output.
Note:
withis a reserved keyword in Nix, so I usedwith'instead.
Step 2: Create a checkout function
Checkout is another common workflow step. This function builds it with mkStep.
# 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;
}
The same pattern could cover other common actions. Generating functions from action.yaml files is another possibility.
Step 3: Create a workflow
The functions above 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;
};
};
}
Generate the Gitea Actions YAML workflow
Convert the Nix workflow to YAML with:
nix-instantiate --eval --strict --json ./workflow.nix | yq eval -P - > example.yaml
This requires yq and writes the generated workflow to example.yaml.
Possible next steps
This may be most useful where projects have similar workflows. The Gitea project has one workflow for nightly release artifacts and another for tagged release artifacts. Arguments and conditionals could move their shared definitions into one place instead of maintaining two similar files.
Nix flakes are another possible way to define inputs for an Action step and pass them to the workflow.
This approach replaces handwritten YAML with Nix, but it adds Nix and yq to the workflow toolchain and still generates YAML as an intermediate file. Whether that tradeoff is worthwhile depends on how much workflow code can be shared.