about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorVincent Haupert <vincent@yaxi.tech>2024-01-22 14:21:07 +0100
committerVincent Haupert <vincent@yaxi.tech>2024-02-09 16:19:19 +0100
commit782b1645230f0f1b8362941c597c61688baf9534 (patch)
treebec77da04a4cc15001b333bd2b1b256b68ca1760 /nixos
parent9ad02c831a5dc264940e38a5e9ec2892cd74600e (diff)
downloadnixlib-782b1645230f0f1b8362941c597c61688baf9534.tar
nixlib-782b1645230f0f1b8362941c597c61688baf9534.tar.gz
nixlib-782b1645230f0f1b8362941c597c61688baf9534.tar.bz2
nixlib-782b1645230f0f1b8362941c597c61688baf9534.tar.lz
nixlib-782b1645230f0f1b8362941c597c61688baf9534.tar.xz
nixlib-782b1645230f0f1b8362941c597c61688baf9534.tar.zst
nixlib-782b1645230f0f1b8362941c597c61688baf9534.zip
nixos/github-runners: rework `name` default
Diffstat (limited to 'nixos')
-rw-r--r--nixos/modules/services/continuous-integration/github-runner/options.nix446
-rw-r--r--nixos/modules/services/continuous-integration/github-runner/service.nix513
-rw-r--r--nixos/modules/services/continuous-integration/github-runners.nix69
3 files changed, 497 insertions, 531 deletions
diff --git a/nixos/modules/services/continuous-integration/github-runner/options.nix b/nixos/modules/services/continuous-integration/github-runner/options.nix
index 1ee8067e47ea..35c0a6133db9 100644
--- a/nixos/modules/services/continuous-integration/github-runner/options.nix
+++ b/nixos/modules/services/continuous-integration/github-runner/options.nix
@@ -1,228 +1,242 @@
-{ config
-, lib
+{ lib
 , pkgs
-, includeNameDefault
 , ...
 }:
 
 with lib;
-
 {
-  enable = mkOption {
-    default = false;
-    example = true;
-    description = mdDoc ''
-      Whether to enable GitHub Actions runner.
-
-      Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here:
-      [About self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners).
-    '';
-    type = types.bool;
-  };
-
-  url = mkOption {
-    type = types.str;
-    description = mdDoc ''
-      Repository to add the runner to.
-
-      Changing this option triggers a new runner registration.
-
-      IMPORTANT: If your token is org-wide (not per repository), you need to
-      provide a github org link, not a single repository, so do it like this
-      `https://github.com/nixos`, not like this
-      `https://github.com/nixos/nixpkgs`.
-      Otherwise, you are going to get a `404 NotFound`
-      from `POST https://api.github.com/actions/runner-registration`
-      in the configure script.
-    '';
-    example = "https://github.com/nixos/nixpkgs";
-  };
-
-  tokenFile = mkOption {
-    type = types.path;
-    description = mdDoc ''
-      The full path to a file which contains either
-
-      * a fine-grained personal access token (PAT),
-      * a classic PAT
-      * or a runner registration token
-
-      Changing this option or the `tokenFile`’s content triggers a new runner registration.
-
-      We suggest using the fine-grained PATs. A runner registration token is valid
-      only for 1 hour after creation, so the next time the runner configuration changes
-      this will give you hard-to-debug HTTP 404 errors in the configure step.
-
-      The file should contain exactly one line with the token without any newline.
-      (Use `echo -n '…token…' > …token file…` to make sure no newlines sneak in.)
-
-      If the file contains a PAT, the service creates a new registration token
-      on startup as needed.
-      If a registration token is given, it can be used to re-register a runner of the same
-      name but is time-limited as noted above.
-
-      For fine-grained PATs:
-
-      Give it "Read and Write access to organization/repository self hosted runners",
-      depending on whether it is organization wide or per-repository. You might have to
-      experiment a little, fine-grained PATs are a `beta` Github feature and still subject
-      to change; nonetheless they are the best option at the moment.
-
-      For classic PATs:
-
-      Make sure the PAT has a scope of `admin:org` for organization-wide registrations
-      or a scope of `repo` for a single repository.
-
-      For runner registration tokens:
-
-      Nothing special needs to be done, but updating will break after one hour,
-      so these are not recommended.
-    '';
-    example = "/run/secrets/github-runner/nixos.token";
-  };
-
-  name = let
-    # Same pattern as for `networking.hostName`
-    baseType = types.strMatching "^$|^[[:alnum:]]([[:alnum:]_-]{0,61}[[:alnum:]])?$";
-  in mkOption {
-    type = if includeNameDefault then baseType else types.nullOr baseType;
-    description = mdDoc ''
-      Name of the runner to configure. Defaults to the hostname.
-
-      Changing this option triggers a new runner registration.
-    '';
-    example = "nixos";
-  } // (if includeNameDefault then {
-    default = config.networking.hostName;
-    defaultText = literalExpression "config.networking.hostName";
-  } else {
-    default = null;
-  });
-
-  runnerGroup = mkOption {
-    type = types.nullOr types.str;
-    description = mdDoc ''
-      Name of the runner group to add this runner to (defaults to the default runner group).
-
-      Changing this option triggers a new runner registration.
-    '';
-    default = null;
-  };
-
-  extraLabels = mkOption {
-    type = types.listOf types.str;
-    description = mdDoc ''
-      Extra labels in addition to the default.
-      Requires a non-empty list if the `noDefaultLabels` option is used.
-
-      Changing this option triggers a new runner registration.
-    '';
-    example = literalExpression ''[ "nixos" ]'';
-    default = [ ];
-  };
-
-  noDefaultLabels = mkOption {
-    type = types.bool;
-    description = mdDoc ''
-      Disables adding the default labels. Also see the `extraLabels` option.
-
-      Changing this option triggers a new runner registration.
-    '';
-    default = false;
-  };
-
-  replace = mkOption {
-    type = types.bool;
+  options.services.github-runners = mkOption {
     description = mdDoc ''
-      Replace any existing runner with the same name.
-
-      Without this flag, registering a new runner with the same name fails.
-    '';
-    default = false;
-  };
-
-  extraPackages = mkOption {
-    type = types.listOf types.package;
-    description = mdDoc ''
-      Extra packages to add to `PATH` of the service to make them available to workflows.
-    '';
-    default = [ ];
-  };
-
-  extraEnvironment = mkOption {
-    type = types.attrs;
-    description = mdDoc ''
-      Extra environment variables to set for the runner, as an attrset.
-    '';
-    example = {
-      GIT_CONFIG = "/path/to/git/config";
-    };
-    default = {};
-  };
-
-  serviceOverrides = mkOption {
-    type = types.attrs;
-    description = mdDoc ''
-      Modify the systemd service. Can be used to, e.g., adjust the sandboxing options.
-      See {manpage}`systemd.exec(5)` for more options.
+      Multiple GitHub Runners.
     '';
     example = {
-      ProtectHome = false;
-      RestrictAddressFamilies = [ "AF_PACKET" ];
+      runner1 = {
+        enable = true;
+        url = "https://github.com/owner/repo";
+        name = "runner1";
+        tokenFile = "/secrets/token1";
+      };
+
+      runner2 = {
+        enable = true;
+        url = "https://github.com/owner/repo";
+        name = "runner2";
+        tokenFile = "/secrets/token2";
+      };
     };
-    default = {};
-  };
-
-  package = mkPackageOption pkgs "github-runner" { };
-
-  ephemeral = mkOption {
-    type = types.bool;
-    description = mdDoc ''
-      If enabled, causes the following behavior:
-
-      - Passes the `--ephemeral` flag to the runner configuration script
-      - De-registers and stops the runner with GitHub after it has processed one job
-      - On stop, systemd wipes the runtime directory (this always happens, even without using the ephemeral option)
-      - Restarts the service after its successful exit
-      - On start, wipes the state directory and configures a new runner
-
-      You should only enable this option if `tokenFile` points to a file which contains a
-      personal access token (PAT). If you're using the option with a registration token, restarting the
-      service will fail as soon as the registration token expired.
-
-      Changing this option triggers a new runner registration.
-    '';
-    default = false;
-  };
-
-  user = mkOption {
-    type = types.nullOr types.str;
-    description = mdDoc ''
-      User under which to run the service. If null, will use a systemd dynamic user.
-    '';
-    default = null;
-    defaultText = literalExpression "username";
-  };
-
-  workDir = mkOption {
-    type = with types; nullOr str;
-    description = mdDoc ''
-      Working directory, available as `$GITHUB_WORKSPACE` during workflow runs
-      and used as a default for [repository checkouts](https://github.com/actions/checkout).
-      The service cleans this directory on every service start.
-
-      A value of `null` will default to the systemd `RuntimeDirectory`.
-
-      Changing this option triggers a new runner registration.
-    '';
-    default = null;
-  };
-
-  nodeRuntimes = mkOption {
-    type = with types; nonEmptyListOf (enum [ "node16" "node20" ]);
-    default = [ "node20" ];
-    description = mdDoc ''
-      List of Node.js runtimes the runner should support.
-    '';
+    default = { };
+    type = types.attrsOf (types.submodule ({ name, ... }: {
+      options = {
+        enable = mkOption {
+          default = false;
+          example = true;
+          description = mdDoc ''
+            Whether to enable GitHub Actions runner.
+
+            Note: GitHub recommends using self-hosted runners with private repositories only. Learn more here:
+            [About self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/about-self-hosted-runners).
+          '';
+          type = types.bool;
+        };
+
+        url = mkOption {
+          type = types.str;
+          description = mdDoc ''
+            Repository to add the runner to.
+
+            Changing this option triggers a new runner registration.
+
+            IMPORTANT: If your token is org-wide (not per repository), you need to
+            provide a github org link, not a single repository, so do it like this
+            `https://github.com/nixos`, not like this
+            `https://github.com/nixos/nixpkgs`.
+            Otherwise, you are going to get a `404 NotFound`
+            from `POST https://api.github.com/actions/runner-registration`
+            in the configure script.
+          '';
+          example = "https://github.com/nixos/nixpkgs";
+        };
+
+        tokenFile = mkOption {
+          type = types.path;
+          description = mdDoc ''
+            The full path to a file which contains either
+
+            * a fine-grained personal access token (PAT),
+            * a classic PAT
+            * or a runner registration token
+
+            Changing this option or the `tokenFile`’s content triggers a new runner registration.
+
+            We suggest using the fine-grained PATs. A runner registration token is valid
+            only for 1 hour after creation, so the next time the runner configuration changes
+            this will give you hard-to-debug HTTP 404 errors in the configure step.
+
+            The file should contain exactly one line with the token without any newline.
+            (Use `echo -n '…token…' > …token file…` to make sure no newlines sneak in.)
+
+            If the file contains a PAT, the service creates a new registration token
+            on startup as needed.
+            If a registration token is given, it can be used to re-register a runner of the same
+            name but is time-limited as noted above.
+
+            For fine-grained PATs:
+
+            Give it "Read and Write access to organization/repository self hosted runners",
+            depending on whether it is organization wide or per-repository. You might have to
+            experiment a little, fine-grained PATs are a `beta` Github feature and still subject
+            to change; nonetheless they are the best option at the moment.
+
+            For classic PATs:
+
+            Make sure the PAT has a scope of `admin:org` for organization-wide registrations
+            or a scope of `repo` for a single repository.
+
+            For runner registration tokens:
+
+            Nothing special needs to be done, but updating will break after one hour,
+            so these are not recommended.
+          '';
+          example = "/run/secrets/github-runner/nixos.token";
+        };
+
+        name = mkOption {
+          type = types.nullOr types.str;
+          description = mdDoc ''
+            Name of the runner to configure. If null, defaults to the hostname.
+
+            Changing this option triggers a new runner registration.
+          '';
+          example = "nixos";
+          default = name;
+        };
+
+        runnerGroup = mkOption {
+          type = types.nullOr types.str;
+          description = mdDoc ''
+            Name of the runner group to add this runner to (defaults to the default runner group).
+
+            Changing this option triggers a new runner registration.
+          '';
+          default = null;
+        };
+
+        extraLabels = mkOption {
+          type = types.listOf types.str;
+          description = mdDoc ''
+            Extra labels in addition to the default (unless disabled through the `noDefaultLabels` option).
+
+            Changing this option triggers a new runner registration.
+          '';
+          example = literalExpression ''[ "nixos" ]'';
+          default = [ ];
+        };
+
+        noDefaultLabels = mkOption {
+          type = types.bool;
+          description = mdDoc ''
+            Disables adding the default labels. Also see the `extraLabels` option.
+
+            Changing this option triggers a new runner registration.
+          '';
+          default = false;
+        };
+
+        replace = mkOption {
+          type = types.bool;
+          description = mdDoc ''
+            Replace any existing runner with the same name.
+
+            Without this flag, registering a new runner with the same name fails.
+          '';
+          default = false;
+        };
+
+        extraPackages = mkOption {
+          type = types.listOf types.package;
+          description = mdDoc ''
+            Extra packages to add to `PATH` of the service to make them available to workflows.
+          '';
+          default = [ ];
+        };
+
+        extraEnvironment = mkOption {
+          type = types.attrs;
+          description = mdDoc ''
+            Extra environment variables to set for the runner, as an attrset.
+          '';
+          example = {
+            GIT_CONFIG = "/path/to/git/config";
+          };
+          default = { };
+        };
+
+        serviceOverrides = mkOption {
+          type = types.attrs;
+          description = mdDoc ''
+            Modify the systemd service. Can be used to, e.g., adjust the sandboxing options.
+            See {manpage}`systemd.exec(5)` for more options.
+          '';
+          example = {
+            ProtectHome = false;
+            RestrictAddressFamilies = [ "AF_PACKET" ];
+          };
+          default = { };
+        };
+
+        package = mkPackageOption pkgs "github-runner" { };
+
+        ephemeral = mkOption {
+          type = types.bool;
+          description = mdDoc ''
+            If enabled, causes the following behavior:
+
+            - Passes the `--ephemeral` flag to the runner configuration script
+            - De-registers and stops the runner with GitHub after it has processed one job
+            - On stop, systemd wipes the runtime directory (this always happens, even without using the ephemeral option)
+            - Restarts the service after its successful exit
+            - On start, wipes the state directory and configures a new runner
+
+            You should only enable this option if `tokenFile` points to a file which contains a
+            personal access token (PAT). If you're using the option with a registration token, restarting the
+            service will fail as soon as the registration token expired.
+
+            Changing this option triggers a new runner registration.
+          '';
+          default = false;
+        };
+
+        user = mkOption {
+          type = types.nullOr types.str;
+          description = mdDoc ''
+            User under which to run the service. If null, will use a systemd dynamic user.
+          '';
+          default = null;
+          defaultText = literalExpression "username";
+        };
+
+        workDir = mkOption {
+          type = with types; nullOr str;
+          description = mdDoc ''
+            Working directory, available as `$GITHUB_WORKSPACE` during workflow runs
+            and used as a default for [repository checkouts](https://github.com/actions/checkout).
+            The service cleans this directory on every service start.
+
+            A value of `null` will default to the systemd `RuntimeDirectory`.
+
+            Changing this option triggers a new runner registration.
+          '';
+          default = null;
+        };
+
+        nodeRuntimes = mkOption {
+          type = with types; nonEmptyListOf (enum [ "node16" "node20" ]);
+          default = [ "node20" ];
+          description = mdDoc ''
+            List of Node.js runtimes the runner should support.
+          '';
+        };
+      };
+    }));
   };
 }
diff --git a/nixos/modules/services/continuous-integration/github-runner/service.nix b/nixos/modules/services/continuous-integration/github-runner/service.nix
index 8895f4827bb1..cdf86ca8bfce 100644
--- a/nixos/modules/services/continuous-integration/github-runner/service.nix
+++ b/nixos/modules/services/continuous-integration/github-runner/service.nix
@@ -1,279 +1,290 @@
 { config
 , lib
 , pkgs
-
-, cfg ? config.services.github-runner
-, svcName
-
-, systemdDir ? "${svcName}/${cfg.name}"
-  # %t: Runtime directory root (usually /run); see systemd.unit(5)
-, runtimeDir ? "%t/${systemdDir}"
-  # %S: State directory root (usually /var/lib); see systemd.unit(5)
-, stateDir ? "%S/${systemdDir}"
-  # %L: Log directory root (usually /var/log); see systemd.unit(5)
-, logsDir ? "%L/${systemdDir}"
-  # Name of file stored in service state directory
-, currentConfigTokenFilename ? ".current-token"
-
 , ...
 }:
 
 with lib;
-
-let
-  workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
-  # Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
-  package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; });
-in
 {
-  description = "GitHub Actions runner";
+  config.assertions = flatten (
+    flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [
+      {
+        assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
+        message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
+      }
+    ])
+  );
 
-  wantedBy = [ "multi-user.target" ];
-  wants = [ "network-online.target" ];
-  after = [ "network.target" "network-online.target" ];
+  config.systemd.services = flip mapAttrs' config.services.github-runners (name: cfg:
+    let
+      svcName = "github-runner-${name}";
+      systemdDir = "github-runner/${name}";
 
-  environment = {
-    HOME = workDir;
-    RUNNER_ROOT = stateDir;
-  } // cfg.extraEnvironment;
+      # %t: Runtime directory root (usually /run); see systemd.unit(5)
+      runtimeDir = "%t/${systemdDir}";
+      # %S: State directory root (usually /var/lib); see systemd.unit(5)
+      stateDir = "%S/${systemdDir}";
+      # %L: Log directory root (usually /var/log); see systemd.unit(5)
+      logsDir = "%L/${systemdDir}";
+      # Name of file stored in service state directory
+      currentConfigTokenFilename = ".current-token";
 
-  path = (with pkgs; [
-    bash
-    coreutils
-    git
-    gnutar
-    gzip
-  ]) ++ [
-    config.nix.package
-  ] ++ cfg.extraPackages;
+      workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
+      # Support old github-runner versions which don't have the `nodeRuntimes` arg yet.
+      package = cfg.package.override (old: optionalAttrs (hasAttr "nodeRuntimes" old) { inherit (cfg) nodeRuntimes; });
+    in
+    nameValuePair svcName {
+      description = "GitHub Actions runner";
 
-  serviceConfig = mkMerge [
-    {
-      ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ];
+      after = [ "network.target" "network-online.target" ];
 
-      # Does the following, sequentially:
-      # - If the module configuration or the token has changed, purge the state directory,
-      #   and create the current and the new token file with the contents of the configured
-      #   token. While both files have the same content, only the later is accessible by
-      #   the service user.
-      # - Configure the runner using the new token file. When finished, delete it.
-      # - Set up the directory structure by creating the necessary symlinks.
-      ExecStartPre =
-        let
-          # Wrapper script which expects the full path of the state, working and logs
-          # directory as arguments. Overrides the respective systemd variables to provide
-          # unambiguous directory names. This becomes relevant, for example, if the
-          # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
-          # to contain more than one directory. This causes systemd to set the respective
-          # environment variables with the path of all of the given directories, separated
-          # by a colon.
-          writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
-            set -euo pipefail
+      environment = {
+        HOME = workDir;
+        RUNNER_ROOT = stateDir;
+      } // cfg.extraEnvironment;
 
-            STATE_DIRECTORY="$1"
-            WORK_DIRECTORY="$2"
-            LOGS_DIRECTORY="$3"
+      path = (with pkgs; [
+        bash
+        coreutils
+        git
+        gnutar
+        gzip
+      ]) ++ [
+        config.nix.package
+      ] ++ cfg.extraPackages;
 
-            ${lines}
-          '';
-          runnerRegistrationConfig = getAttrs [
-            "ephemeral"
-            "extraLabels"
-            "name"
-            "noDefaultLabels"
-            "runnerGroup"
-            "tokenFile"
-            "url"
-            "workDir"
-          ] cfg;
-          newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
-          currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
-          newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
-          currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
+      serviceConfig = mkMerge [
+        {
+          ExecStart = "${package}/bin/Runner.Listener run --startuptype service";
 
-          runnerCredFiles = [
-            ".credentials"
-            ".credentials_rsaparams"
-            ".runner"
-          ];
-          unconfigureRunner = writeScript "unconfigure" ''
-            copy_tokens() {
-              # Copy the configured token file to the state dir and allow the service user to read the file
-              install --mode=666 ${escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
-              # Also copy current file to allow for a diff on the next start
-              install --mode=600 ${escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
-            }
-            clean_state() {
-              find "$STATE_DIRECTORY/" -mindepth 1 -delete
-              copy_tokens
-            }
-            diff_config() {
-              changed=0
-              # Check for module config changes
-              [[ -f "${currentConfigPath}" ]] \
-                && ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
-                || changed=1
-              # Also check the content of the token file
-              [[ -f "${currentConfigTokenPath}" ]] \
-                && ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
-                || changed=1
-              # If the config has changed, remove old state and copy tokens
-              if [[ "$changed" -eq 1 ]]; then
-                echo "Config has changed, removing old runner state."
-                echo "The old runner will still appear in the GitHub Actions UI." \
-                     "You have to remove it manually."
-                clean_state
-              fi
-            }
-            if [[ "${optionalString cfg.ephemeral "1"}" ]]; then
-              # In ephemeral mode, we always want to start with a clean state
-              clean_state
-            elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
-              # There are state files from a previous run; diff them to decide if we need a new registration
-              diff_config
-            else
-              # The state directory is entirely empty which indicates a first start
-              copy_tokens
-            fi
-            # Always clean workDir
-            find -H "$WORK_DIRECTORY" -mindepth 1 -delete
-          '';
-          configureRunner = writeScript "configure" ''
-            if [[ -e "${newConfigTokenPath}" ]]; then
-              echo "Configuring GitHub Actions Runner"
-              args=(
-                --unattended
-                --disableupdate
-                --work "$WORK_DIRECTORY"
-                --url ${escapeShellArg cfg.url}
-                --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)}
-                --name ${escapeShellArg cfg.name}
-                ${optionalString cfg.replace "--replace"}
-                ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
-                ${optionalString cfg.ephemeral "--ephemeral"}
-                ${optionalString cfg.noDefaultLabels "--no-default-labels"}
-              )
-              # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
-              # if it is not a PAT, we assume it contains a registration token and use the --token option
-              token=$(<"${newConfigTokenPath}")
-              if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
-                args+=(--pat "$token")
-              else
-                args+=(--token "$token")
-              fi
-              ${package}/bin/Runner.Listener configure "''${args[@]}"
-              # Move the automatically created _diag dir to the logs dir
-              mkdir -p  "$STATE_DIRECTORY/_diag"
-              cp    -r  "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
-              rm    -rf "$STATE_DIRECTORY/_diag/"
-              # Cleanup token from config
-              rm "${newConfigTokenPath}"
-              # Symlink to new config
-              ln -s '${newConfigPath}' "${currentConfigPath}"
-            fi
-          '';
-          setupWorkDir = writeScript "setup-work-dirs" ''
-            # Link _diag dir
-            ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
+          # Does the following, sequentially:
+          # - If the module configuration or the token has changed, purge the state directory,
+          #   and create the current and the new token file with the contents of the configured
+          #   token. While both files have the same content, only the later is accessible by
+          #   the service user.
+          # - Configure the runner using the new token file. When finished, delete it.
+          # - Set up the directory structure by creating the necessary symlinks.
+          ExecStartPre =
+            let
+              # Wrapper script which expects the full path of the state, working and logs
+              # directory as arguments. Overrides the respective systemd variables to provide
+              # unambiguous directory names. This becomes relevant, for example, if the
+              # caller overrides any of the StateDirectory=, RuntimeDirectory= or LogDirectory=
+              # to contain more than one directory. This causes systemd to set the respective
+              # environment variables with the path of all of the given directories, separated
+              # by a colon.
+              writeScript = name: lines: pkgs.writeShellScript "${svcName}-${name}.sh" ''
+                set -euo pipefail
 
-            # Link the runner credentials to the work dir
-            ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
-          '';
-        in
-        map (x: "${x} ${escapeShellArgs [ stateDir workDir logsDir ]}") [
-          "+${unconfigureRunner}" # runs as root
-          configureRunner
-          setupWorkDir
-        ];
+                STATE_DIRECTORY="$1"
+                WORK_DIRECTORY="$2"
+                LOGS_DIRECTORY="$3"
 
-      # If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
-      # to trigger a fresh registration.
-      Restart = if cfg.ephemeral then "on-success" else "no";
-      # If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
-      # https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
-      RestartForceExitStatus = [ 2 ];
+                ${lines}
+              '';
+              runnerRegistrationConfig = getAttrs [
+                "ephemeral"
+                "extraLabels"
+                "name"
+                "noDefaultLabels"
+                "runnerGroup"
+                "tokenFile"
+                "url"
+                "workDir"
+              ]
+                cfg;
+              newConfigPath = builtins.toFile "${svcName}-config.json" (builtins.toJSON runnerRegistrationConfig);
+              currentConfigPath = "$STATE_DIRECTORY/.nixos-current-config.json";
+              newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
+              currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
 
-      # Contains _diag
-      LogsDirectory = [ systemdDir ];
-      # Default RUNNER_ROOT which contains ephemeral Runner data
-      RuntimeDirectory = [ systemdDir ];
-      # Home of persistent runner data, e.g., credentials
-      StateDirectory = [ systemdDir ];
-      StateDirectoryMode = "0700";
-      WorkingDirectory = workDir;
+              runnerCredFiles = [
+                ".credentials"
+                ".credentials_rsaparams"
+                ".runner"
+              ];
+              unconfigureRunner = writeScript "unconfigure" ''
+                copy_tokens() {
+                  # Copy the configured token file to the state dir and allow the service user to read the file
+                  install --mode=666 ${escapeShellArg cfg.tokenFile} "${newConfigTokenPath}"
+                  # Also copy current file to allow for a diff on the next start
+                  install --mode=600 ${escapeShellArg cfg.tokenFile} "${currentConfigTokenPath}"
+                }
+                clean_state() {
+                  find "$STATE_DIRECTORY/" -mindepth 1 -delete
+                  copy_tokens
+                }
+                diff_config() {
+                  changed=0
+                  # Check for module config changes
+                  [[ -f "${currentConfigPath}" ]] \
+                    && ${pkgs.diffutils}/bin/diff -q '${newConfigPath}' "${currentConfigPath}" >/dev/null 2>&1 \
+                    || changed=1
+                  # Also check the content of the token file
+                  [[ -f "${currentConfigTokenPath}" ]] \
+                    && ${pkgs.diffutils}/bin/diff -q "${currentConfigTokenPath}" ${escapeShellArg cfg.tokenFile} >/dev/null 2>&1 \
+                    || changed=1
+                  # If the config has changed, remove old state and copy tokens
+                  if [[ "$changed" -eq 1 ]]; then
+                    echo "Config has changed, removing old runner state."
+                    echo "The old runner will still appear in the GitHub Actions UI." \
+                         "You have to remove it manually."
+                    clean_state
+                  fi
+                }
+                if [[ "${optionalString cfg.ephemeral "1"}" ]]; then
+                  # In ephemeral mode, we always want to start with a clean state
+                  clean_state
+                elif [[ "$(ls -A "$STATE_DIRECTORY")" ]]; then
+                  # There are state files from a previous run; diff them to decide if we need a new registration
+                  diff_config
+                else
+                  # The state directory is entirely empty which indicates a first start
+                  copy_tokens
+                fi
+                # Always clean workDir
+                find -H "$WORK_DIRECTORY" -mindepth 1 -delete
+              '';
+              configureRunner = writeScript "configure" ''
+                if [[ -e "${newConfigTokenPath}" ]]; then
+                  echo "Configuring GitHub Actions Runner"
+                  args=(
+                    --unattended
+                    --disableupdate
+                    --work "$WORK_DIRECTORY"
+                    --url ${escapeShellArg cfg.url}
+                    --labels ${escapeShellArg (concatStringsSep "," cfg.extraLabels)}
+                    ${optionalString (cfg.name != null ) "--name ${escapeShellArg cfg.name}"}
+                    ${optionalString cfg.replace "--replace"}
+                    ${optionalString (cfg.runnerGroup != null) "--runnergroup ${escapeShellArg cfg.runnerGroup}"}
+                    ${optionalString cfg.ephemeral "--ephemeral"}
+                    ${optionalString cfg.noDefaultLabels "--no-default-labels"}
+                  )
+                  # If the token file contains a PAT (i.e., it starts with "ghp_" or "github_pat_"), we have to use the --pat option,
+                  # if it is not a PAT, we assume it contains a registration token and use the --token option
+                  token=$(<"${newConfigTokenPath}")
+                  if [[ "$token" =~ ^ghp_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
+                    args+=(--pat "$token")
+                  else
+                    args+=(--token "$token")
+                  fi
+                  ${package}/bin/Runner.Listener configure "''${args[@]}"
+                  # Move the automatically created _diag dir to the logs dir
+                  mkdir -p  "$STATE_DIRECTORY/_diag"
+                  cp    -r  "$STATE_DIRECTORY/_diag/." "$LOGS_DIRECTORY/"
+                  rm    -rf "$STATE_DIRECTORY/_diag/"
+                  # Cleanup token from config
+                  rm "${newConfigTokenPath}"
+                  # Symlink to new config
+                  ln -s '${newConfigPath}' "${currentConfigPath}"
+                fi
+              '';
+              setupWorkDir = writeScript "setup-work-dirs" ''
+                # Link _diag dir
+                ln -s "$LOGS_DIRECTORY" "$WORK_DIRECTORY/_diag"
 
-      InaccessiblePaths = [
-        # Token file path given in the configuration, if visible to the service
-        "-${cfg.tokenFile}"
-        # Token file in the state directory
-        "${stateDir}/${currentConfigTokenFilename}"
-      ];
+                # Link the runner credentials to the work dir
+                ln -s "$STATE_DIRECTORY"/{${lib.concatStringsSep "," runnerCredFiles}} "$WORK_DIRECTORY/"
+              '';
+            in
+            map (x: "${x} ${escapeShellArgs [ stateDir workDir logsDir ]}") [
+              "+${unconfigureRunner}" # runs as root
+              configureRunner
+              setupWorkDir
+            ];
 
-      KillSignal = "SIGINT";
+          # If running in ephemeral mode, restart the service on-exit (i.e., successful de-registration of the runner)
+          # to trigger a fresh registration.
+          Restart = if cfg.ephemeral then "on-success" else "no";
+          # If the runner exits with `ReturnCode.RetryableError = 2`, always restart the service:
+          # https://github.com/actions/runner/blob/40ed7f8/src/Runner.Common/Constants.cs#L146
+          RestartForceExitStatus = [ 2 ];
 
-      # Hardening (may overlap with DynamicUser=)
-      # The following options are only for optimizing:
-      # systemd-analyze security github-runner
-      AmbientCapabilities = mkBefore [ "" ];
-      CapabilityBoundingSet = mkBefore [ "" ];
-      # ProtectClock= adds DeviceAllow=char-rtc r
-      DeviceAllow = mkBefore [ "" ];
-      NoNewPrivileges = mkDefault true;
-      PrivateDevices = mkDefault true;
-      PrivateMounts = mkDefault true;
-      PrivateTmp = mkDefault true;
-      PrivateUsers = mkDefault true;
-      ProtectClock = mkDefault true;
-      ProtectControlGroups = mkDefault true;
-      ProtectHome = mkDefault true;
-      ProtectHostname = mkDefault true;
-      ProtectKernelLogs = mkDefault true;
-      ProtectKernelModules = mkDefault true;
-      ProtectKernelTunables = mkDefault true;
-      ProtectSystem = mkDefault "strict";
-      RemoveIPC = mkDefault true;
-      RestrictNamespaces = mkDefault true;
-      RestrictRealtime = mkDefault true;
-      RestrictSUIDSGID = mkDefault true;
-      UMask = mkDefault "0066";
-      ProtectProc = mkDefault "invisible";
-      SystemCallFilter = mkBefore [
-        "~@clock"
-        "~@cpu-emulation"
-        "~@module"
-        "~@mount"
-        "~@obsolete"
-        "~@raw-io"
-        "~@reboot"
-        "~capset"
-        "~setdomainname"
-        "~sethostname"
-      ];
-      RestrictAddressFamilies = mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
+          # Contains _diag
+          LogsDirectory = [ systemdDir ];
+          # Default RUNNER_ROOT which contains ephemeral Runner data
+          RuntimeDirectory = [ systemdDir ];
+          # Home of persistent runner data, e.g., credentials
+          StateDirectory = [ systemdDir ];
+          StateDirectoryMode = "0700";
+          WorkingDirectory = workDir;
 
-      BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
+          InaccessiblePaths = [
+            # Token file path given in the configuration, if visible to the service
+            "-${cfg.tokenFile}"
+            # Token file in the state directory
+            "${stateDir}/${currentConfigTokenFilename}"
+          ];
+
+          KillSignal = "SIGINT";
+
+          # Hardening (may overlap with DynamicUser=)
+          # The following options are only for optimizing:
+          # systemd-analyze security github-runner
+          AmbientCapabilities = mkBefore [ "" ];
+          CapabilityBoundingSet = mkBefore [ "" ];
+          # ProtectClock= adds DeviceAllow=char-rtc r
+          DeviceAllow = mkBefore [ "" ];
+          NoNewPrivileges = mkDefault true;
+          PrivateDevices = mkDefault true;
+          PrivateMounts = mkDefault true;
+          PrivateTmp = mkDefault true;
+          PrivateUsers = mkDefault true;
+          ProtectClock = mkDefault true;
+          ProtectControlGroups = mkDefault true;
+          ProtectHome = mkDefault true;
+          ProtectHostname = mkDefault true;
+          ProtectKernelLogs = mkDefault true;
+          ProtectKernelModules = mkDefault true;
+          ProtectKernelTunables = mkDefault true;
+          ProtectSystem = mkDefault "strict";
+          RemoveIPC = mkDefault true;
+          RestrictNamespaces = mkDefault true;
+          RestrictRealtime = mkDefault true;
+          RestrictSUIDSGID = mkDefault true;
+          UMask = mkDefault "0066";
+          ProtectProc = mkDefault "invisible";
+          SystemCallFilter = mkBefore [
+            "~@clock"
+            "~@cpu-emulation"
+            "~@module"
+            "~@mount"
+            "~@obsolete"
+            "~@raw-io"
+            "~@reboot"
+            "~capset"
+            "~setdomainname"
+            "~sethostname"
+          ];
+          RestrictAddressFamilies = mkBefore [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ];
+
+          BindPaths = lib.optionals (cfg.workDir != null) [ cfg.workDir ];
 
-      # Needs network access
-      PrivateNetwork = mkDefault false;
-      # Cannot be true due to Node
-      MemoryDenyWriteExecute = mkDefault false;
+          # Needs network access
+          PrivateNetwork = mkDefault false;
+          # Cannot be true due to Node
+          MemoryDenyWriteExecute = mkDefault false;
 
-      # The more restrictive "pid" option makes `nix` commands in CI emit
-      # "GC Warning: Couldn't read /proc/stat"
-      # You may want to set this to "pid" if not using `nix` commands
-      ProcSubset = mkDefault "all";
-      # Coverage programs for compiled code such as `cargo-tarpaulin` disable
-      # ASLR (address space layout randomization) which requires the
-      # `personality` syscall
-      # You may want to set this to `true` if not using coverage tooling on
-      # compiled code
-      LockPersonality = mkDefault false;
+          # The more restrictive "pid" option makes `nix` commands in CI emit
+          # "GC Warning: Couldn't read /proc/stat"
+          # You may want to set this to "pid" if not using `nix` commands
+          ProcSubset = mkDefault "all";
+          # Coverage programs for compiled code such as `cargo-tarpaulin` disable
+          # ASLR (address space layout randomization) which requires the
+          # `personality` syscall
+          # You may want to set this to `true` if not using coverage tooling on
+          # compiled code
+          LockPersonality = mkDefault false;
 
-      # Note that this has some interactions with the User setting; so you may
-      # want to consult the systemd docs if using both.
-      DynamicUser = mkDefault true;
+          # Note that this has some interactions with the User setting; so you may
+          # want to consult the systemd docs if using both.
+          DynamicUser = mkDefault true;
+        }
+        (mkIf (cfg.user != null) { User = cfg.user; })
+        cfg.serviceOverrides
+      ];
     }
-    (mkIf (cfg.user != null) { User = cfg.user; })
-    cfg.serviceOverrides
-  ];
+  );
 }
diff --git a/nixos/modules/services/continuous-integration/github-runners.nix b/nixos/modules/services/continuous-integration/github-runners.nix
index 3af1c4a49871..4a4608c2e4f8 100644
--- a/nixos/modules/services/continuous-integration/github-runners.nix
+++ b/nixos/modules/services/continuous-integration/github-runners.nix
@@ -1,69 +1,10 @@
-{ config
-, pkgs
-, lib
-, ...
-}@args:
-
-with lib;
-
-let
-  cfg = config.services.github-runners;
-
-in
-
+{ lib, ... }:
 {
   imports = [
-    mkRemovedOptionModule [ "services" "github-runner" ] "Use `services.github-runners.*` instead"
+    (lib.mkRemovedOptionModule [ "services" "github-runner" ] "Use `services.github-runners.*` instead")
+    ./github-runner/options.nix
+    ./github-runner/service.nix
   ];
 
-  options.services.github-runners = mkOption {
-    default = {};
-    type = with types; attrsOf (submodule { options = import ./github-runner/options.nix (args // {
-      # services.github-runners.${name}.name doesn't have a default; it falls back to ${name} below.
-      includeNameDefault = false;
-    }); });
-    example = {
-      runner1 = {
-        enable = true;
-        url = "https://github.com/owner/repo";
-        name = "runner1";
-        tokenFile = "/secrets/token1";
-      };
-
-      runner2 = {
-        enable = true;
-        url = "https://github.com/owner/repo";
-        name = "runner2";
-        tokenFile = "/secrets/token2";
-      };
-    };
-    description = lib.mdDoc ''
-      Multiple GitHub Runners.
-    '';
-  };
-
-  config = {
-    assertions = flatten (
-      flip mapAttrsToList config.services.github-runners (name: cfg: map (mkIf cfg.enable) [{
-        assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
-        message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
-      }])
-    );
-
-    systemd.services = flip mapAttrs' cfg (n: v:
-      let
-        svcName = "github-runner-${n}";
-      in
-        nameValuePair svcName
-        (import ./github-runner/service.nix (args // {
-          inherit svcName;
-          cfg = v // {
-            name = if v.name != null then v.name else n;
-          };
-          systemdDir = "github-runner/${n}";
-        }))
-    );
-  };
-
-  meta.maintainers = with maintainers; [ veehaitch newam ];
+  meta.maintainers = with lib.maintainers; [ veehaitch newam ];
 }