Introduction
gcit (“GitHub CI triggers”) is a Linux + systemd daemon that watches one or
more git remotes for new commits on a configured ref. When the tip moves, it
dispatches a GitHub Actions workflow_dispatch against a target repository,
correlates the dispatch to its resulting run id, monitors the run to
completion, and notifies operators via Discord webhooks and/or local Unix
mail.
A single gcit daemon hosts many independent flows. Each flow has one source (git remote + ref), one action (workflow dispatch target), and zero or more destinations (Discord webhook, local mail). Flows are isolated: a panic, network failure, or auth error in one flow cannot crash the daemon or affect other flows.
This book is the operator manual: how to install, configure, and run gcit. For badges and license see the project README.
Why gcit
- Polling instead of push. GitHub Actions can already react to its own push events, but many real-world CI triggers come from upstream repos you don’t control: the kernel mainline, a vendor SDK, a security-advisory feed. gcit polls those sources on a configurable cadence and converts SHA changes into GitHub Actions runs without relying on webhooks the upstream cannot install.
- One daemon, many flows. A flow is a (source, action, destinations)
triple. gcit reloads flows live on
SIGHUP; flows whose config did not change keep their state and credentials, while changed and removed flows are cancelled cleanly and the new generation starts a fresh poll cycle. - systemd-native. gcit runs as a Type=notify unit with socket activation
for the control channel,
LoadCredential=for secrets,DynamicUser=yesby default (orUser=gcit+Group=mailwhenlocal_mailis configured), and a full hardening profile. - Strict secrets. Credentials are wrapped in
secrecy::SecretString, redacted inDebug/Displayand in CLI output. Credential files must have no group or other access bits set, and must be owned by the daemon’s effective uid (or root).
What gcit is not
- Not a generic webhook receiver. gcit polls; it does not accept inbound HTTP from GitHub.
- Not a workflow runner. gcit dispatches to GitHub Actions and observes the result; the runner side is GitHub’s responsibility.
- Not portable. The crate emits a
compile_error!on non-Linux targets. systemd is the supported deployment surface;--foregroundexists for development and is not the production path.
Status
gcit is pre-1.0. The wire format of the state file, the control protocol, and the config schema are subject to change before 1.0.
Requirements
- Linux.
- systemd.
--foregroundmode is intended for development and testing; the supported deployment surface is the systemd units installed bygcit install. - Rust 1.91 or newer to build from source.
Quick start
This walkthrough installs gcit system-wide, configures one flow that watches the Linux mainline tree and dispatches a GitHub Actions workflow on every new commit, and starts the daemon under systemd.
1. Build and install the binary
git clone https://github.com/likewhatevs/gcit
cd gcit
cargo build --release
sudo install -m 0755 target/release/gcit /usr/local/bin/gcit
2. Author a minimal config
Create /etc/gcit/config.toml:
[poll]
source_interval = "60s"
job_interval = "30s"
jitter = 0.1
[log]
filter = "info,gcit=debug"
[http]
request_timeout = "30s"
[[flow]]
name = "linux-mainline-ci"
enabled = true
description = "Watch torvalds/linux master and dispatch our CI builder."
[flow.source]
url = "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git"
ref = "refs/heads/master"
[flow.action]
kind = "github_workflow_dispatch"
repo = "myorg/linux-builder"
workflow = "ci.yml"
ref = "refs/heads/main"
credential_id = "github_pat"
inputs = { upstream_sha = "{{source.sha}}" }
[[flow.destination]]
kind = "discord_webhook"
credential_id = "discord_ci_webhook"
fire_on = ["run_complete"]
[flow.destination.template]
title = "{{flow.name}}: {{run.conclusion}}"
description = "linux@{{source.sha_short}} -> {{action.repo}}/{{action.workflow}}"
[[flow.destination]]
kind = "local_mail"
user = "ops"
fire_on = ["run_complete"]
local_mailrequires a system-scope install. The notifier writes directly to/var/mail/<user>and depends on the daemon being a member of the--userinstalls the daemon runs as the operator and cannot append to system mail spools —gcit install --userrejects any config containinglocal_maildestinations with a clear error. Usegcit install --systemfor any flow with alocal_maildestination, and the installer will switch the unit fromDynamicUser=yestoUser=gcit+Group=mailautomatically.
3. Drop credentials
Each credential_id referenced in the config needs a credential file at
<config_dir>/credentials/<credential_id>. The mode must satisfy
mode & 0o077 == 0 (no group or other access bits; e.g. 0400,
0600, 0700). The file must be owned by the daemon’s effective uid
or by root.
sudo install -m 0600 -o root -g root /path/to/token /etc/gcit/credentials/github_pat
sudo install -m 0600 -o root -g root /path/to/url /etc/gcit/credentials/discord_ci_webhook
For a --user install:
chmod 0600 ~/.config/gcit/credentials/github_pat
chown $(id -u):$(id -g) ~/.config/gcit/credentials/github_pat
GitHub authentication accepts only fine-grained personal access tokens
(tokens beginning github_pat_).
See Credential management for the full resolution chain
($CREDENTIALS_DIRECTORY, env var, file) and ownership rules per install
scope.
4. Install systemd units
sudo gcit install --system --config /etc/gcit/config.toml
The install command previews every file path it will create ([exists] /
[new] per entry) and refuses to write without explicit confirmation.
It copies the config, emits gcit.service and gcit.socket, and prints
the next-step systemd commands. Pass --non-interactive for CI.
To preview the rendered systemd service unit without writing anything,
pass --dry-run: gcit install --system --dry-run --config /etc/gcit/config.toml
prints the unit text to stdout (suitable for piping to systemd-analyze security or diffing against an installed unit).
For a user-scope install (no local_mail destinations):
gcit install --user --config path/to/config.toml.
gcit uninstall reverses an install via the on-disk install manifest;
operator-modified files are detected by sha256 mismatch and the uninstall
refuses to proceed without --force.
5. Validate
gcit check
gcit check parses the config, validates every rule, prints every problem
in one pass (rather than stopping at the first), and verifies that every
credential id referenced by a flow can be resolved. See
Troubleshooting for the three exit states.
6. Start
sudo systemctl daemon-reload
sudo systemctl enable --now gcit.socket gcit.service
journalctl -u gcit -f
The first poll cycle records a baseline SHA without firing — gcit only dispatches when the previously recorded SHA differs from the just-observed one. The next poll that sees a changed tip will dispatch the workflow, correlate the run id, monitor it to completion, and fire the configured notifiers.
7. Inspect
gcit status # all flows
gcit status linux-mainline-ci # one flow
gcit status --format json # machine-readable
gcit trigger linux-mainline-ci --dry-run # render the dispatch payload
The control socket is created by the gcit.socket unit (system path
/run/gcit/control.sock, user path $XDG_RUNTIME_DIR/gcit/control.sock)
with SocketMode=0600.
Configuration reference
gcit reads a TOML configuration file. The default path depends on who is running gcit and which subcommand is invoked.
Default config path
When --config is not passed, the default depends on the running euid and
subcommand:
gcit install --user/gcit uninstall --useralways resolve to the user-scope path.- For any other subcommand invoked by a non-root euid, gcit defaults to
$XDG_CONFIG_HOME/gcit/config.toml(or$HOME/.config/gcit/config.tomlwhen$XDG_CONFIG_HOMEis unset). A non-root shell runninggcit run --foreground,gcit check, orgcit statuswithout--configreads the operator’s user-scope config rather than the system one. - Root invocations without
--configuse the bottom default/etc/gcit/config.toml. - An explicit
--configalways wins regardless of euid or scope.
Top-level tables
| table | purpose |
|---|---|
[poll] | default poll cadence and jitter (each flow can override). |
[log] | reserved for future log-config integration. |
[http] | shared HTTP client tuning. |
[[flow]] | one or more flow definitions. |
A config is rejected at load time if the flow list is empty.
[poll]
Poll defaults applied to every flow unless the flow overrides them under
[flow.poll].
| field | type | bound | default |
|---|---|---|---|
source_interval | humantime duration | inclusive [15s, 24h] | unset; resolved per strategy (60s for GitHub API and grokmirror, 5m for ls-remote) |
job_interval | humantime duration | inclusive [15s, 24h] | 30s |
jitter | float | inclusive [0.0, 0.5] | 0.1 |
cooldown | humantime duration | 0s disables throttling; non-zero bounded [15s, 24h] | 5m |
Effective interval has a 15-second floor after jitter is applied:
interval = base * (1 + sample * jitter) where sample is uniform in
[-1, +1). Values outside the documented jitter range are clamped at
runtime, but the validator still rejects out-of-range values at config load.
Cooldown bounds dispatch frequency. After a trigger acceptance, subsequent SHA-diff observations within cooldown are suppressed. The most recent SHA at the end of the window is dispatched when the cooldown expires. Set cooldown = "0s" to opt out.
[log]
| field | type | notes |
|---|---|---|
filter | string (tracing_subscriber::EnvFilter syntax) | applies to subcommands that load the config (run, check, install). Precedence: --log-filter CLI flag > this field > built-in default info,gcit=debug. The control-channel subcommands (reload, status, trigger) read no config and use the CLI flag or the default. |
[http]
| field | type | bound | default |
|---|---|---|---|
request_timeout | humantime duration | inclusive [1s, 300s] | 30s |
max_concurrent | unsigned integer | accepted for back-compat | deprecated; no effect — concurrency is bounded by octocrab’s per-credential rate-limiter and the tokio scheduler |
[[flow]]
A flow is a (source, action, destinations) triple plus optional poll overrides. Each flow runs as an independent task tree under the supervisor.
| field | type | required | notes |
|---|---|---|---|
name | string | yes | unique across all flows; charset [a-zA-Z0-9_-]+, 1..=64 chars |
enabled | boolean | no, default true | disabled flows parse and validate but are not spawned |
description | string | no | renders as {{flow.description}} in templates; missing renders as empty string |
source | inline table | yes | see [flow.source] |
action | inline table | yes | see [flow.action] |
destination | array of inline tables | no | see [[flow.destination]] |
poll | inline table | no | per-flow override of [poll] defaults |
[flow.source]
The git side of the flow — what gcit polls.
| field | type | required | notes |
|---|---|---|---|
url | string | yes | parsed via url::Url; scheme must be one of http, https, ssh, git, file |
ref | string | yes | must start with refs/. For the GitHub API strategy, only refs/heads/* and refs/tags/* are supported by the underlying get_ref endpoint |
credential_id | string | no | auth credential for the git fetch (used by ls-remote when the upstream requires HTTP basic) |
The polling strategy is auto-detected from url. See Polling
strategies for the dispatch rules.
[flow.action]
The dispatch side of the flow — what gcit fires when the source SHA changes.
The only supported kind in v1 is github_workflow_dispatch.
| field | type | required | notes |
|---|---|---|---|
kind | string | yes | currently github_workflow_dispatch only |
repo | string | yes | owner/repo form (exactly one /) |
workflow | string | yes | non-empty; cannot contain /, \, or .. (a workflow filename, not a path) |
ref | string | yes | must start with refs/; the workflow ref to dispatch on the target repo |
credential_id | string | yes | GitHub fine-grained PAT (github_pat_*) with Actions read+write on repo |
inputs | inline table | no | string-keyed string-valued; values are handlebars templates rendered at trigger time |
Each inputs value is a handlebars template that renders against the same
namespace as notifier templates — see Templates below. gcit
also injects gcit_run_id into the rendered inputs payload at dispatch
time so the resulting GitHub Actions run can be correlated back to gcit.
See Polling strategies for
the correlation contract.
[[flow.destination]]
Each flow can attach zero or more destinations. The kind discriminator
selects the variant.
kind = "discord_webhook"
| field | type | required | notes |
|---|---|---|---|
kind | string | yes | "discord_webhook" |
credential_id | string | yes | Discord webhook URL stored as a credential — host must be one of discord.com, discordapp.com, ptb.discord.com, or canary.discord.com |
fire_on | array of FireEvent | no, default ["run_complete"] | duplicates rejected at config load — one error per duplicate |
template | inline table | no | see Templates |
kind = "local_mail"
| field | type | required | notes |
|---|---|---|---|
kind | string | yes | "local_mail" |
user | string | yes | local Unix user; charset [a-zA-Z0-9_-]+, 1..=32 chars; the daemon writes to /var/mail/<user> |
fire_on | array of FireEvent | no, default ["run_complete"] | duplicates rejected at config load |
template | inline table | no | see Templates |
local_mail requires a system-scope install (gcit install --system);
gcit install --user rejects any config with local_mail destinations.
FireEvent values
fire_on accepts these string values (snake_case):
| value | when |
|---|---|
run_start | after a workflow_dispatch succeeds AND the resulting Run.id is correlated, before the per-run monitor is spawned |
job_complete | each individual job within a run reaches a terminal state (per-job, in observation order) |
run_complete | the run itself reaches a terminal status (the only event guaranteed to deliver a summary) |
[flow.poll] (per-flow overrides)
Same fields as [poll]. Any field not set falls back to the top-level
default; if the top-level default is also unset, gcit uses the strategy’s
built-in default (60s / 60s / 5m for GithubApi / Grokmirror / LsRemote).
Templates
Notification messages, dispatch input values, and Discord embed fields are
rendered via handlebars in strict mode
(set_strict_mode(true)). Every variable must resolve or rendering fails.
Block helpers (each, with, if, unless, lookup, log, raw) are
deregistered, so templates are leaf-only substitutions; comparison helpers
(eq, ne, gt, …) and len remain available.
The escape function is replaced with handlebars::no_escape so plain-text
outputs (mbox bodies, Discord embed leaves) are NOT HTML-encoded — Discord
renders the result as markdown, mbox renders as plain text.
Variables are namespaced. Bare names like {{flow}} or {{gcit_run_id}}
are rejected at config load.
Available keys
The following keys are available in every template (run-start,
run-complete, mbox subject/body, Discord title/description/
collapsed_summary, dispatch [action.inputs] values):
| key | source |
|---|---|
{{flow.name}} | flow name from [[flow]] name = ... |
{{flow.description}} | optional [[flow]] description = ... (renders as empty string when unset) |
{{source.url}} | [flow.source] url |
{{source.ref_name}} | [flow.source] ref (the ref field — Rust keyword conflict forces the _name suffix on the rendered key) |
{{source.sha}} | full 40-char hex SHA observed at trigger time |
{{source.sha_short}} | 12-char hex prefix of source.sha |
{{action.repo}} | [flow.action] repo |
{{action.workflow}} | [flow.action] workflow |
{{action.run_id}} | GitHub Actions Run.id resolved by the correlator |
{{action.run_url}} | https://github.com/<repo>/actions/runs/<id> |
{{action.dispatched_at}} | RFC 3339 timestamp at dispatch time |
{{run.status}} | run status label (queued, in progress, completed, …) |
{{run.conclusion}} | terminal conclusion (success, failure, timed out, action required, …). For [action.inputs] rendered at trigger time, this is (in progress) because the run hasn’t completed yet. |
{{gcit.run_id}} | UUID gcit injects into the dispatch payload and uses for run correlation |
Per-job-only keys
Discord field_name and field_value templates render once per job and
additionally have access to:
| key | source |
|---|---|
{{job.id}} | GitHub Actions Job.id |
{{job.name}} | Job.name from the workflow |
{{job.url}} | Job.html_url |
{{job.conclusion}} | per-job conclusion label |
{{job.attempt}} | run_attempt counter |
{{job.*}} is not available on title, description, or
collapsed_summary templates — they render once per run, not per job. The
config validator’s probe context supplies stubs for every documented
job key so gcit check and gcit validate-template accept templates
that reference {{job.*}} in field_name or field_value. The notification
path supplies the real per-job data when iterating.
Strict-mode pitfalls
- A typo in any key path (e.g.
{{source.shaa}}) is rejected at config load —gcit checkshows the exact line. - Untrusted DATA values (sha, ref names, run conclusion strings, job names) render as inert text and cannot be re-interpreted as template fragments.
Reload behavior
On SIGHUP (or gcit reload), the supervisor diffs each flow against its
previous shape. Flows whose config is unchanged keep their poll/dispatcher
pair, credential resources, and rate-limit state — any in-flight run
monitor stays attached and the per-credential rate-limit poller keeps
refreshing without interruption. Credential file rotation takes effect only
when no kept-alive flow still references the credential id. As long as any
unchanged flow holds a credential, every flow using it continues with the
previously-resolved token. If rotating because the old token was
compromised, restart the daemon (systemctl restart gcit) rather than
SIGHUP to ensure all flows use the new token immediately. Changed and
removed flows are cancelled cleanly and the new generation starts a fresh
poll cycle.
Credential management
Every secret gcit needs (GitHub PAT, Discord webhook URL, optional source-
side fetch credential) is referenced by a credential_id string in the
config. The id is opaque; it never appears in a URL or in the workflow
dispatch payload. gcit resolves the id to a secret value at boot, on
SIGHUP reload, and on every CLI subcommand that needs the secret (gcit check, gcit trigger --dry-run).
Secrets are wrapped in secrecy::SecretString and redacted in
Debug/Display and in gcit status / gcit trigger --dry-run output.
Resolution order
Credentials are looked up in this order. The first hit wins; failures stop the walk so a misconfigured file is not silently overridden by a later step.
1. $CREDENTIALS_DIRECTORY/<credential_id>
systemd’s LoadCredential= directive populates $CREDENTIALS_DIRECTORY
with one file per credential. This is the recommended ingress under
systemd. The default gcit.service unit emits LoadCredential= lines
sourced from <config_dir>/credentials/<id> for every id referenced by
the config.
The on-disk invariants below apply.
2. GCIT_CREDENTIAL_<UPPER_SNAKE_ID> environment variable
Each id is converted to an env var name by uppercasing and replacing -
with _. Example: a credential id discord_ci_webhook resolves to
GCIT_CREDENTIAL_DISCORD_CI_WEBHOOK.
Two ids that map to the same env var name are rejected at config load with
a credential_id collision error naming both ids and the rule
(id.uppercase().replace('-','_')).
3. <config_dir>/credentials/<credential_id> file
The fallback path is always relative to the directory holding config.toml.
For a system install that resolves to /etc/gcit/credentials/<id>; for a
user install, $XDG_CONFIG_HOME/gcit/credentials/<id>.
The on-disk invariants below apply.
4. Otherwise: error
If every step misses, the resolution surfaces an error listing every searched path and the flows that need the credential.
On-disk invariants
Both file-based resolution paths (steps 1 and 3) enforce the same on-disk
checks. The probe is shared across gcit check, gcit install, and the
daemon supervisor so they agree on what counts as a usable credential.
Mode
The file mode bitmask must satisfy mode & 0o077 == 0. Any mode whose
group and other bits are all clear qualifies — 0400, 0600, 0700
are typical examples; 0640 and 0644 are rejected.
The recommended canonical form is 0600. Failed mode checks render a
single-line operator-facing message including the offending mode and a
chmod 0600 <path> recovery command.
File type
The path must be a regular file. Symlinks are refused outright (a 0600
symlink could pivot the resolution to a world-readable target). Fifos,
sockets, directories, and block/char devices are also refused.
The probe uses symlink_metadata, not metadata, so the symlink check
fires before any link is followed.
Owner uid
The owner uid must be one of:
- the daemon’s effective uid (the resolving process), or
- root (uid 0).
Root is accepted so an operator on a DynamicUser=yes unit can drop
credential files via sudo — the transient daemon uid is not knowable in
advance. Failed owner checks include both the file’s owner uid and the
resolving euid in the error message, plus a chown command.
Ownership rules by install scope
gcit install --user
Writes config under $XDG_CONFIG_HOME/gcit (typically ~/.config/gcit).
The daemon runs as the invoking operator. Credential files must be owned
by the operator’s uid:
chmod 0600 ~/.config/gcit/credentials/github_pat
chown $(id -u):$(id -g) ~/.config/gcit/credentials/github_pat
gcit install --system with DynamicUser=yes
The default for system installs without local_mail. systemd allocates a
transient uid each time the unit starts; the operator cannot match it.
Drop credential files as root and gcit accepts them:
sudo install -m 0600 -o root -g root /path/to/token /etc/gcit/credentials/github_pat
Or use systemd’s LoadCredential= directive which bypasses the on-disk
file entirely:
# /etc/systemd/system/gcit.service.d/credentials.conf
[Service]
LoadCredential=github_pat:/etc/gcit/credentials/github_pat
The default unit already emits LoadCredential= lines for every credential
id referenced by the config; the override above is only needed when
sourcing from a non-default path.
gcit install --system with local_mail destination
The unit switches from DynamicUser=yes to User=gcit + Group=mail +
SupplementaryGroups=mail. The install wizard creates the static gcit
user via useradd --system --no-create-home --shell /usr/sbin/nologin -G mail gcit. Drop credentials owned by gcit (or root), mode 0600:
sudo install -m 0600 -o gcit -g gcit /path/to/token /etc/gcit/credentials/github_pat
GitHub authentication
gcit accepts only fine-grained personal access tokens for
github_workflow_dispatch. A fine-grained PAT begins github_pat_. The
token must have Actions read+write on the target repo. Classic PATs and
GitHub App authentication are out of scope for v1.
gcit check exit semantics
gcit check reports one of three states:
| state | meaning | exit code |
|---|---|---|
| 1 | Config parses, validates, and every referenced credential resolves now via one of the three resolution steps. | 0 |
| 2 | Config parse or validation error, OR a credential id is referenced by a flow but not declared anywhere. | 78 (EX_CONFIG) |
| 3 | Config validates AND $CREDENTIALS_DIRECTORY is set and points at a real directory but the credential is not present right now (the daemon will receive it from systemd at runtime via LoadCredential=). | 0 with an INFO note |
State 3 lets gcit check run from a developer shell where
$CREDENTIALS_DIRECTORY is not yet populated without falsely reporting a
config bug. Run gcit check from inside the unit (e.g. systemctl start gcit-check.service if you wire one up) for an end-to-end check that
exercises step 1 of the resolution chain.
gcit check runs the same validate_spool_writability probe used by the
daemon for every local_mail destination. A missing or non-writable
/var/mail/<user> surfaces alongside any credential errors so the
operator sees every problem in one pass.
Rotation
To rotate a credential safely:
- Update the credential file in place (or re-deploy via your secret management tool).
- Run
systemctl restart gcit(system) orsystemctl --user restart gcit(user). ASIGHUPreload only re-resolves credentials for flows whose shape changed — flows whose config is unchanged keep the previously resolved token, by design, to avoid disrupting in-flight runs.
If the old token is known to be compromised, always restart rather than reload. The reload-vs-restart distinction is documented in the Configuration reference.
Polling strategies
gcit picks one of three polling strategies for each flow based on the
source URL. There is no strategy= config field; auto-detection is
deterministic and exact. You cannot override the strategy choice — if
you need a different one, change the URL.
Auto-detection rules
The strategy is selected by host match on the parsed URL:
| host | strategy | default interval |
|---|---|---|
github.com / www.github.com | GithubApi | 60s |
git.kernel.org | Grokmirror | 60s |
| anything else (or an unparseable URL) | LsRemote | 5m |
Host comparison is case-insensitive but exact: api.github.com,
github.com.evil.example.com, and www.kernel.org all fall through to
LsRemote. URLs that fail to parse fall through to LsRemote, which
errors at connect time with a clear message.
Effective interval has a 15-second floor after jitter is applied. Jitter
samples uniformly in [-jitter, +jitter] and the resulting duration is
clamped to MIN_INTERVAL = 15s.
GithubApi
Uses octocrab’s get_ref endpoint
(GET /repos/{owner}/{repo}/git/ref/{ref_path}) to resolve a single ref
in one HTTP round trip. Cheapest of the three strategies; lowest default
interval (60s) because GitHub explicitly publishes per-ref endpoints.
- Auth. Optional. If
[flow.source]declares acredential_id, the resolved token is supplied as a Bearer header. Public repos can be polled anonymously. - Ref support. Only
refs/heads/*andrefs/tags/*. Any other ref syntax is rejected at config load. - Errors.
- 404 maps to
PollOutcome::UnbornRef(the ref does not exist). - 403 / 429 map to
Transientand the caller awaits the rate-limit reset. - 5xx and network errors map to
Transient. - Other 4xx map to
Permanent.
- 404 maps to
Grokmirror
Used for git.kernel.org. Fetches a single manifest.js.gz file that
describes every repo on the mirror. Comparing fingerprints across polls
detects changes for many repos with one HTTP request.
The wire format on kernel.org is a static .gz file (no
Content-Encoding: gzip), so reqwest’s transparent decompression does not
apply. gcit decodes the body explicitly via flate2::read::GzDecoder.
- Caps.
- Compressed body: 64 MiB cap (kernel.org’s manifest is ~1-2 MiB).
- Decompressed body: 16 MiB cap.
- Wall-clock: 60-second timeout per round trip.
- Cache. The poll task caches the previous fingerprint in memory.
Matching fingerprint →
PollOutcome::Unchanged(no per-ref lookup). Mismatch → the strategy returns the new fingerprint and a follow-up ls-remote resolution to obtain the per-ref SHA. - Errors.
- Network / 5xx / decode noise →
Transient. - Configured repo not in the manifest →
RepoNotInManifest→PollOutcome::UnbornRef. - Configuration / decode errors that won’t recover →
Permanent.
- Network / 5xx / decode noise →
LsRemote
Anything that isn’t github.com or git.kernel.org. Uses
gix-protocol’s stateless ls-refs over the
configured transport (https://, git://, ssh://, file://, scp-style
git@host:path).
- Auth. Optional. For HTTPS URLs to private repos, gix-protocol’s
handshake invokes gcit’s authenticate callback when the server returns
401; the callback resolves the credential and replies. For SSH URLs the
SSH agent / configured key handles auth out of band. For
file://URLs no auth is needed. - Ref support. Any ref name that exists in the remote’s ls-refs output. Lightweight tags resolve to the commit they point at; annotated tags resolve to the tag SHA itself (no peel).
- Concurrency. Blocking-IO calls run on the tokio blocking pool.
- Errors. Transport-level failures classify as
Transient; missing refs and unborn repos surface asPollOutcome::UnbornRef.
SHA-diff comparator
Every strategy yields a PollOutcome. The supervisor pairs each
Refreshed { sha } with the previously recorded last_sha:
last_sha | observed | trigger? |
|---|---|---|
None (first poll) | any | no — record baseline |
Some(prev) | prev | no — unchanged |
Some(prev) | != prev | yes |
UnbornRef and Unchanged never fire, but both still trigger a
PollObservation so gcit status can report “polled <timestamp>” without
showing a stale “no activity” indicator.
Cooldown
After a dispatch fires, subsequent SHA-diff observations within the configured cooldown window are suppressed. last_sha is not advanced during suppression, so the next poll past the window re-detects the diff and dispatches the most recent SHA. Intermediate SHAs are coalesced.
gcit trigger bypasses cooldown — manual triggers go directly to the dispatcher. Cooldown tracks only poll-originated dispatch acceptances.
Default: 5 minutes. Set cooldown = "0s" to disable.
Workflow dispatch correlation
GitHub’s workflow_dispatch API does not return the resulting run id.
gcit correlates the dispatch to the spawned run by:
- Generating a UUID at dispatch time and injecting it into the
workflow_dispatchinputspayload asgcit_run_id. - Polling
list_workflow_runsand matching byRun.name.contains("gcit-<uuid>").
Workflows opt in by declaring the input and embedding it in run-name:
on:
workflow_dispatch:
inputs:
gcit_run_id:
type: string
run-name: gcit-${{ inputs.gcit_run_id }}
When run-name is not configured, gcit falls back to filtering by
head_sha + ?created>=<dispatch_iso> and selects the most recent matching
run. The fallback emits a WARN recommending the run-name directive.
Per-flow interval overrides
A flow can override the top-level [poll] defaults under [flow.poll].
Resolution order:
[flow.poll].source_interval[poll].source_interval- The strategy’s built-in default (60s for GithubApi/Grokmirror, 5m for LsRemote).
Same fallback chain for job_interval and jitter. Any value outside the
documented bounds is rejected at config load — the validator does not
silently clamp.
Notifiers
A flow’s destinations decide who hears about each run. Two notifier kinds ship with v1: Discord webhook and local mail (mbox append). Each destination is independent — one flow can have multiple destinations of the same kind, and a slow or failing notifier does not block any other.
Both kinds share the same lifecycle hooks:
| hook | when |
|---|---|
on_run_start | after a workflow_dispatch succeeds AND the resulting Run.id is correlated, before the per-run monitor is spawned |
on_job_complete | once per job per run, in observation order |
on_run_complete | exactly once when the run reaches a terminal status — the only event guaranteed to deliver a summary |
fire_on selects which hooks fire for this destination. A hook whose
event isn’t in fire_on returns Skipped { FireOnMismatch } and is logged
at DEBUG.
Discord webhook (kind = "discord_webhook")
Posts programmatic twilight embeds with conclusion-coloured states.
- Webhook host allowlist. The credential URL must be on
discord.com,discordapp.com,ptb.discord.com, orcanary.discord.com. Other hosts are rejected at config load. - Embed templates. All template fields are optional handlebars templates rendered against the run context.
| field | scope | notes |
|---|---|---|
title | once per run | embed title; truncated codepoint-safe to Discord’s limit |
description | once per run | embed description; truncated codepoint-safe |
collapsed_summary | once per run | shown inline when many jobs are present, replacing per-job fields |
field_name | once per job | per-job embed field name; {{job.*}} available here |
field_value | once per job | per-job embed field value; {{job.*}} available here |
The Discord embed builder is collapse-aware: when too many jobs would
exceed Discord’s limits, gcit collapses the per-job fields into a single
summary block (using collapsed_summary if configured, otherwise an
auto-generated fallback). The total embed codepoint count is enforced
defensively before the HTTP call.
- Errors.
- 401 / 403 / 404 / 410 →
Permanent(token revoked, webhook deleted, etc.). - 429 →
Transientwith the API’sretry_afterhint. - 5xx / Hyper / Timeout →
Transient. - Validation failures (embed too large, etc.) →
Permanent(config bug).
- 401 / 403 / 404 / 410 →
- Cancellation. Each per-flow
CancellationTokenis plumbed into the webhook call. When cancel fires after the HTTP request has been sent but before the response is read, the delivery state is unknowable from gcit’s side. The cancelsourcereflects this:"discord webhook <id> cancelled; delivery status unknown — the request may or may not have reached Discord".
Local mail (kind = "local_mail")
Appends an mboxrd-formatted message to /var/mail/<user> directly. No
SMTP, no MTA dependency. Multiple gcit instances coordinate via
flock(LOCK_EX). Other mbox writers (mailx, procmail, postfix) may use
dotlocking instead of flock — gcit does not acquire dotlocks, so
concurrent writes from a dotlock-only writer are not coordinated. On
systems where the mail spool is written by both gcit and a traditional
MUA, verify the MUA also uses flock or configure a dedicated spool.
- Spool path.
/var/mail/<user>(POSIX convention). The validator rejects user names outside[a-zA-Z0-9_-]+or longer than 32 characters. - Filesystem invariants.
O_NOFOLLOWon open — refuses a symlinked spool.flock(LOCK_EX)for cross-process exclusion. The lock-wait deadline is 5 seconds; if the lock is not acquired within that window, gcit returnsTransientwith"spool lock not acquired within 5s".O_APPENDwrite of the formatted message.fsync(2)after write to durably commit.
- Templates.
| field | scope | notes |
|---|---|---|
subject | once per run | mbox Subject: header |
body | once per run | mbox body — codepoint-capped for very large messages |
- Cancellation semantics. The mail notifier splits its 5-second
deadline:
LOCK_WAIT_DEADLINEbounds only the flock acquisition wait. After the lock is acquired,write_alland the post-lockfsyncrun unbounded. A slow disk that holdsfsynclonger than 5 seconds completes the write successfully — the deadline is for lock contention, not for durability. This also means cancellation can only short-circuit the lock-wait phase: if cancel fires after the lock is acquired, the write+sync runs to completion to keep the spool record atomic. - Lock-wait race. Because the post-lock-wait blocking task
(
spawn_blockingrunningflock(LOCK_EX)) is not cancellable from userspace, a cancellation during lock-wait may still result in a spool entry once the holder releases — the cancel returns Transient to the supervisor first, but the OS thread proceeds with its blocking syscall. Thecancelledmessage includes the addendum"the blocking task may still write if the lock becomes available before the deadline"so an operator readingjournalctl -u gcitknows a stray spool entry may appear.
Install scope requirement
local_mail requires a system-scope install. Under --user installs
the daemon runs as the operator and cannot append to system mail spools.
gcit install --user rejects any config with local_mail destinations
with a clear error. With --system, the install wizard:
- Switches the unit from
DynamicUser=yestoUser=gcit+Group=mailSupplementaryGroups=mail.
- Pre-flights
/var/mail/<user>for each configured user — warns when the spool is missing or not group-writable, and prints the exacttouch/chgrp mail/chmod 0660commands to fix it. - Creates the static
gcituser viauseradd --system --no-create-home --shell /usr/sbin/nologin -G mail gcit(skipped if the account already exists).
Logging
Notifier outcomes (Sent / Skipped / Err) all log under the single
tracing target gcit::flow::notify. Filter on this target to adjust
notifier verbosity independently of the per-flow targets. Each record
carries:
| field | values |
|---|---|
kind | discord, local_mail |
id | the destination id from config |
label | run-start, run-complete, job-complete |
job_id | only on per-job records |
Sent records also carry an opaque receipt string identifying the
delivery. For Discord this is a synthetic webhook:{id} token derived
from the parsed webhook URL’s id segment — gcit does not request a
message-body reply from Discord (no ?wait=true), so a real message id
is never available. For local mail the receipt is file:{path} — the
absolute spool path that was appended to, prefixed with file: so a
journald reader can grep '^file:' to find every successful mbox
delivery.
Skipped records carry a debug-formatted reason:
| reason | meaning |
|---|---|
NotConfigured | the destination uses a default no-op on_run_start / on_job_complete impl. Never returned by on_run_complete. |
FireOnMismatch | the destination’s fire_on array does not include the triggering event. Operator opted out. |
RateLimited | the notifier’s per-credential rate bucket said defer. Caller retries on the next supervisor cycle. |
Canonical Transient messages
When investigating last_error in gcit status or filtering journald,
expect these canonical message shapes from the notifier path:
| message prefix | meaning | operator action |
|---|---|---|
cancelled before lock acquired on <path> | Pre-cancel or cancel during flock-wait fired before the OS thread started its blocking syscall. No spool write occurred. | None — normal shutdown/reload artifact. |
cancelled before lock acquired on <path>; the blocking task may still write if the lock becomes available before the deadline | Cancel fired during lock-wait; the OS thread is still blocked on flock(LOCK_EX). A stray spool entry MAY appear post-cancel if the holder releases before the deadline elapses. | None — normal shutdown/reload artifact. Re-read the spool only if you suspect a duplicate run on retry. |
spool lock not acquired within 5s | Another writer held the flock past LOCK_WAIT_DEADLINE. backon will retry on the next supervisor cycle. | Investigate the other writer (mailx, procmail, another gcit instance) if contention persists. |
discord webhook <id> cancelled; delivery status unknown — the request may or may not have reached Discord | Cancel fired mid-HTTP. Delivery state is ambiguous. | None — normal shutdown/reload artifact. |
any map_io_error variant (e.g. spool file <path> does not exist, permission denied writing to <path>, <path> is a symlink, etc.) | Operator-actionable I/O failure surfaced through NotifyError::Permanent. | Follow the remediation embedded in the message (useradd/touch, BindPaths=, resolve the symlink, etc.). |
Run-start delivery semantics
Notifier fan-out tasks for on_run_start are spawned without waiting for
completion — the dispatcher does not await them so a slow notifier cannot
delay monitor spawn. On shutdown these tasks are not joined and may be cut
off mid-send by tokio runtime teardown. As a result, a run-start
notification that was in flight when SIGTERM arrived may or may not be
delivered, and the operator has no way to tell which.
The on_run_complete and per-job on_job_complete fan-outs ARE awaited
inside the per-run monitor task (which the supervisor joins), so those
deliveries either complete or surface their failure in the journal before
shutdown finishes. If lossless shutdown matters for run-start, drain
dispatch first by gcit reload-ing onto a config with the relevant flows
disabled, wait until gcit status no longer shows an active_runs: line
for each affected flow (the text renderer suppresses the line when the
count is zero), then systemctl stop gcit.
CLI reference
gcit [OPTIONS] <SUBCOMMAND>
Global options
| flag | purpose |
|---|---|
--config <PATH> | path to the config file (see Configuration reference for default-resolution rules) |
--log-filter <FILTER> | tracing filter per tracing_subscriber::EnvFilter syntax. Precedence: CLI flag > config-file [log] filter > built-in default info,gcit=debug. The config-file fallback only applies to subcommands that load the config (run, check, install); control-channel subcommands (reload, status, trigger) read no config and use this flag or the default. gcit does not honour any RUST_LOG-style env var. |
--control-socket <PATH> | path to the daemon’s Unix control socket. Default: $XDG_RUNTIME_DIR/gcit/control.sock (when set) or /run/gcit/control.sock. |
--version | print version + git SHA and exit |
--help | print help and exit |
Exit codes
Exit codes follow sysexits.h:
| code | name | meaning |
|---|---|---|
0 | EX_OK | success |
64 | EX_USAGE | bad invocation (missing required arg, mutex violation) |
65 | EX_DATAERR | bad template (returned by gcit validate-template) |
70 | EX_SOFTWARE | internal software error |
71 | EX_OSERR | OS-level failure (FS error, manifest sha mismatch on uninstall without --force) |
75 | EX_TEMPFAIL | transient (control socket unreachable, daemon not responding) |
78 | EX_CONFIG | configuration error (parse, validation, refused silent overwrite, unresolved credential) |
Subcommands
gcit run [--foreground]
Daemon entry. Routed via the systemd unit in production. The daemon polls every configured flow, dispatches workflow runs on SHA changes, monitors them to completion, and posts notifications.
Pass --foreground for development to log to stderr instead of journald.
Without it, gcit initializes the journald layer and refuses to start when
journald is unreachable (logs from a misconfigured daemon must NOT silently
route to stderr that nothing reads).
gcit install --user | --system [--non-interactive] [--force] [--dry-run]
Interactive install of config skeleton + systemd units.
- One of
--useror--systemis required (clapArgGroupmutex). --non-interactiveskips the path-preview confirmation prompt for CI.--forceoverwrites existing managed files. Without it, encountering any existing managed file is fatal (EX_CONFIG=78).--dry-runrenders the systemd service unit to stdout and exits 0 without writing files, creating users, or invoking daemon-reload. Config is still parsed and validated (so a bad config fails fast), and--user+local_mailis still rejected. Stdout contains only the unit text — the credential walkthrough, path preview, and post-install banner are suppressed so the output can be piped directly intosystemd-analyze security.
The wizard (skipped under --dry-run):
- Walks each referenced credential id and prints the URL hint, target
repo, install path, and
chmod 0600command. Annotates already- configured credentials with a✓line. - For configs with
local_maildestinations, pre-flights every/var/mail/<user>and prints actionable warnings. - Previews every file path it will create (
[exists]/[new]per entry) and refuses to write without confirmation. - Creates the static
gcituser viauseraddwhenlocal_mailis present + scope is system. - Writes files atomically + writes the install manifest at
$STATE_DIRECTORY/.install-manifest.json. - Triggers
daemon-reloadvia the user session bus (or hints for--system). - Prints next-step systemctl commands.
gcit uninstall --user | --system [--force]
Reverses a prior gcit install using the install manifest. One of
--user or --system is required.
- Files NOT in the manifest are NEVER touched.
- Operator-modified files (sha256 mismatch with the manifest) are
detected and the uninstall refuses to proceed with non-zero exit.
Pass
--forceto remove them anyway. - Path-traversal defense: every manifest entry must canonicalize under one
of the expected install directories. A tampered manifest pointing at
/etc/passwdis rejected before any removal happens. - The state directory is preserved so a future re-install can pick up where the previous run left off. The manifest itself is removed last; if a file removal fails midway, re-run uninstall.
gcit check [--config PATH]
Validate the configuration. Parses, validates every rule, and verifies that every referenced credential id can be resolved. Prints every error in one pass (rather than stopping at the first) so the operator can fix multiple issues per edit.
Three exit states:
| state | meaning | exit |
|---|---|---|
| 1 | Config parses, validates, and every credential resolves now. | 0 |
| 2 | Config error OR credential id referenced but not declared. | 78 (EX_CONFIG) |
| 3 | Config validates AND $CREDENTIALS_DIRECTORY is set + real but the credential is missing right now. | 0 with an INFO note |
State 3 lets gcit check run from a developer shell without falsely
reporting a config bug — the daemon will receive the credential at
runtime via systemd LoadCredential=. Run gcit check from inside the
unit for an end-to-end check.
gcit status [FLOW] [--format text|json]
Per-flow status snapshot via the control socket. Without a flow argument, prints all flows.
--format text(default): human-readable per-flow lines. Flow header showsname: state. Indented summary lines forlast_sha,last_poll_at,active_runs,notified_runs,last_error[kind] at: message. Theactive_runs:andnotified_runs:lines are suppressed when the count is zero. Theretry_atsub-line appears only forGithubErrorKind::RateLimitederrors.--format json: the daemon’s JSON shape printed verbatim. Use this for scripting.
Synthetic daemon-level keys (any key wrapped in parentheses such as
(reload)) are not flow names — they carry daemon-scoped errors the
supervisor records under a sentinel key. The text renderer prefixes those
entries with [daemon] so an operator scanning the output can tell at a
glance that the entry is not a flow they configured.
Exits EX_TEMPFAIL=75 when the daemon is unreachable.
gcit trigger <FLOW> [--dry-run]
Manually fire a flow’s dispatch path. The flow name is required and must be non-empty.
With --dry-run, the daemon returns the rendered dispatch payload(s)
without contacting GitHub or any notifier. Useful for verifying your
templates and [action.inputs] values render correctly. Secrets in the
returned payload are redacted.
Without --dry-run, the daemon fires the dispatch as if a SHA change had
been observed at the current source SHA, runs the correlator, monitors
the run to completion, and fires the configured notifiers.
gcit trigger bypasses cooldown — the dispatcher fires regardless of whether cooldown would have suppressed an automatic trigger.
Exits EX_TEMPFAIL=75 on transport error or unknown flow.
gcit reload
Send Reload to the daemon over the control socket. Equivalent to
SIGHUP to the daemon process. The supervisor diffs each flow against
its previous shape and restarts only the changed and removed flows; see
Configuration reference for the
full semantics.
gcit validate-template <FILE> [--kind discord | local-mail]
Compile a standalone template file using the same template rules and
namespaces ({{flow.*}}, {{source.*}}, {{action.*}}, {{run.*}},
{{gcit.*}}) the daemon uses at notification time. The file’s contents
are read verbatim — no TOML wrapping; just the raw template string.
The pipeline matches the daemon’s config-load validation:
- Register the template into a
notify::strict_handlebars()instance — strict_mode + DEREGISTERED_HELPERS + no_escape. - AST check — reject single-segment references like
{{flow}}or{{gcit_run_id}}. The runtime template namespace is dotted only. - Render against the probe context (the same shape
gcit checkuses to catch typos in dotted leaves like{{flow.naem}}).
On success, the rendered output is printed to stdout. On compile or
render failure, exits EX_DATAERR=65 with the underlying error on
stderr. Operators routinely pipe the success output into jq or compare
against expected text in CI.
--kind is recorded in a one-line stderr header so reviewers reading CI
logs see which surface was validated. The underlying probe context is
shared across surfaces today; surface-specific checks (e.g. Discord’s
per-job key set, local_mail’s body cap) hang off this flag in future
iterations.
The probe context currently uses empty strings for every namespaced leaf
— matches gcit check behaviour.
gcit completions <SHELL>
Print a shell-completion script to stdout. Supported shells (per
clap_complete::Shell): bash, elvish, fish, powershell, zsh.
Per-shell install hints print to stderr so the hint never contaminates the generated script when piped to a file:
| shell | suggested path |
|---|---|
| bash | ~/.local/share/bash-completion/completions/gcit (per-user) or /etc/bash_completion.d/gcit (system) |
| zsh | a directory on $fpath named _gcit; e.g. ~/.zsh/completions/_gcit then add fpath+=(~/.zsh/completions) before compinit in ~/.zshrc |
| fish | ~/.config/fish/completions/gcit.fish |
| elvish | ~/.config/elvish/lib/gcit.elv and add use gcit to ~/.config/elvish/rc.elv |
| powershell | a .ps1 file (e.g. ~/.config/powershell/gcit.ps1) and dot-source it from $PROFILE |
Systemd integration
gcit is a Type=notify systemd service with socket activation for the
control channel. The supported deployment surface is the systemd units
that gcit install writes; --foreground is for development only.
Install paths
| scope | service unit | socket unit | config dir | state dir |
|---|---|---|---|---|
--system | /etc/systemd/system/gcit.service | /etc/systemd/system/gcit.socket | /etc/gcit | /var/lib/gcit |
--user | $XDG_CONFIG_HOME/systemd/user/gcit.service | $XDG_CONFIG_HOME/systemd/user/gcit.socket | $XDG_CONFIG_HOME/gcit | $XDG_STATE_HOME/gcit |
For --user installs, $XDG_CONFIG_HOME defaults to ~/.config and
$XDG_STATE_HOME defaults to ~/.local/state when the env vars are
unset.
The install manifest is written at <state_dir>/.install-manifest.json
and tracks every file the wizard wrote, with sha256 + mode. gcit uninstall reads the manifest to know what to remove (and only what to
remove).
Service unit
The rendered gcit.service includes:
Type=notifyandNotifyAccess=main. The daemon emitsREADY=1once the supervisor’sselect!loop is live and ready to accept SIGTERM / SIGHUP / control commands.Requires=gcit.socket(the control socket is socket-activated).After=network-online.target+Wants=network-online.target.ExecStart=<binary> runandExecReload=<binary> reload. The binary path is resolved at install time viastd::env::current_exe()so a--userinstall rooted at~/.cargo/bin/gcitrecords that exact path rather than a hardcoded/usr/bin/gcit.
User model
config has local_mail? | user model |
|---|---|
| no | DynamicUser=yes (systemd assigns a transient uid each start) |
| yes | User=gcit, Group=mail, SupplementaryGroups=mail |
The local_mail notifier writes to /var/mail/<user> and needs mail
group access. DynamicUser=yes cannot join the mail group, so the
install wizard switches to a static gcit system user it creates via
useradd --system --no-create-home --shell /usr/sbin/nologin -G mail gcit.
Exit code 9 from useradd (E_NAME_IN_USE) is treated as success — the
account already exists, nothing to do, and uninstall will NOT remove it
unless this install created it. The manifest’s
user_created_by_install: true flag tracks whether the install minted
the account.
Hardening directives
The unit emits the following byte-for-byte hardening profile so
systemd-analyze security gcit.service reports identical hardening
regardless of the user-model branch:
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallArchitectures=native
CapabilityBoundingSet=
DeviceAllow=
UMask=0077
RuntimeDirectory=gcit
RuntimeDirectoryMode=0700
StateDirectory=gcit
StateDirectoryMode=0700
ConfigurationDirectory=gcit
ConfigurationDirectoryMode=0750
BindPaths=/var/mail # only when at least one local_mail destination is configured
Restart=on-failure
NotifyAccess=main
TimeoutStopSec=360
BindPaths=/var/mail is gated on has_local_mail (true when at
least one configured destination is kind = "local_mail"); a Discord-
only install emits no BindPaths=/var/mail line so
ProtectSystem=strict is not unnecessarily relaxed for deployments
that never write to /var/mail. Every other directive above is
emitted byte-for-byte regardless of the user-model branch. The empty
CapabilityBoundingSet= and DeviceAllow= clear the daemon’s
capability and device allow-lists; without these gcit would inherit
systemd’s defaults.
Socket unit
[Unit]
Description=gcit control socket
[Socket]
ListenStream=%t/gcit/control.sock
SocketMode=0600
FileDescriptorName=control
[Install]
WantedBy=sockets.target
%t resolves at runtime to /run/gcit for system units and
$XDG_RUNTIME_DIR/gcit for user units. SocketMode=0600 keeps the
control channel restricted to the unit’s effective uid.
FileDescriptorName=control lets the daemon match the inherited fd
against its expected name. gcit reads the socket-activated fd via
sd_notify::listen_fds_with_names_and_unset_env() at startup, before any
threads or the tokio runtime are created.
LoadCredential lines
The install wizard emits one LoadCredential=<id>:<path> line per
credential id referenced by the config, sourced from
<config_dir>/credentials/<id> (deterministic sorted order for
reproducibility). Operators who want a different source path can write
a drop-in:
# /etc/systemd/system/gcit.service.d/credentials.conf
[Service]
LoadCredential=github_pat:/run/secrets/github_pat
systemd populates $CREDENTIALS_DIRECTORY with one file per
LoadCredential line; gcit’s resolution chain looks there first. See
Credential management.
Install wizard walkthrough
gcit install --system (or --user) is interactive by default
(every step below is skipped under --dry-run):
- Credential walkthrough. Prints one section per referenced
credential id with the URL hint, target repo (for GitHub PATs), the
on-disk install path, and the
chmod 0600 <path>command. Already- configured credentials get a✓ <id>: configured at <path>line and the long instructions are skipped. Root-owned credentials under--userscope are annotated(owned by root — rotate via sudo)so operators know future rotations requiresudo. - Local mail check (only when
local_mailis configured). For each user named in alocal_maildestination, probes/var/mail/<user>and prints either confirmation or actionable warnings (sudo touch,sudo chgrp mail,sudo chmod 0660). - Path preview. Each file the wizard will write is listed with
[exists]or[new], plus the runtime directories systemd will auto-create on first start, plus the user model the unit will activate, plus anyuseraddinvocation the wizard will run. - Confirmation. Prompts
Proceed? [y/N]. Operator changing their mind exits 0; onlyy/Y/yes/YES/Yesproceed.--non-interactiveskips this prompt. - Atomic write. Each file is written via tempfile + write +
sync_all+ persist + parent dir fsync. The install manifest is written last with the same durability. daemon-reload. Triggered via the user session bus (zbus), or skipped with a hint when running--systemwithout root.- Next steps. Prints the exact
systemctl daemon-reload && systemctl enable --now gcit.socket gcit.servicecommand for the chosen scope and thejournalctl -u gcit -ffollow-up.
The install refuses to silently overwrite any existing managed file
without --force and lists every offending path so the operator can
decide whether to gcit uninstall first or pass --force.
gcit install --dry-run short-circuits the wizard: config validation
and --user + local_mail rejection still run, but the wizard then
renders the systemd service unit to stdout and exits 0 without writing
files, creating users, or invoking daemon-reload. The credential
walkthrough, path preview, and post-install banner are suppressed so
stdout contains only the unit text — pipe directly into
systemd-analyze security to score the rendered unit, or diff the
proposed unit against an existing one before running the real install:
gcit install --user --dry-run --config ~/.config/gcit/config.toml \
| diff - ~/.config/systemd/user/gcit.service
ExecStart= records the absolute path of the gcit binary that
rendered the unit (std::env::current_exe() at install time). When
diffing a dry-run rendered from a development build against a unit
installed from a different binary, expect the ExecStart=/ExecReload=
paths to differ.
Shutdown semantics
On SIGTERM (or SIGINT) the supervisor cancels its root token, awaits
every per-flow poll/dispatcher/monitor task in the JoinSet, drains the
control server, then signals the state writer to flush and waits for the
state-writer thread to exit. State persisted before shutdown is durable.
systemctl stop gcit sends SIGTERM. Ctrl-C in foreground sends
SIGINT. The unit’s TimeoutStopSec=360 gives gcit up to 6 minutes for
the shutdown sequence, which is enough headroom for in-flight runs to
finish their final notifier fan-out.
For lossless shutdown semantics (specifically around on_run_start
fan-outs which are not awaited), see Notifiers — Run-start delivery
semantics.
Troubleshooting
This chapter covers common failure modes when installing, configuring,
and operating gcit. The two most useful tools are gcit check (validate
the config and credentials) and gcit status (snapshot of every flow’s
runtime state).
gcit check exit states
See Credential management — gcit check exit semantics.
gcit check prints every error in one pass rather than stopping at
the first, so you can fix multiple issues per edit cycle.
Common config errors
Every config-load error displays as path:line[s]: <message> so editors
and grep can navigate from the terminal output to the source.
at least one flow is required
The config has no [[flow]] block. Add one with at least name,
source, and action.
duplicate flow name; N occurrences
Two or more [[flow]] blocks share the same name. The error names
every line. Rename each occurrence so flow names are unique.
credential_id collision: ids map to the same env var
Two credential_id strings produce the same GCIT_CREDENTIAL_* env var
name (rule: id.uppercase().replace('-','_')). Rename one so each id
maps to a unique env var.
credential 'X' not found
A flow references a credential_id that does not resolve. The error
lists every searched path:
credential 'github_pat' not found
searched:
/etc/gcit/credentials/github_pat
$env::GCIT_CREDENTIAL_GITHUB_PAT
consumed by: linux-mainline-ci
Drop the credential at one of the searched paths (mode 0600, owner
root or the daemon’s effective uid), or set the env var, or add a
LoadCredential= line to the unit.
credential 'X' at <path> has mode 0644
The on-disk file failed the mode check. mode & 0o077 != 0. Fix:
sudo chmod 0600 /etc/gcit/credentials/X
credential 'X' at <path> is owned by uid N but the resolving process runs as uid M
The file owner does not match either the daemon’s effective uid or root.
The error names both uids and the chown command.
credential 'X' at <path> is a symlink
Symlinks are refused outright (a 0600 symlink could pivot the
resolution to a world-readable target). Place the credential file
directly at the path.
Template typos rejected at config load
gcit check compiles every template against a probe context. A typo
like {{source.shaa}} is rejected with the offending line and the
strict-mode rendering error.
gcit validate-template path/to/template
renders a standalone template against the same probe context and exits
EX_DATAERR=65 on failure.
Bare names rejected
Single-segment template names like {{flow}} or {{gcit_run_id}} are
rejected because the runtime template namespace is dotted only. Rewrite
to the dotted form ({{flow.name}}, {{gcit.run_id}}).
Common runtime issues
gcit status cannot connect to the control socket
gcit status: cannot connect to /run/gcit/control.sock: No such file or directory (is the daemon running?)
The daemon is not running, or the socket path differs from the default. Check:
# system-scope install:
systemctl status gcit
journalctl -u gcit -n 100
# user-scope install:
systemctl --user status gcit
journalctl --user -u gcit -n 100
For a non-default socket path, pass --control-socket. For user-scope
installs, the default is $XDG_RUNTIME_DIR/gcit/control.sock.
Flow state shows errored
gcit status prints a last_error[kind] at: message indented under the
flow header. The kind names the failure category. The emitted values
are git_poll_failed (poll failure), dispatch (workflow_dispatch
failure), correlate (run-id correlation failure), input_render
(handlebars input render failure), state_writer (state writer
dropped), notifier_setup (notifier construction failure), credential
(credential resolution failure during flow setup), panic (task
panicked), and the synthetic config_reload (paired with the
(reload) daemon-level key when SIGHUP fails to parse the new config).
For dispatch and correlate errors backed by GitHub’s rate-limit
response, the renderer also prints retry_at: <RFC3339 timestamp>
showing the quota reset window.
(reload) entries in gcit status
Synthetic daemon-level keys (parenthesized names) are not flow names —
they carry daemon-scoped errors the supervisor records under a sentinel
key. The text renderer prefixes those entries with [daemon] so an
operator scanning the output can tell at a glance that the entry is not
a flow they configured. A (reload) entry typically means the most
recent SIGHUP failed to apply (e.g. config now references a missing
credential id).
Workflow runs not correlated
If gcit dispatches but never logs a correlated Run.id, the workflow
likely doesn’t declare the gcit_run_id input. See Polling
strategies — Workflow dispatch correlation
for the run-name YAML.
local_mail notifier returns permission denied
The daemon (effective gid mail) cannot write /var/mail/<user>.
Verify:
ls -l /var/mail/<user>
# expected: -rw-rw---- 1 <user> mail
Fix:
sudo touch /var/mail/<user>
sudo chgrp mail /var/mail/<user>
sudo chmod 0660 /var/mail/<user>
gcit check runs the same validate_spool_writability probe used by
the daemon and surfaces the failure mode (ParentMissing,
SpoolMissing, NotWritable) with the appropriate remediation.
local_mail lock contention
spool lock not acquired within 5s
Another writer (mailx, procmail, another gcit instance) is holding the
flock past LOCK_WAIT_DEADLINE. backon will retry on the next
supervisor cycle. If contention persists, identify and resolve the other
writer.
Discord webhook permanent errors
| status | meaning |
|---|---|
| 401 | webhook token revoked or invalid |
| 403 | webhook lacks permission (rare for Discord webhooks) |
| 404 | webhook deleted |
| 410 | webhook permanently gone |
Re-create the webhook in Discord, update the credential file, and
systemctl restart gcit (not just reload — see Credential management
— Rotation).
gcit run --foreground exits with log init failed
The default mode tries to attach to journald. Pass --foreground for a
stderr-only logger, or fix the journald reachability problem.
Logging
gcit emits structured records via tracing to journald (when run under
systemd) or stderr (under --foreground). All records carry a target
that names the daemon subsystem; --log-filter takes a tracing
EnvFilter-compatible string (default: info,gcit=debug).
Target hierarchy:
| target | what it covers |
|---|---|
gcit::supervisor | top-level select! loop, signal handlers, reload, shutdown |
gcit::flow::poll | per-flow poll loop, strategy selection, SHA-diff observations |
gcit::flow::dispatcher | per-flow workflow_dispatch send + correlation |
gcit::flow::monitor | per-run monitor loop, terminal detection |
gcit::flow::notify | uniform per-notifier outcome (Sent/Skipped/Err) for run-start, run-complete, and per-job-complete fan-outs |
gcit::control | control socket accept loop and per-connection handlers |
gcit::state | state-writer thread, persistence, schema-version checks |
gcit::git::ls_remote | ls-refs transport (gix-protocol) |
Filter on a single target to focus an investigation. For example, to trace only the dispatch path:
gcit run --foreground --log-filter "warn,gcit::flow::dispatcher=trace"
When investigating notifier behaviour, filter on
gcit::flow::notify=trace and read the structured fields (kind,
id, label, job_id, receipt / reason / error).
Diagnostic checklist
When a flow misbehaves, run through these in order:
gcit check— config and credentials valid?gcit status <flow> --format json— what state is the supervisor in? Anylast_errorrecorded?journalctl -u gcit --since "10 minutes ago"— what did the daemon actually do?gcit trigger <flow> --dry-run— does the dispatch payload render correctly with current source SHA?- If still unclear:
journalctl -u gcit -fandgcit trigger <flow>(without--dry-run) to fire a manual dispatch and watch the journald output in real time.
Architecture overview
This chapter is operator-oriented context for what the daemon does at
runtime. It is not a design document. For the source itself, see the
modules under src/.
Module layout
| module | responsibility |
|---|---|
cli | every subcommand implementation (check, install, uninstall, status, trigger, reload, validate-template); exit codes |
config | TOML parse, validation, credential id resolution |
control | the Unix-socket control protocol (length-delimited JSON) |
discord | Discord webhook notifier, embed builder, conclusion → color/label mapping |
flow | per-flow task pipeline (poll → dispatch → monitor) and the daemon supervisor |
git | three polling strategies (github_api, grokmirror, ls_remote), strategy auto-detection, jitter math, SHA-diff |
github | workflow_dispatch send + correlator + run/job monitor |
log | tracing init (journald + stderr layers) |
mail | local-mail (mbox append) notifier |
notify | shared Notifier trait, RunContext, error/outcome types, strict handlebars factory |
state | persistent on-disk state (mpsc-driven, atomic-rename writes, schema-versioned) |
systemd | unit-file rendering, install paths, daemon-reload via session bus |
The library is not a published API. Every pub item is crate-internal
and unstable; integration tests reach across the boundary so
pub(crate) does not span the test boundary.
Supervisor lifecycle
The daemon entry is gcit::flow::run_daemon. The supervisor owns:
- A root
CancellationToken. Each flow runs as a child task tree underroot.child_token(). The supervisor’s root cancels every child on shutdown; per-flow cancellation cancels only the named flow (used for config-reload removal of changed flows). - A
JoinSet<...>of per-flow tasks. Panics in a flow are caught inside the spawned future viastd::panic::AssertUnwindSafe(...).catch_unwind()so the flow name is preserved on the JoinSet exit;JoinError::is_panicalone would discard the per-task identity. - A
tokio::sync::watch<Arc<Config>>so reload-aware components see the latest config without locking. - The control-server accept loop (length-delimited JSON over the socket-activated Unix stream).
- Signal handlers for
SIGTERM,SIGINT, andSIGHUP. sd_notifylifecycle (READY=1once the supervisor’sselect!loop is live;STOPPING=1on shutdown).
On SIGHUP the supervisor reloads the config, diffs each flow against
its previous shape, and:
- Unchanged flows keep their poll/dispatcher pair, credential resources, and rate-limit state — any in-flight run monitor stays attached and the per-credential rate-limit poller keeps refreshing without interruption.
- Changed and removed flows are cancelled cleanly. The new generation starts a fresh poll cycle on the next supervisor cycle.
Credential file rotation takes effect only when no kept-alive flow still references the credential id. Restart the daemon (rather than reload) when rotating because the old token was compromised. See Credential management — Rotation.
Panicked flow tasks are respawned after a 30-second delay
(RESPAWN_DELAY). The constant is deliberately not configurable so the
value pushes the operator toward fixing the underlying bug rather than
tuning it away — a panic in a polling loop that respawns every second
would mask the real fault.
Per-flow task tree
Each enabled flow runs three tasks under its own cancellation token:
| task | purpose |
|---|---|
| poll | runs the strategy auto-detect once, then loops: jitter the cadence, fetch the source, apply the SHA-diff, emit TriggerSignal on change, persist a PollObservation either way |
| dispatcher | reads TriggerSignal from an mpsc (capacity 8), renders dispatch inputs, calls dispatch_with_retry, runs the correlator, emits RunStarted, hands the resulting CorrelationOutcome to a per-run monitor task |
| monitor (per run) | wraps monitor_run in a task that drains MonitorEvent::{Update, Done} into the state writer + notifier dispatch. Awaited inside the supervisor’s JoinSet so completion fan-outs run before shutdown finishes. |
The dispatcher’s run-start fan-out is deliberately not awaited so a slow notifier cannot delay monitor spawn. See Notifiers — Run-start delivery semantics for the shutdown implications.
State persistence
The state-writer thread:
- Drains an mpsc channel (capacity 256, batched in groups of 64) of
StateUpdatevariants emitted by the per-flow tasks. - Persists state via atomic-rename writes to
$STATE_DIRECTORY/state.json(tempfile + write +sync_all+ persist + parent dir fsync). - Outlives the tokio runtime — runs on a dedicated OS thread so shutdown can flush after the runtime tears down.
- Refuses unknown schema versions. The on-disk format is schema-
versioned (
schema: 1); a state file from a future gcit version fails fast rather than silently dropping unfamiliar fields.
StateUpdate variants and their apply rules:
| variant | apply |
|---|---|
PollObservation | LWW on (last_sha, last_poll_at) for the named flow. Creates the flow entry if it does not yet exist. |
PollTimestamp | refresh last_poll_at only (used by strategies that prove a fast-path “no change” without a fresh ObjectId — e.g. grokmirror manifest fingerprint match). Never clears last_sha. |
RunStarted | append to flows[name].active_runs. Multiple runs per flow are supported. |
RunFinished | move the entry from active_runs to notified_runs, capped at 100 per flow. |
FlowRemoved | drop in-memory state for the flow (used by reload to clean up removed flows). |
apply is a pure function of (current state, update) — no side
effects, no logging — so test skeletons under tests/state_*.rs can
drive every variant + interleaving combination deterministically.
Control protocol
The control socket carries length-delimited JSON request/response messages. The supported requests:
Reload— equivalent to SIGHUP.Status { flow }— per-flow snapshot or all flows.Trigger { flow, dry_run }— manually fire a flow’s dispatch path (or render its payload).
Each request carries a UUID id; the response echoes it. The CLI uses a one-shot client per invocation. Request and response shapes are crate-internal and unstable — do not script against the JSON wire format without checking the source first.
Single-instance lock
The daemon holds an exclusive flock(2) on
$RUNTIME_DIRECTORY/gcit.lock (tmpfs). A second gcit run against the
same runtime directory exits with a clear “another gcit is running”
error rather than racing for state-file writes or control-socket binds.
Notifier fan-out
Each run-completion event spawns one task per configured notifier (via
spawn_fan_out). One notifier’s failure never affects the others —
each task is independent. All notifier outcomes log under the single
tracing target gcit::flow::notify with structured kind / id /
label (and optional job_id) fields so an operator can filter for a
specific notifier without inspecting per-call-site targets.
The on_run_complete and per-job on_job_complete fan-outs ARE awaited
inside the per-run monitor task (which the supervisor joins), so those
deliveries either complete or surface their failure in the journal
before shutdown finishes. The on_run_start fan-out is NOT awaited; see
Notifiers — Run-start delivery semantics.