I hate YAML. Instead of writing an essay on why I hate YAML, I can just link to noyaml.com. In my personal projects I will never use it, preferring either JSON, TOML or even plain old INI files depending on the use case. However the ship has sailed already, there are tons of projects everywhere that uses YAML: from most CI systems (GitHub Actions, CircleCI, Travis, et tu builds.sr.ht), to Kubernetes, or in almost every Rails application.
One way to avoid at least some issues with the language is to write YAML in another language. I will show my solution in one of my personal repositories, writing Nix to generate GitHub Actions configuration files. Bonus points for validating the result against the schema of GitHub Actions, so the famous "this is supposed to be string instead of a list of strings" is gone.
Let's start with the basics: YAML is supposed to be a superset of JSON. What that means is that a JSON file can be parsed by a YAML parser. And Nix itself generates JSON natively, after all, Nix can be imagined as "JSON with functions".
To make things easier, I will assume that you have the nix-commands and
flakes enabled as experimental-features in your Nix configuration. If not,
go here.
Using the nix eval command, we can generate a JSON expression from Nix by:
$ nix eval --expr '{ foo = "bar"; }' --json
{"foo":"bar"}
However, typing long excerpts of Nix code inside the console would be
impractical. We can write the following code inside a foo.nix file instead:
{
  foo = "bar";
}
And:
$ nix eval --file foo.nix --json
{"foo":"bar"}
While you can use a JSON output as an input for YAML parsers, it is probably
not the best idea. Sadly (or
maybe not), Nix has no native functionality to export data to YAML. However,
since we are using Nix, it is trivial to use nixpkgs to use some program to
convert from JSON to YAML.
To start, let's create a new directory, move our foo.nix file to it, create a
new flake.nix file and put the following contents:
{
  description = "Generate YAML files with Nix";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };
  outputs = { nixpkgs, ... }:
    {
      packages.x86_64-linux =
        let
          inherit (nixpkgs) lib;
          pkgs = import nixpkgs { system = "x86_64-linux"; };
        in
        {
          toYAML = pkgs.runCommand "toYAML" {
            buildInputs = with pkgs; [ yj ];
            json = builtins.toJSON (import ./foo.nix);
            passAsFile = [ "json" ]; # will be available as `$jsonPath`
          } ''
            mkdir -p $out
            yj -jy < "$jsonPath" > $out/go.yaml
          '';
        };
    };
}
We are loading the ./foo.nix as a Nix file, converting it to JSON with
builtins.toJSON function, and finally, using pkgs.runCommand and its
passAsFile option to load the contents of the JSON file into
yj, that converts between serialisation
formats (-jy flag means "JSON to YAML"). The reason I choose yj is mostly
because it is a single binary Go program, but you can use whatever you prefer.
By the way, there is a
lib.generators.toYAML
inside nixpkgs.lib, but as of the day of this post it only calls
lib.strings.toJSON (that in turn, calls builtins.toJSON). So it doesn't
really help here. Another option would be pkgs.formats.yaml.generate, that
converts between formats, but it calls
remarshal
(in Python), so not my favorite choice.
If we run the following commands, we can see the result:
$ nix build .#packages.x86_64-linux.toYAML
$ cat result/foo.yaml
foo: bar
That is the basic idea. To have a more realistic example, let's convert the
go.yml,
that builds this blog, to Nix:
{
  name = "Go";
  on.push.branches = [ "main" ];
  jobs = {
    build = {
      runs-on = "ubuntu-latest";
      permissions.contents = "write";
      steps = [
        { uses = "actions/checkout@v4"; }
        {
          name = "Set up Go";
          uses = "actions/checkout@v4";
          "with".go-version = "1.21";
        }
        {
          name = "Update";
          run = "make";
        }
        {
          name = "Publish";
          run = "make publish";
          env.MATAROA_TOKEN = ''''${{ secrets.MATAROA_TOKEN }}'';
        }
        {
          name = "Commit";
          uses = "stefanzweifel/git-auto-commit-action@v5";
          "with".commit_message = "README/rss:update";
        }
      ];
    };
  };
}
Some interesting things to highlight: with is a reserved word in Nix, so we
need to quote it. Not a problem, but something to be aware. And the template
string in GitHub Actions uses the same ${} that Nix uses, so we need to
escape.
And after running the following commands:
$ nix build .#packages.x86_64-linux.toYAML
$ cat result/go.yaml
jobs:
  build:
    permissions:
      contents: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Go
        uses: actions/checkout@v4
        with:
          go-version: "1.21"
      - name: Update
        run: make
      - env:
          MATAROA_TOKEN: ${{ secrets.MATAROA_TOKEN }}
        name: Publish
        run: make publish
      - name: Commit
        uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: README/rss:update
name: Go
"on":
  push:
    branches:
      - main
Yes, the keys are not in the same order as we defined, since Nix, like most programming languages (with the exception of Python), do not guarantee the insertion order in maps/dicts/attrsets/whatever. But I really hope whatever is consuming your YAML is not relying in the order the keys are defined (this would be more cursed than YAML already is).
So that is basically it. For the bonus points that I talked at the start of the
post, we can modify pkgs.runCommand to run some kind of validator. I use
action-validator, one that I
particularly packaged in
nixpkgs to use in those cases.
But you could use e.g.: a validator of Kubernetes YAML. Or a generic YAML lint
like this one. The possibilities are
endless.
Let's modify our flake.nix to add the validation:
{
  # ...
  outputs = { nixpkgs, ... }:
    {
      packages.x86_64-linux =
        let
          inherit (nixpkgs) lib;
          pkgs = import nixpkgs { system = "x86_64-linux"; };
        in
        {
          toYAML = pkgs.runCommand "toYAML" {
            buildInputs = with pkgs; [ action-validator yj ];
            json = builtins.toJSON (import ./go.nix);
            passAsFile = [ "json" ];
          } ''
            mkdir -p $out
            yj -jy < "$jsonPath" > $out/go.yaml
            action-validator -v $out/go.yaml
          '';
        };
    };
}
And let's add an error in our go.nix file:
diff --git a/go.nix b/go.nix
index 25e0596..8c00033 100644
--- a/go.nix
+++ b/go.nix
@@ -5,7 +5,7 @@
   jobs = {
     build = {
       runs-on = "ubuntu-latest";
-      permissions.contents = "write";
+      permissions.contents = [ "write" ];
       steps = [
         { uses = "actions/checkout@v4"; }
         {
Finally, let's try to build our YAML file again:
$ nix build .#packages.x86_64-linux.toYAML
error: builder for '/nix/store/j8wr6j1pvyf986sf74hqw8k31lvlzac5-toYAML.drv' failed with exit code 1;
       last 25 log lines:
       >                                 "Additional property 'runs-on' is not allowed",
       >                             ),
       >                             path: "/jobs/build",
       >                             title: "Property conditions are not met",
       >                         },
       >                         Properties {
       >                             code: "properties",
       >                             detail: Some(
       >                                 "Additional property 'steps' is not allowed",
       >                             ),
       >                             path: "/jobs/build",
       >                             title: "Property conditions are not met",
       >                         },
       >                         Required {
       >                             code: "required",
       >                             detail: None,
       >                             path: "/jobs/build/uses",
       >                             title: "This property is required",
       >                         },
       >                     ],
       >                 },
       >             ],
       >         },
       >     ],
       > }
       For full logs, run 'nix log /nix/store/j8wr6j1pvyf986sf74hqw8k31lvlzac5-toYAML.drv'.
Yes, the output of action-validator is awfully verbose, but it is still
better than making "8 commits/push in one
hour".
If you are interested in how a more advantage usage of this technique is,
including usage of functions and constants to share common steps between
different actions, please take a look at the
actions
(permalink)
in my nix-config repository.