Operations
This guide covers configuring, running and maintaining ghalet on a single trusted host. Start with getting started for setup and the command overview .
- Configuration and credentials
- Direct execution and environment
- Capacity, retries and shutdown
- Applying configuration
- Diagnosis and garbage collection
- Logs
- Service deployment
- Deployment verification
- Trust boundary
Configuration and credentials
run and config validate select ~/.config/ghalet/config.yaml unless
--config is explicit. Without --config, status, apply, and
gc discover accessible daemons and select one only when exactly one responds.
Use ghalet list to see their management UIDs, sockets and running snapshots,
then --config PATH to select among multiple daemons. An explicit config
never falls back to a daemon using another config. If no daemon is found,
management commands use the per-user default config; gc retains its locked
offline maintenance behavior. list --config PATH filters the list and does
not require the file to exist.
Discovery probes private Unix sockets in /tmp/ghalet-<UID>/, subject to
filesystem permissions. Normally only the management user’s daemon is
accessible; root can also discover other users’ daemons. A candidate must
respond within one second with a config path matching its socket name.
Stale, unresponsive, unrelated, symlinked and insecure socket entries are
ignored, not deleted. Responses are limited to 4 MiB. Management operations
also verify the connected peer’s UID and require the daemon’s config to match
the selected path. No PID or process-name guessing is used.
Restart the daemon when upgrading the CLI.
No working-directory or /etc config search is performed. Relative paths
are based on the selected config’s directory, not the shell’s current
directory. Use absolute paths in service definitions; ~ is not expanded
inside YAML.
When set, systemd’s STATE_DIRECTORY and CACHE_DIRECTORY take precedence:
executions and recovery state use $STATE_DIRECTORY/runs, and runner archives
and digests use $CACHE_DIRECTORY/runners. Each variable must contain a single
absolute directory; relative paths and colon-separated lists are rejected.
These are application-specific directories, not XDG bases; see
systemd’s directory management
.
Otherwise, execution directories and recovery state use $XDG_STATE_HOME/ghalet/runs
(default: ~/.local/state/ghalet/runs). Runner archives and their official
SHA-256 digests share $XDG_CACHE_HOME/ghalet/runners
(default: ~/.cache/ghalet/runners). Unset, empty, or relative XDG values use
these HOME-based defaults. Paths follow the controller’s environment, not
runner.user or the config directory. Only one daemon can own a given execution
root; separate instances need distinct state directories.
These paths are not YAML settings.
Editing the file alone changes neither the running configuration nor the control address, which is derived from the normalized absolute config path. Connected management commands query the running snapshot even if the file is invalid or missing. See applying configuration for reloadable settings and restart requirements.
examples/config.yaml includes repository, organization, gh, PAT and App
examples. Each target contains its registration scope. The optional
credential mapping inherits defaults.credential when omitted. If no shared
credential is configured, it defaults to type: gh. An explicit target
credential replaces the whole shared credential; an empty mapping selects
gh instead of inheriting. Within a credential mapping, omitted type means
gh. PAT and App credentials require an explicit
type: pat or type: github-app; credential-specific fields do not select
the type automatically. An explicitly empty type is rejected.
For GitHub.com, scope accepts
OWNER/REPO for a repository or ORG for an organization; these are normalized
to HTTPS URLs before validation, authentication, and registration matching.
Full HTTPS URLs are also accepted and are required for other GitHub hosts.
Only repository and organization scopes are supported; runnerGroup is
organization-only. Each target requires scope and scaleSet, the GitHub
registration name and workflow runs-on label. Together with runnerGroup
(the default group when omitted), they uniquely identify the target.
The same scale set name can be used in different scopes or runner groups.
No local id is configured. A SHA-256 key derived from the normalized
registration identifies local state, credentials and diagnostic targetId
fields; status output also includes the scope, runner group and scale set name.
App installationId belongs to the credential and is inherited with the App’s
client ID and key path. Different installations can reference the same key path
in separate credentials. For a shared installation, put it in defaults.credential.
Managed setup information
App setup adds missing target registrations to the static configuration; credential registration leaves it unchanged. Both save authentication in a separate managed file; secrets remain in credential files, not inline in either YAML document. Handwritten credentials do not require a managed file.
The default managed file is <StateDir>/managed.yaml for the standard user
configuration. /etc/ghalet/config.yaml uses /var/lib/ghalet/managed.yaml
outside the service too, unless STATE_DIRECTORY selects another state root.
Other configuration paths use <StateDir>/managed/<SHA-256-of-config-path>.yaml.
The identity is the normalized absolute config path, regardless of whether
--config was supplied; moving a custom configuration changes its default
managed path.
Override the path with managedFile; relative paths use the static config’s
directory:
managedFile: /var/lib/ghalet/managed.yamlFor a custom systemd configuration, set an absolute managedFile or run setup,
validation and the service with the same STATE_DIRECTORY. Selecting another
config does not change the service unit. Keep the managed file outside runner
execution, cache and log directories. Generated files are owner-only; existing
managed files must be regular, owned by the controller and not writable by others.
Back up the managed file along with credentials and App resume metadata.
Only static targets become active. Managed target entries are matched by normalized
scope, runner group and scale set; authentication is never reused for a different
registration. Explicit target credential prevents
managed target authentication from being applied. Otherwise a managed target’s
complete credential is used ahead of shared defaults.
For shared defaults, static defaults.credential wins over the managed default;
without either, authentication remains gh. Credentials are replaced as units,
never merged field by field. Paths inside the managed file use its own directory.
To adopt generated defaults for an existing configuration, remove the static
defaults.credential deliberately, or use app setup --overwrite.
Likewise, remove a target’s handwritten authentication only when choosing to use
its managed entry. A missing managed file is treated as empty; malformed
files are errors. Changes take effect only at startup or explicit
apply; systemd credentials require a service restart.
The commented example
includes an optional
yaml-language-server comment selecting schema.yaml for editor completion
and validation.
Use ghalet config validate to also check constraints such as conflicting
registrations, minimum capacities, and managed directory safety.
Unknown keys are accepted and ignored at every level of static and managed configuration. Known fields are validated, and duplicate YAML keys are rejected.
For the default gh authentication, install GitHub CLI where ghalet runs
and log in as the account that actually starts the service. ghalet runs
gh auth token --hostname HOST with a 15-second deadline and does not write
the token to configuration. A login in an interactive user’s keychain does
not automatically grant access to a different service user or to a locked
keychain. Test the command as that user in the actual service environment.
Missing GitHub CLI or authentication is an error; ghalet does not fall back
to another credential type.
For PATs, select exactly one of tokenEnv and tokenFile. Credential files,
including App private keys, must be owner-only regular files (normally
chmod 600). Values are never accepted inline in YAML. A systemd credential
can be referenced by an absolute tokenFile or privateKeyFile path under
/run/credentials/ghalet.service/ with the corresponding LoadCredential= or
LoadCredentialEncrypted=. For encrypted registration, shared defaults and
rotation, see encrypted credentials
.
Do not put secrets into Environment= or workflow files unnecessarily.
See authentication setup and minimum permissions for scope-specific permissions, PAT creation and GitHub App installation. Group access policy and App installation access are separate. Create groups and grant their repository access in GitHub first: ghalet never creates, changes access to, or deletes runner groups.
Short-lived runner registration tokens are not a supported credential input.
Direct execution and environment
ghalet config validate --config /path/to/config.yaml
ghalet run --config /path/to/config.yamlJobs run as the launching user unless runner.user names another existing
account. A non-root controller can switch users through non-interactive sudo;
it must belong to the runner account’s primary group. The
packaged sudo policy
permits only ghalet’s runner-user
helper as ghalet-runner. For a custom installation, adjust the management
user, runner user/group, and executable path in that policy.
The helper launches runners, sends identity-checked stop signals, and makes
runner-owned files accessible to the shared group after shutdown for log
archiving and cleanup. It never runs jobs as root or receives management
credentials. Running the controller as root also supports direct user switching,
but requires an explicit non-root runner.user.
The CLI never creates or deletes users and refuses root jobs.
The official runner must list and traverse every ancestor of its
directory, up to the filesystem root. Managed execution parents (workRoot,
target and run directories) use mode 0755, as does the Linux package’s
/var/lib/ghalet. Directory names are visible, but these parents
remain management-owned and are not writable by other users; state and lock
files remain owner-only. Custom ancestors outside these managed directories
are not changed: make them listable and traversable by the runner user.
Administrator-selected storage bases (STATE_DIRECTORY, CACHE_DIRECTORY,
XDG bases or their HOME fallbacks, and an explicit logs.root) may use
symlinks, including macOS /var. ghalet resolves existing base links before
deriving managed paths; new state records and credential paths use these
canonical locations. Keep the bases and their ancestors trusted and stable
while ghalet runs. After stopping the daemon and its executions, a base may
be moved with its old path left as a symlink; recovery and App setup retries
reconcile persisted paths to that canonical base, without resolving managed
children.
Moving live executions is not supported. Systemd drop-ins keep their fixed
/etc/ghalet/credentials/... source references, so layout migrations do not
invalidate existing directives or expose target directory names to systemd
specifier expansion. Managed children (including execution directories, runner
caches, credential directories/files and archive deletion roots) must not be
redirected through symlinks. Cleanup does not follow links out of an owned
execution or archive. Config-file parents may also use administrator layout
links, but the selected config path keeps its lexical identity for the control
socket; resolving storage bases does not change that address. Path checks are
not protection against concurrent replacement of trusted directories.
Runner startup and its access check do not inherit the controller’s environment.
HOME, USER and LOGNAME are set from the execution user’s account, even when
the controller and runner use different users. PATH defaults to
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; macOS prefixes
/opt/homebrew/bin:/opt/homebrew/sbin.
No login shell or shell initialization files are read.
Use runner.env to explicitly add environment variables or override PATH,
for example for user-installed tools, locales or proxies. Controller token
variables, SSH agent sockets, tool configuration paths and other variables
are not copied automatically. Management credentials, account identity and
runner input variables cannot be set through runner.env; runner input names
are checked case-insensitively. A JIT secret is supplied only as
ACTIONS_RUNNER_INPUT_JITCONFIG, never a command-line argument or a state
field. Do not intentionally copy management secrets into another variable.
The runner’s own files are private to its execution directory and removed
after execution.
This minimizes the runner environment; the controller’s environment for authentication, API calls and downloads is unchanged. Filesystem and keychain access remain subject to the trust boundary .
Install build tools and the official runner’s OS prerequisites on the host before use. Docker actions, job containers and services are unsupported. No Docker daemon, Kubernetes, VM runtime or service manager is needed for native Linux/macOS execution. macOS ARM64/x64 and Linux ARM64/x64/ARM runner packages are selected from the pinned version. Windows is not supported.
runner is global and optional. Its version defaults to the numeric version
bundled with ghalet; set runner.version to pin a different release.
Updating ghalet and restarting picks up the new
default unless the configuration explicitly pins a version. Startup never
resolves latest, and runner self-update remains disabled.
The pinned archive is fetched from actions/runner releases, checked against
the release asset’s official SHA-256 digest, and checked again whenever the
cached archive is reused. Releases without an official asset digest fail
closed. The runner cache stores both the pinned digest and the archive;
each execution gets an independent extraction into its execution directory.
Cache preparation and verification are serialized; independent extractions can
proceed concurrently.
A corrupt cache is not silently replaced: remove the indicated bad archive,
then restart.
Capacity, retries and shutdown
scaling is optional: each target defaults to minRunners: 0 and
maxRunners: 2. Omitted fields retain their defaults; an explicit
maxRunners: 0 is invalid.
The desired total is min(maxRunners, assignedJobs + minRunners). Running
jobs, launches and uncertain recovered executions occupy the same shared
capacity pool. An accepted job takes precedence over another target’s idle
warm runner. Slots are not reused until the previous process group is
confirmed stopped. Busy runners are not stopped merely because demand falls.
A full warm pool does not prevent other targets from accepting jobs within the shared capacity limit. Cross-target fairness and coordination across multiple controllers are not provided.
Preparation has a 10-minute deadline, API operations 45 seconds, authentication
commands 15 seconds, and listener polls 60 seconds. Registration/listener
failures and preparation/start failures recover automatically with exponential
delays of 1/2/4/8/16/32 seconds, capped at one minute, without an attempt limit.
Startup registration is bounded to four attempts; remaining transient failures
recover in the background so other targets and control access remain available.
Permanent invalid-request/authentication errors exposed by the SDK and unsafe
registration, state, or credential-copying failures stop immediately and require
inspection and apply or a normal stop/restart. Other targets continue.
Preparation starts are rate-limited to one per second per target. Drain cancels
pending registration recovery; apply and shutdown cancel the old listeners.
An unresolved cleanup of a failed preparation or abnormally exited runner
suspends new starts for that target until cleanup succeeds (or is resolved with
gc/restart), preventing repeated
failures from accumulating runner trees. Cleanup retry policy is unchanged.
Transient listener and preparation/start failures remain visible in status
until the corresponding listener or runner startup recovers.
Workflow results are not retried by ghalet.
SIGINT/SIGTERM starts drain for all targets during normal shutdown.
apply also drains all targets before switching configuration.
Normal shutdown retains scale sets.
New acquisitions and warm replenishment stop, but already assigned,
unstarted jobs can still provision runners within both limits. The drain
deadline starts at the request, not at each runner’s start. The default
drainTimeout is 10 minutes.
After that deadline, preparation is canceled and verified process groups,
including busy jobs, receive SIGTERM, followed by SIGKILL if they remain.
The controller waits for runner process groups to stop and local exit/output
observers to finish before exiting. Unknown identities are not signaled by
name or PID alone and continue to block shutdown until their groups are
confirmed absent. Their records and diagnostics remain for inspection;
absence alone does not authorize cleanup. Forced shutdown is reported as
incomplete, not success.
For changes requiring a restart, stop the daemon normally and
verify successful shutdown before starting it again. In the foreground,
check the exit status of ghalet run; under a service manager, check the
daemon’s exit result and logs rather than only the stop command’s result.
Do not proceed with a restart after forced or incomplete drain; inspect
diagnostics and resolve retained executions or cleanup failures first.
On Linux and macOS, each new runner has a foreground guard, whether run directly or through a service manager. If the controller exits or is killed, the guard forcibly stops its runner process group. After a normal runner exit, it also terminates remaining group members, allowing two seconds before forced cleanup. Exit diagnostics report the runner’s actual result. State and logs remain for recovery. This is child supervision, not job isolation: trusted jobs must not kill their guard or escape its group, and kernel/host failure can interrupt supervision.
Applying configuration
ghalet apply --config /path/to/config.yaml
# Packaged systemd service:
sudo systemctl reload ghaletEdit the file and run apply to change targets, limits, credentials, runner
versions, environment, timeouts or log policy. It requires a running daemon
and reloads that daemon’s original config path without modifying the file.
Missing or invalid configuration fails rather than falling back to another file.
apply waits for reconciliation. It validates the new configuration before
stopping new job acceptance, drains all targets
under the old limits/timeouts, closes their listeners, then registers targets
from the new configuration and waits for their listener initialization.
Unchanged scale sets are reused. Startup failures are reported by apply;
later listener failures remain visible through status.
The normalized scope/group/scale set combination identifies each registration;
changing any part replaces that registration. Use
targets: [] to remove all targets. Missing or null targets is invalid.
Scale sets from removed or replaced targets are deleted only if no new target references their scope and registration ID (or scope/group/name), no local execution remains, drain and listener cleanup succeeded, and GitHub statistics show no accepted jobs or registered runners. Runner groups are never deleted. Do not share a managed scale set with another controller.
Failures are reported per target and return nonzero; healthy targets still
use the new configuration. There is no automatic rollback. Failed deletions
retain their old client in memory and can be retried with apply; forced drain,
uncertain executions and listener-close failures require inspection. Pending
deletions are not persisted across daemon restarts. After a crash, current
configuration is authoritative; unreachable old registrations may need manual
cleanup in GitHub. Retained execution records are never discarded just to free
capacity. Concurrent apply requests are rejected; shutdown interrupts apply.
If the CLI disconnects or times out, the daemon continues the request; inspect
status before retrying.
Execution-user and managed-directory changes require a normal stop/restart
and are rejected before drain.
Environment variables and systemd’s decrypted credential files belong to the
running service; the provided units require a restart to refresh those inputs. Ordinary
credential files are reread when apply creates new clients.
The provided systemd units run ghalet apply synchronously through
ExecReload and allow 15 minutes via TimeoutStartSec; extend this reload
deadline and TimeoutStopSec when increasing drain timeouts or target counts.
systemctl daemon-reload only reloads unit definitions, not ghalet configuration.
Diagnosis and garbage collection
During slow GitHub requests, runner preparation, process stopping, and cleanup,
other targets can continue scheduling, and status returns the last
coherent execution snapshot
while an operation is in progress. A reservation remains visible until its
preparation or cleanup result is published. Drain and apply wait for execution
operations to finish; gc still waits for the cleanup it reports rather than
returning merely queued work.
ghalet status --config /path/to/config.yaml
ghalet status --fail-on-error --config /path/to/config.yaml
ghalet gc --config /path/to/config.yaml --dry-run
ghalet gc --config /path/to/config.yamlManagement-command responses are JSON. status always includes diagnostic
details in status.problems and, when problems exist, a top-level error summary.
It returns zero when it retrieves a snapshot, including an unhealthy one.
status --fail-on-error produces the same JSON but returns nonzero for any
diagnostic problem. Failure to retrieve a snapshot is always nonzero.
This replaces the former doctor command.
gc and apply are nonzero for any failed/incomplete part and
include individual results. A partial management failure does not terminate
the daemon or roll back successful cleanup.
Connected GC is performed by the daemon, serialized with its
runtime state. If disconnected, direct GC must acquire the same
OS workRoot lock and the config’s control-address lock. A failed socket
connection alone never permits bypassing an existing daemon. Lock files
are not PID files and may remain after a process exits.
Offline GC must use the same storage environment as the service to find its state
(for example, STATE_DIRECTORY=/var/lib/ghalet and
CACHE_DIRECTORY=/var/cache/ghalet for the packaged service).
After a crash, records are matched to current explicit registrations, process
UID/group/kernel start identity and, for live executions, GitHub runner IDs.
Linux identity includes the boot ID and /proc start ticks; macOS uses the
kernel process start time with microsecond precision. Process liveness is checked
independently of registration and execution-user matching. Records whose processes
are verified stopped do not reserve capacity, even if a target was removed,
authentication fails, or registration/user matching prevents cleanup. Their
records, files and diagnostics remain; stopping alone does not authorize deletion.
Potentially live or unverifiable processes still reserve conservative capacity.
Malformed records also reserve capacity because they provide no trusted identity
with which to prove process absence; they are never used to authorize cleanup.
Ordinary files directly in workRoot or a target directory are retained and logged
as warnings without reserving capacity; unrecognized directories and symlinks
remain conservative diagnostics.
Recovery of a surviving process cannot recover its exit code.
Descendants observed in the original process group can be recovered; escaped
descendants cannot be completely detected or isolated.
An empty or zombie-only process listing alone is not proof that an execution has stopped. ghalet revalidates process identities; failed verification retains uncertainty and capacity. State files are limited to 1 MiB, including their final newline. Oversized saves fail without replacing the last readable record, including when a large descendant list cannot be persisted before signaling.
For manual recovery, inspect the named paths/processes, wait for or explicitly
stop only the identified execution, then run gc --dry-run and gc. Correct
configuration/permission issues and restart to revalidate. Do not delete
state alone to manufacture free capacity. A corrupt record requires manual
inspection of its actual execution before repairing/removing that record.
Logs
Daemon stdout is exclusively JSONL (time, level, msg, optional event,
scope, targetId, runId, numeric durationMs, and error). Newlines are
escaped within a record; runner output is never mixed into stdout. Configure
your collector to select structured fields and perform any forwarding or
OTel conversion externally. There is no embedded HTTP health endpoint,
metrics exporter, OTel SDK or collector dependency.
The scope field in runner-start events and archive metadata is the
registration URL.
The logs settings control runner log archives, not daemon stdout/stderr.
With logs.preserve: true, output.log and _diag/**/*.log are archived to
logs.root/runs/RUN_ID before removing the execution. logs.root
defaults to $STATE_DIRECTORY/logs when STATE_DIRECTORY is set, otherwise
$XDG_STATE_HOME/ghalet/logs, or ~/.local/state/ghalet/logs when
XDG_STATE_HOME is unset, empty, or relative.
An explicit logs.root overrides this default; relative values are
still resolved against the selected config’s directory. Credentials and
workspace contents are not archived. Failed archives preserve the original
logs and state. Once processes are confirmed stopped, cleanup failures do not
hold execution capacity or indefinitely block drain completion. During normal
operation, transient network/API failures (timeouts, rate limits and recoverable
server errors) and temporary filesystem failures are retried after 5 seconds,
with exponential waits capped at 5 minutes. Authentication/permission errors,
identity mismatches, invalid state and output-collection failures require
operator attention, not automatic retries. gc retries immediately regardless
of the wait; the next startup also revalidates retained records. Unknown
process or registration identities never authorize cleanup.
logs.retention expires only complete saved archives (also checked
hourly). There is no disk quota, disk-pressure monitor or automatic external
log shipping.
Live output capture and diagnostic-log archival limit each line to 64 KiB
(excluding the newline). An oversized line is omitted in full and replaced
with [ghalet: log line over 64 KiB omitted]; capture resumes at the next
newline. This bounds memory without leaking a secret through a partial line.
Known management and JIT values are redacted. Nonblank lines of multiline values, including App private keys, are also masked independently. The official runner is responsible for masking job secrets in its own logs. No mechanism can mask arbitrary secrets deliberately transformed or printed by a trusted workflow.
Service deployment
For packaged Linux installation or Docker Compose, see packages and containers . The instructions below cover manual service installation.
For Linux, precreate dedicated ghalet and ghalet-runner users with accessible
home directories. Add ghalet to the runner’s primary group, ghalet-runner,
but not the reverse. Set runner.user: ghalet-runner and install
packaging/sudoers
as /etc/sudoers.d/ghalet, owned by
root with mode 0440; adjust its executable path to /usr/local/bin/ghalet
and validate it with visudo -cf /etc/sudoers.d/ghalet.
Copy examples/ghalet.service into /etc/systemd/system/, and place the
configuration at /etc/ghalet/config.yaml. The unit uses StateDirectory=ghalet
and CacheDirectory=ghalet to create /var/lib/ghalet and /var/cache/ghalet
for the service user and pass them through STATE_DIRECTORY and CACHE_DIRECTORY.
No XDG overrides are needed. Set logs.root
only if overriding the default state-based log directory. Authenticate as that
user, run configuration validation, then systemctl enable --now ghalet.
KillMode=mixed sends the initial stop signal only to the controller, allowing
it to drain jobs and terminate runner groups before exiting. The finite
TimeoutStopSec=15min is an independent outer deadline: if the controller
hangs or cannot verify a remaining group, systemd forcibly stops all remaining
processes in the service’s cgroup, including the controller and runners.
It also kills remaining service processes if the controller crashes/exits.
This last-resort cleanup deliberately takes precedence over retaining live
runners for recovery. State and logs remain for recovery after interruption.
Keep the service deadline longer than the configured drain timeout, with
allowance for termination, API calls and cleanup; the examples allow five
additional minutes. Host shutdown/power loss can still interrupt jobs.
For native macOS, customize absolute paths in
examples/com.github.ghalet.plist, create the log directory, and place the
file in ~/Library/LaunchAgents/. Use launchctl bootstrap gui/$(id -u) FILE.
The 900-second service stop limit exceeds the default 10-minute drain by five
minutes; increase it if changing the configured timeout. Zero would mean an
infinite launchd stop wait and is deliberately avoided. AbandonProcessGroup
is false so launchd also cleans up the controller’s own group. Separate runner
groups use the guards described above
.
LaunchAgents start at login, not before login, and do not prevent sleep.
For another runner user, set runner.user and configure group membership and
non-interactive sudo as described above. For startup before login, use an
administrator-managed LaunchDaemon with UserName set to the controller account.
Test keychain access and shutdown behavior in
the actual login/service context.
For Apple Silicon Lima, examples/lima.yaml supplies an Ubuntu 26.04 ARM64 VM
with VZ, 4 vCPU, 8 GiB, 80 GiB, no host mounts, no containerd, installation
and an enabled systemd service. Provisioning does not copy host credentials.
It builds the published ghalet module with Go’s toolchain selection.
Copy a configuration explicitly with limactl copy, log in
as the guest ghalet user or supply guest-only credentials, and start the
service. The service is conditionally skipped until configuration exists.
examples/com.github.ghalet-lima.plist starts the already-created VM at
login; customize the limactl path and instance name. It does not restart the
VM per job or promise operation while the Mac sleeps/logs out. Inspect
limactl list and guest mount output to confirm no macOS host shares are
exposed. VM lifecycle, sleep/wake and power-loss tests are operator
acceptance checks, not established by template validation.
Deployment verification
After setup or an update, run ghalet config validate, ghalet status --fail-on-error, and
a trusted test workflow for each target. Local validation alone does not prove
GitHub permissions or server-side job assignment.
Confirm organization/App permissions, runner group access, multi-target
scheduling, service shutdown, reboot, and sleep/wake in the actual deployment
environment. Successful tests do not establish job isolation.
Trust boundary
ghalet registers each runner as ephemeral to handle one job. After confirming that the runner and its process group have stopped, ghalet cleans up its runner directory and workspace. If shutdown cannot be verified or cleanup fails, it retains the state and files. Ephemeral registration is not isolation: the runner is a native user process, not a per-job sandbox. Use only trusted workflows in trusted repositories, not fork PRs that could introduce untrusted code.
Each runner has separate execution/work paths but shares an OS, execution user’s HOME, caches, tools, network and existing privileges. Jobs can modify that shared environment. They may access the execution user’s saved credentials, including GitHub CLI and keychain credentials.
Stripping token variables or supplying a job-scoped GH_TOKEN does not isolate
that storage. Use a separate execution user to separate those credentials.
The default same-user model also exposes controller files/processes to jobs; file permissions, redaction and a private socket are not security boundaries against that user.
No extra sudo or Docker socket is provided and root jobs are refused, but preexisting privileges are not stripped. Use a dedicated account/VM and appropriate host controls.