nixos/github-runners: GitHub App authentication and multi-org runners (#533377)

This commit is contained in:
Jörg Thalheim
2026-08-18 02:36:32 +00:00
committed by GitHub
6 changed files with 656 additions and 44 deletions

View File

@@ -0,0 +1,88 @@
# shellcheck shell=bash
# Request an installation access token for a GitHub App.
#
# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT,
# Copyright (c) 2020 Marcus Young), see:
# https://github.com/orgs/community/discussions/24743#discussioncomment-3245300
#
# Expects the following environment variables:
# * APP_ID the GitHub App's ID
# * APP_PRIVATE_KEY the GitHub App's PEM-encoded private key (contents)
# * APP_LOGIN the org/user login the App is installed on
# * GITHUB_HOST optional, defaults to github.com (set for GHES)
#
# Prints the installation access token (prefixed with `ghs_`) to stdout.
_GITHUB_HOST=${GITHUB_HOST:="github.com"}
# If the host is not github.com, use the GitHub Enterprise Server API endpoint.
if [[ ${_GITHUB_HOST} == "github.com" ]]; then
URI="https://api.${_GITHUB_HOST}"
else
URI="https://${_GITHUB_HOST}/api/v3"
fi
API_VERSION=v3
API_HEADER="Accept: application/vnd.github.${API_VERSION}+json"
CONTENT_LENGTH_HEADER="Content-Length: 0"
APP_INSTALLATIONS_URI="${URI}/app/installations"
# JWT token issuance and expiration parameters, see:
# https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
JWT_IAT_DRIFT=60
JWT_EXP_DELTA=600
JWT_JOSE_HEADER='{
"alg": "RS256",
"typ": "JWT"
}'
build_jwt_payload() {
now=$(date +%s)
iat=$((now - JWT_IAT_DRIFT))
jq -c \
--arg iat_str "${iat}" \
--arg exp_delta_str "${JWT_EXP_DELTA}" \
--arg app_id_str "${APP_ID}" \
'
($iat_str | tonumber) as $iat
| ($exp_delta_str | tonumber) as $exp_delta
| ($app_id_str | tonumber) as $app_id
| .iat = $iat
| .exp = ($iat + $exp_delta)
| .iss = $app_id
' <<<"{}" | tr -d '\n'
}
base64url() {
base64 | tr '+/' '-_' | tr -d '=\n'
}
rs256_sign() {
openssl dgst -binary -sha256 -sign <(echo "$1")
}
request_access_token() {
jwt_payload=$(build_jwt_payload)
encoded_jwt_parts=$(base64url <<<"${JWT_JOSE_HEADER}").$(base64url <<<"${jwt_payload}")
encoded_mac=$(echo -n "${encoded_jwt_parts}" | rs256_sign "${APP_PRIVATE_KEY}" | base64url)
generated_jwt="${encoded_jwt_parts}.${encoded_mac}"
auth_header="Authorization: Bearer ${generated_jwt}"
app_installations_response=$(
curl -fsSX GET \
-H "${auth_header}" \
-H "${API_HEADER}" \
"${APP_INSTALLATIONS_URI}"
)
access_token_url=$(echo "${app_installations_response}" | jq --raw-output '.[] | select (.account.login == "'"${APP_LOGIN}"'" and .app_id == '"${APP_ID}"') .access_tokens_url')
curl -fsSX POST \
-H "${CONTENT_LENGTH_HEADER}" \
-H "${auth_header}" \
-H "${API_HEADER}" \
"${access_token_url}" |
jq --raw-output .token
}
request_access_token

View File

@@ -42,7 +42,8 @@
};
url = lib.mkOption {
type = lib.types.str;
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Repository to add the runner to.
@@ -55,12 +56,16 @@
Otherwise, you are going to get a `404 NotFound`
from `POST https://api.github.com/actions/runner-registration`
in the configure script.
Mandatory unless `orgs` is used, in which case the URL is taken
from each `orgs.<name>.url` instead and this option is ignored.
'';
example = "https://github.com/nixos/nixpkgs";
};
tokenFile = lib.mkOption {
type = lib.types.path;
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The full path to a file which contains either
@@ -68,6 +73,10 @@
* a classic PAT
* or a runner registration token
Exactly one of `tokenFile` and `githubApp` must be set. Use
`githubApp` to authenticate via a GitHub App installation instead
of a token file.
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
@@ -126,6 +135,71 @@
default = "auto";
};
githubApp = lib.mkOption {
default = null;
description = ''
Authenticate the runner using a GitHub App installation instead
of a `tokenFile`. Exactly one of `tokenFile` and `githubApp` must
be set.
On every start the service derives a short-lived installation
access token from the App's private key, uses it to fetch a fresh
runner registration token and registers the runner with it. This
avoids storing a long-lived personal access token on the host and
pairs well with `ephemeral` runners.
The App needs read and write access to the
"self-hosted runners" administration of the organisation (or
repository) given in `url`, and must be installed on the `login`
below.
'';
example = lib.literalExpression ''
{
id = 123456;
login = "my-org";
privateKeyFile = "/run/secrets/github-app.pem";
}
'';
type = lib.types.nullOr (
lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.int;
description = "The GitHub App's ID (the numeric `App ID`, not the client ID).";
example = 123456;
};
login = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
The organisation (or user) login the GitHub App is
installed on. Used to look up the App installation and,
for organisation-wide runners, as the registration scope.
Mandatory unless `orgs` is used, in which case the login
is taken from each `orgs.<name>.login` instead and this
option is ignored.
Changing this option triggers a new runner registration.
'';
example = "my-org";
};
privateKeyFile = lib.mkOption {
type = lib.types.path;
description = ''
The full path to a file containing the GitHub App's
PEM-encoded private key. The file should be deployed as a
secret and is never copied into the Nix store.
'';
example = "/run/secrets/github-app.pem";
};
};
}
);
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
@@ -137,6 +211,107 @@
default = name;
};
count = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
example = 4;
description = ''
Number of identical runner instances to create.
Without `orgs`, this fans the single entry (using the
entry-level `url`) out into `count` runner services:
`github-runner-<name>` for `count == 1` (unchanged) and
`github-runner-<name>-<n>` for `count > 1`. Each instance
registers under a distinct runner name.
With `orgs`, this is the default replica count for every org
that does not set its own; see `orgs.<name>.count`.
Pairs well with `ephemeral`.
'';
};
orgs = lib.mkOption {
default = { };
description = ''
Organisations (or repositories) to serve from this entry.
When set, the entry fans out into one systemd service per runner
named `github-runner-<name>-<org>-<n>`, where `<org>` is the
attribute name and `<n>` ranges over the per-org `count`. The
entry-level `githubApp`/`tokenFile` is shared across every org;
only the App `login` changes per org (defaulting to the
attribute name), so a single GitHub App installed on multiple
orgs serves all of them.
Leaving this empty (the default) keeps the single-runner
behaviour: the entry-level `url`, `name` and auth, fanned out by
the entry-level `count`.
'';
example = lib.literalExpression ''
{
org-a.count = 12;
org-b = {
count = 2;
extraLabels = [ "org-b" ];
};
}
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
url = lib.mkOption {
type = lib.types.str;
default = "https://github.com/${name}";
defaultText = lib.literalExpression ''"https://github.com/''${name}"'';
description = ''
URL of the organisation (or repository) to connect to.
Defaults to the GitHub URL derived from the attribute name.
'';
};
login = lib.mkOption {
type = lib.types.str;
default = name;
defaultText = lib.literalExpression "\${name}";
description = ''
GitHub login (org or user) the shared `githubApp` is
installed on. Defaults to the attribute name. Ignored
when authenticating via `tokenFile`.
'';
};
count = lib.mkOption {
type = lib.types.ints.positive;
default = config.count;
defaultText = lib.literalMD "the entry-level `count`";
example = 4;
description = ''
Number of identical runner instances to create for this
org. Defaults to the entry-level `count`. Each gets its
own systemd service named `github-runner-<name>-<org>-<n>`
and registers under a distinct runner name. Pairs well
with `ephemeral`.
'';
};
extraLabels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = lib.literalExpression ''[ "org-a" ]'';
description = ''
Extra labels added, on top of the entry-level `extraLabels`,
only to this org's runners.
'';
};
};
}
)
);
};
runnerGroup = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''

View File

@@ -0,0 +1,58 @@
# shellcheck shell=bash
# Fetch a self-hosted runner registration token from the GitHub API.
#
# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT,
# Copyright (c) 2020 Marcus Young).
#
# Expects the following environment variables:
# * ACCESS_TOKEN a token authorized to manage self-hosted runners
# (a GitHub App installation token or a suitable PAT)
# * RUNNER_SCOPE one of `org`, `ent` or `repo`
# * ORG_NAME the org login (for `org` scope)
# * ENTERPRISE_NAME the enterprise slug (for `ent` scope)
# * REPO_URL the repository URL (for `repo` scope)
# * GITHUB_HOST optional, defaults to github.com (set for GHES)
#
# Prints `{"token": ..., "full_url": ...}` to stdout.
_GITHUB_HOST=${GITHUB_HOST:="github.com"}
# If the host is not github.com, use the GitHub Enterprise Server API endpoint.
if [[ ${_GITHUB_HOST} == "github.com" ]]; then
URI="https://api.${_GITHUB_HOST}"
else
URI="https://${_GITHUB_HOST}/api/v3"
fi
API_VERSION=v3
API_HEADER="Accept: application/vnd.github.${API_VERSION}+json"
AUTH_HEADER="Authorization: token ${ACCESS_TOKEN}"
CONTENT_LENGTH_HEADER="Content-Length: 0"
case ${RUNNER_SCOPE} in
org*)
_FULL_URL="${URI}/orgs/${ORG_NAME}/actions/runners/registration-token"
;;
ent*)
_FULL_URL="${URI}/enterprises/${ENTERPRISE_NAME}/actions/runners/registration-token"
;;
*)
_PROTO="https://"
_URL="${REPO_URL/${_PROTO}/}"
_PATH="$(echo "${_URL}" | grep / | cut -d/ -f2-)"
_ACCOUNT="$(echo "${_PATH}" | cut -d/ -f1)"
_REPO="$(echo "${_PATH}" | cut -d/ -f2)"
_FULL_URL="${URI}/repos/${_ACCOUNT}/${_REPO}/actions/runners/registration-token"
;;
esac
RUNNER_TOKEN="$(curl -fsSX POST \
-H "${CONTENT_LENGTH_HEADER}" \
-H "${AUTH_HEADER}" \
-H "${API_HEADER}" \
"${_FULL_URL}" |
jq -r '.token')"
echo "{\"token\": \"${RUNNER_TOKEN}\", \"full_url\": \"${_FULL_URL}\"}"

View File

@@ -0,0 +1,71 @@
# shellcheck shell=bash
# Force-remove a previously registered, offline self-hosted runner via the
# GitHub API. Used before re-registering to avoid orphaned runners piling up in
# the GitHub Actions UI.
#
# Adapted from https://github.com/myoung34/docker-github-actions-runner (MIT,
# Copyright (c) 2020 Marcus Young).
#
# Expects the following environment variables:
# * ACCESS_TOKEN a token authorized to manage self-hosted runners
# * RUNNER_NAME the name of the runner to remove
# * RUNNER_SCOPE one of `org`, `ent` or `repo`
# * ORG_NAME the org login (for `org` scope)
# * ENTERPRISE_NAME the enterprise slug (for `ent` scope)
# * REPO_URL the repository URL (for `repo` scope)
# * GITHUB_HOST optional, defaults to github.com (set for GHES)
_GITHUB_HOST=${GITHUB_HOST:="github.com"}
# If the host is not github.com, use the GitHub Enterprise Server API endpoint.
if [[ ${_GITHUB_HOST} == "github.com" ]]; then
URI="https://api.${_GITHUB_HOST}"
else
URI="https://${_GITHUB_HOST}/api/v3"
fi
API_HEADER="Accept: application/vnd.github+json"
AUTH_HEADER="Authorization: token ${ACCESS_TOKEN}"
CONTENT_LENGTH_HEADER="Content-Length: 0"
runners_url() {
case ${RUNNER_SCOPE} in
org*)
echo "${URI}/orgs/${ORG_NAME}/actions/runners"
;;
ent*)
echo "${URI}/enterprises/${ENTERPRISE_NAME}/actions/runners"
;;
*)
_PROTO="https://"
_URL="${REPO_URL/${_PROTO}/}"
_PATH="$(echo "${_URL}" | grep / | cut -d/ -f2-)"
_ACCOUNT="$(echo "${_PATH}" | cut -d/ -f1)"
_REPO="$(echo "${_PATH}" | cut -d/ -f2)"
echo "${URI}/repos/${_ACCOUNT}/${_REPO}/actions/runners"
;;
esac
}
_RUNNERS_URL="$(runners_url)"
RUNNERS="$(curl -fsSX GET \
-H "${CONTENT_LENGTH_HEADER}" \
-H "${AUTH_HEADER}" \
-H "${API_HEADER}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${_RUNNERS_URL}")"
RUNNER_ID=$(echo "$RUNNERS" | jq -r '.runners[] | select( (.name == env.RUNNER_NAME) and (.status == "offline") ) | .id')
if [[ $RUNNER_ID == "" ]]; then
echo "Runner ${RUNNER_NAME} doesn't exist or is online. Nothing to unregister."
exit 0
fi
echo "${RUNNER_NAME} is still registered and offline. Forcing removal..."
curl -fsSX DELETE \
-H "${CONTENT_LENGTH_HEADER}" \
-H "${AUTH_HEADER}" \
-H "${API_HEADER}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"${_RUNNERS_URL}/${RUNNER_ID}"

View File

@@ -9,6 +9,22 @@
lib.flip lib.mapAttrsToList config.services.github-runners (
name: cfg:
map (lib.mkIf cfg.enable) [
{
assertion = (cfg.tokenFile == null) != (cfg.githubApp == null);
message = "`services.github-runners.${name}`: Exactly one of `tokenFile` and `githubApp` must be set";
}
{
assertion = cfg.orgs != { } || cfg.url != null;
message = "`services.github-runners.${name}`: `url` must be set unless `orgs` is used";
}
{
assertion = cfg.orgs != { } || cfg.githubApp == null || cfg.githubApp.login != null;
message = "`services.github-runners.${name}`: `githubApp.login` must be set unless `orgs` is used (the login is then derived per org)";
}
{
assertion = cfg.orgs != { } || cfg.count == 1 || cfg.name != null;
message = "`services.github-runners.${name}`: `name` must not be null when `count > 1` (each replica needs a distinct registration name)";
}
{
assertion = !cfg.noDefaultLabels || (cfg.extraLabels != [ ]);
message = "`services.github-runners.${name}`: The `extraLabels` option is mandatory if `noDefaultLabels` is set";
@@ -24,8 +40,49 @@
config.systemd.services =
let
enabledRunners = lib.filterAttrs (_: cfg: cfg.enable) config.services.github-runners;
runnerInstances = lib.concatMapAttrs (
name: cfg:
if cfg.orgs == { } then
# Single org/repo (entry-level `url`), fanned out by `count`. For
# `count == 1` the service keeps the bare `github-runner-<name>` name
# for backwards compatibility; `count > 1` suffixes `-<n>`.
let
suffixes = if cfg.count == 1 then [ "" ] else map (n: "-${toString n}") (lib.range 1 cfg.count);
in
lib.listToAttrs (
map (
suffix:
lib.nameValuePair "${name}${suffix}" (
cfg // { name = if cfg.name == null then null else "${cfg.name}${suffix}"; }
)
) suffixes
)
else
lib.listToAttrs (
lib.concatLists (
lib.flip lib.mapAttrsToList cfg.orgs (
orgName: org:
map (
n:
let
key = "${name}-${orgName}-${toString n}";
in
lib.nameValuePair key (
cfg
// {
url = org.url;
name = key;
extraLabels = cfg.extraLabels ++ org.extraLabels;
githubApp = if cfg.githubApp == null then null else cfg.githubApp // { login = org.login; };
}
)
) (lib.range 1 org.count)
)
)
)
) enabledRunners;
in
(lib.flip lib.mapAttrs' enabledRunners (
(lib.flip lib.mapAttrs' runnerInstances (
name: cfg:
let
svcName = "github-runner-${name}";
@@ -41,6 +98,66 @@
currentConfigTokenFilename = ".current-token";
workDir = if cfg.workDir == null then runtimeDir else cfg.workDir;
newConfigTokenPath = "$STATE_DIRECTORY/.new-token";
currentConfigTokenPath = "$STATE_DIRECTORY/${currentConfigTokenFilename}";
# 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 =
scriptName: lines:
pkgs.writeShellScript "${svcName}-${scriptName}.sh" ''
set -euo pipefail
STATE_DIRECTORY="$1"
WORK_DIRECTORY="$2"
LOGS_DIRECTORY="$3"
${lines}
'';
ghUrlPath = lib.removePrefix "https://github.com/" cfg.url;
ghUrlSegments = lib.filter (s: s != "") (lib.splitString "/" ghUrlPath);
runnerScope = if lib.length ghUrlSegments >= 2 then "repo" else "org";
appHelper =
helperName: scriptFile:
pkgs.writeShellApplication {
name = helperName;
runtimeInputs = with pkgs; [
jq
curl
openssl
coreutils
];
excludeShellChecks = [
"SC2154"
"SC2116"
];
text = builtins.readFile scriptFile;
};
fetchAccessToken = appHelper "github-app-access-token" ./app-token.sh;
fetchRegistrationToken = appHelper "github-runner-registration-token" ./registration-token.sh;
removeRunner = appHelper "github-runner-remove" ./remove-runner.sh;
appEnv = lib.optionalString (cfg.githubApp != null) ''
export APP_ID=${toString cfg.githubApp.id}
export APP_LOGIN=${lib.escapeShellArg cfg.githubApp.login}
APP_PRIVATE_KEY="$(cat ${lib.escapeShellArg cfg.githubApp.privateKeyFile})"
export APP_PRIVATE_KEY
export RUNNER_SCOPE=${runnerScope}
export ORG_NAME=${lib.escapeShellArg cfg.githubApp.login}
export REPO_URL=${lib.escapeShellArg cfg.url}
${
if cfg.name != null then
"export RUNNER_NAME=${lib.escapeShellArg cfg.name}"
else
''export RUNNER_NAME="$(uname -n)"''
}
'';
in
lib.nameValuePair svcName {
description = "GitHub Actions runner";
@@ -84,24 +201,6 @@
# - 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
STATE_DIRECTORY="$1"
WORK_DIRECTORY="$2"
LOGS_DIRECTORY="$3"
${lines}
'';
runnerRegistrationConfig = lib.getAttrs [
"ephemeral"
"extraLabels"
@@ -114,8 +213,6 @@
] 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}";
runnerCredFiles = [
".credentials"
@@ -164,6 +261,23 @@
# Always clean workDir
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
'';
unconfigureRunnerGitHubApp = writeScript "unconfigure-github-app" ''
${appEnv}
ACCESS_TOKEN="$(${lib.getExe' fetchAccessToken "github-app-access-token"})"
export ACCESS_TOKEN
${lib.getExe' removeRunner "github-runner-remove"} || true
find "$STATE_DIRECTORY/" -mindepth 1 -delete
umask 000
${lib.getExe' fetchRegistrationToken "github-runner-registration-token"} \
| ${pkgs.jq}/bin/jq -r '.token' > "${newConfigTokenPath}"
install --mode=600 "${newConfigTokenPath}" "${currentConfigTokenPath}"
# Always clean workDir
find -H "$WORK_DIRECTORY" -mindepth 1 -delete
'';
configureRunner =
writeScript "configure" # bash
''
@@ -185,24 +299,33 @@
${lib.optionalString cfg.noDefaultLabels "--no-default-labels"}
)
token=$(<"${newConfigTokenPath}")
case ${cfg.tokenType} in
access)
args+=(--pat "$token")
;;
registration)
args+=(--token "$token")
;;
auto)
# 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
if [[ "$token" =~ ^gh[a-z]+_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
args+=(--pat "$token")
${
if cfg.githubApp != null then
''
args+=(--token "$token")
''
else
args+=(--token "$token")
fi
;;
esac
''
case ${cfg.tokenType} in
access)
args+=(--pat "$token")
;;
registration)
args+=(--token "$token")
;;
auto)
# 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
if [[ "$token" =~ ^gh[a-z]+_* ]] || [[ "$token" =~ ^github_pat_* ]]; then
args+=(--pat "$token")
else
args+=(--token "$token")
fi
;;
esac
''
}
${cfg.package}/bin/Runner.Listener configure "''${args[@]}"
# Move the automatically created _diag dir to the logs dir
mkdir -p "$STATE_DIRECTORY/_diag"
@@ -234,11 +357,33 @@
}"
)
[
"+${unconfigureRunner}" # runs as root
# runs as root
"+${if cfg.githubApp != null then unconfigureRunnerGitHubApp else unconfigureRunner}"
configureRunner
setupWorkDir
];
ExecStopPost = lib.optionals (cfg.githubApp != null) (
let
unregister = writeScript "unregister-github-app" ''
${appEnv}
ACCESS_TOKEN="$(${lib.getExe' fetchAccessToken "github-app-access-token"})"
export ACCESS_TOKEN
${lib.getExe' removeRunner "github-runner-remove"} || true
'';
in
map (
x:
"${x} ${
lib.escapeShellArgs [
stateDir
workDir
logsDir
]
}"
) [ "-+${unregister}" ] # runs as root
);
# 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";
@@ -256,11 +401,12 @@
WorkingDirectory = workDir;
InaccessiblePaths = [
# Token file path given in the configuration, if visible to the service
"-${cfg.tokenFile}"
# Token file in the state directory
"${stateDir}/${currentConfigTokenFilename}"
];
]
# Token file path given in the configuration, if visible to the service
++ lib.optional (cfg.tokenFile != null) "-${cfg.tokenFile}"
++ lib.optional (cfg.githubApp != null) "-${cfg.githubApp.privateKeyFile}";
KillSignal = "SIGINT";

View File

@@ -6,6 +6,11 @@
};
nodes.machine =
{ pkgs, ... }:
let
appPrivateKey = pkgs.runCommand "github-app.pem" {
nativeBuildInputs = [ pkgs.openssl ];
} "openssl genrsa -out $out 2048";
in
{
services.github-runners.test = {
enable = true;
@@ -19,6 +24,55 @@
tokenFile = builtins.toFile "github-runner.token" "not-so-secret";
};
# Runner authenticated via a GitHub App installation. This exercises the
# module evaluation and the App authentication code path up to the point it
# contacts the (stubbed) GitHub API; a successful registration would
# require talking to the real API.
services.github-runners.test-app = {
enable = true;
url = "https://github.com/yaxitech";
githubApp = {
id = 123456;
login = "yaxitech";
privateKeyFile = appPrivateKey;
};
};
# A single org/repo entry with `count > 1` fans out into one systemd
# service per replica, named `github-runner-<name>-<n>`.
services.github-runners.test-replicas = {
enable = true;
url = "https://github.com/yaxitech";
tokenFile = builtins.toFile "github-runner.token" "not-so-secret";
count = 2;
};
# A single entry with `orgs` fans out into one systemd service per org and
# per replica, named `github-runner-<name>-<org>-<n>`.
services.github-runners.test-orgs = {
enable = true;
tokenFile = builtins.toFile "github-runner.token" "not-so-secret";
orgs = {
yaxitech.count = 2;
another = { };
};
};
# `orgs` combined with a shared GitHub App: the same App is used for every
# org and only the per-org `login` changes (derived from the attribute
# name), so no entry-level `githubApp.login` is needed.
services.github-runners.test-orgs-app = {
enable = true;
githubApp = {
id = 123456;
privateKeyFile = appPrivateKey;
};
orgs = {
yaxitech = { };
"another-org" = { };
};
};
systemd.services.dummy-github-com = {
wantedBy = [ "multi-user.target" ];
before = [ "github-runner-test.service" ];
@@ -42,6 +96,26 @@
machine.wait_until_succeeds("test -f /tmp/registration-connect")
# The GitHub App runner unit is generated and wired to the App auth path.
machine.succeed("systemctl cat github-runner-test-app.service | grep -F unconfigure-github-app")
# `count > 1` on a single entry fans out into one unit per replica with a
# `-<n>` suffix; there is no bare unit.
machine.succeed("systemctl cat github-runner-test-replicas-1.service")
machine.succeed("systemctl cat github-runner-test-replicas-2.service")
machine.fail("systemctl cat github-runner-test-replicas.service")
# `orgs` fans out into one unit per org and replica; there is no bare unit.
machine.succeed("systemctl cat github-runner-test-orgs-yaxitech-1.service")
machine.succeed("systemctl cat github-runner-test-orgs-yaxitech-2.service")
machine.succeed("systemctl cat github-runner-test-orgs-another-1.service")
machine.fail("systemctl cat github-runner-test-orgs.service")
# `orgs` with a shared GitHub App generates one App-authenticated unit per
# org, each with its per-org login derived from the attribute name.
machine.succeed("systemctl cat github-runner-test-orgs-app-yaxitech-1.service | grep -F unconfigure-github-app")
machine.succeed("systemctl cat github-runner-test-orgs-app-another-org-1.service | grep -F unconfigure-github-app")
machine.fail("systemctl list-unit-files | grep test-disabled")
'';
}