Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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=yes by default (or User=gcit + Group=mail when local_mail is configured), and a full hardening profile.
  • Strict secrets. Credentials are wrapped in secrecy::SecretString, redacted in Debug/Display and 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; --foreground exists 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. --foreground mode is intended for development and testing; the supported deployment surface is the systemd units installed by gcit 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_mail requires a system-scope install. The notifier writes directly to /var/mail/<user> and depends on the daemon being a member of the mail group. Under --user installs the daemon runs as the operator and cannot append to system mail spools — gcit install --user rejects any config containing local_mail destinations with a clear error. Use gcit install --system for any flow with a local_mail destination, and the installer will switch the unit from DynamicUser=yes to User=gcit + Group=mail automatically.

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 --user always 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.toml when $XDG_CONFIG_HOME is unset). A non-root shell running gcit run --foreground, gcit check, or gcit status without --config reads the operator’s user-scope config rather than the system one.
  • Root invocations without --config use the bottom default /etc/gcit/config.toml.
  • An explicit --config always wins regardless of euid or scope.

Top-level tables

tablepurpose
[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].

fieldtypebounddefault
source_intervalhumantime durationinclusive [15s, 24h]unset; resolved per strategy (60s for GitHub API and grokmirror, 5m for ls-remote)
job_intervalhumantime durationinclusive [15s, 24h]30s
jitterfloatinclusive [0.0, 0.5]0.1
cooldownhumantime duration0s 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]

fieldtypenotes
filterstring (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]

fieldtypebounddefault
request_timeouthumantime durationinclusive [1s, 300s]30s
max_concurrentunsigned integeraccepted for back-compatdeprecated; 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.

fieldtyperequirednotes
namestringyesunique across all flows; charset [a-zA-Z0-9_-]+, 1..=64 chars
enabledbooleanno, default truedisabled flows parse and validate but are not spawned
descriptionstringnorenders as {{flow.description}} in templates; missing renders as empty string
sourceinline tableyessee [flow.source]
actioninline tableyessee [flow.action]
destinationarray of inline tablesnosee [[flow.destination]]
pollinline tablenoper-flow override of [poll] defaults

[flow.source]

The git side of the flow — what gcit polls.

fieldtyperequirednotes
urlstringyesparsed via url::Url; scheme must be one of http, https, ssh, git, file
refstringyesmust start with refs/. For the GitHub API strategy, only refs/heads/* and refs/tags/* are supported by the underlying get_ref endpoint
credential_idstringnoauth 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.

fieldtyperequirednotes
kindstringyescurrently github_workflow_dispatch only
repostringyesowner/repo form (exactly one /)
workflowstringyesnon-empty; cannot contain /, \, or .. (a workflow filename, not a path)
refstringyesmust start with refs/; the workflow ref to dispatch on the target repo
credential_idstringyesGitHub fine-grained PAT (github_pat_*) with Actions read+write on repo
inputsinline tablenostring-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"

fieldtyperequirednotes
kindstringyes"discord_webhook"
credential_idstringyesDiscord webhook URL stored as a credential — host must be one of discord.com, discordapp.com, ptb.discord.com, or canary.discord.com
fire_onarray of FireEventno, default ["run_complete"]duplicates rejected at config load — one error per duplicate
templateinline tablenosee Templates

kind = "local_mail"

fieldtyperequirednotes
kindstringyes"local_mail"
userstringyeslocal Unix user; charset [a-zA-Z0-9_-]+, 1..=32 chars; the daemon writes to /var/mail/<user>
fire_onarray of FireEventno, default ["run_complete"]duplicates rejected at config load
templateinline tablenosee 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):

valuewhen
run_startafter a workflow_dispatch succeeds AND the resulting Run.id is correlated, before the per-run monitor is spawned
job_completeeach individual job within a run reaches a terminal state (per-job, in observation order)
run_completethe 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):

keysource
{{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:

keysource
{{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 check shows 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:

statemeaningexit code
1Config parses, validates, and every referenced credential resolves now via one of the three resolution steps.0
2Config parse or validation error, OR a credential id is referenced by a flow but not declared anywhere.78 (EX_CONFIG)
3Config 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:

  1. Update the credential file in place (or re-deploy via your secret management tool).
  2. Run systemctl restart gcit (system) or systemctl --user restart gcit (user). A SIGHUP reload 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:

hoststrategydefault interval
github.com / www.github.comGithubApi60s
git.kernel.orgGrokmirror60s
anything else (or an unparseable URL)LsRemote5m

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 a credential_id, the resolved token is supplied as a Bearer header. Public repos can be polled anonymously.
  • Ref support. Only refs/heads/* and refs/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 Transient and the caller awaits the rate-limit reset.
    • 5xx and network errors map to Transient.
    • Other 4xx map to Permanent.

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 → RepoNotInManifestPollOutcome::UnbornRef.
    • Configuration / decode errors that won’t recover → Permanent.

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 as PollOutcome::UnbornRef.

SHA-diff comparator

Every strategy yields a PollOutcome. The supervisor pairs each Refreshed { sha } with the previously recorded last_sha:

last_shaobservedtrigger?
None (first poll)anyno — record baseline
Some(prev)prevno — unchanged
Some(prev)!= prevyes

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:

  1. Generating a UUID at dispatch time and injecting it into the workflow_dispatch inputs payload as gcit_run_id.
  2. Polling list_workflow_runs and matching by Run.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:

  1. [flow.poll].source_interval
  2. [poll].source_interval
  3. 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:

hookwhen
on_run_startafter a workflow_dispatch succeeds AND the resulting Run.id is correlated, before the per-run monitor is spawned
on_job_completeonce per job per run, in observation order
on_run_completeexactly 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, or canary.discord.com. Other hosts are rejected at config load.
  • Embed templates. All template fields are optional handlebars templates rendered against the run context.
fieldscopenotes
titleonce per runembed title; truncated codepoint-safe to Discord’s limit
descriptiononce per runembed description; truncated codepoint-safe
collapsed_summaryonce per runshown inline when many jobs are present, replacing per-job fields
field_nameonce per jobper-job embed field name; {{job.*}} available here
field_valueonce per jobper-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 → Transient with the API’s retry_after hint.
    • 5xx / Hyper / Timeout → Transient.
    • Validation failures (embed too large, etc.) → Permanent (config bug).
  • Cancellation. Each per-flow CancellationToken is 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 cancel source reflects 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_NOFOLLOW on 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 returns Transient with "spool lock not acquired within 5s".
    • O_APPEND write of the formatted message.
    • fsync(2) after write to durably commit.
  • Templates.
fieldscopenotes
subjectonce per runmbox Subject: header
bodyonce per runmbox body — codepoint-capped for very large messages
  • Cancellation semantics. The mail notifier splits its 5-second deadline: LOCK_WAIT_DEADLINE bounds only the flock acquisition wait. After the lock is acquired, write_all and the post-lock fsync run unbounded. A slow disk that holds fsync longer 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_blocking running flock(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. The cancelled message includes the addendum "the blocking task may still write if the lock becomes available before the deadline" so an operator reading journalctl -u gcit knows 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=yes to User=gcit + Group=mail
    • SupplementaryGroups=mail.
  • Pre-flights /var/mail/<user> for each configured user — warns when the spool is missing or not group-writable, and prints the exact touch / chgrp mail / chmod 0660 commands to fix it.
  • Creates the static gcit user via useradd --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:

fieldvalues
kinddiscord, local_mail
idthe destination id from config
labelrun-start, run-complete, job-complete
job_idonly 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:

reasonmeaning
NotConfiguredthe destination uses a default no-op on_run_start / on_job_complete impl. Never returned by on_run_complete.
FireOnMismatchthe destination’s fire_on array does not include the triggering event. Operator opted out.
RateLimitedthe 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 prefixmeaningoperator 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 deadlineCancel 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 5sAnother 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 DiscordCancel 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

flagpurpose
--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.
--versionprint version + git SHA and exit
--helpprint help and exit

Exit codes

Exit codes follow sysexits.h:

codenamemeaning
0EX_OKsuccess
64EX_USAGEbad invocation (missing required arg, mutex violation)
65EX_DATAERRbad template (returned by gcit validate-template)
70EX_SOFTWAREinternal software error
71EX_OSERROS-level failure (FS error, manifest sha mismatch on uninstall without --force)
75EX_TEMPFAILtransient (control socket unreachable, daemon not responding)
78EX_CONFIGconfiguration 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 --user or --system is required (clap ArgGroup mutex).
  • --non-interactive skips the path-preview confirmation prompt for CI.
  • --force overwrites existing managed files. Without it, encountering any existing managed file is fatal (EX_CONFIG=78).
  • --dry-run renders 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_mail is 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 into systemd-analyze security.

The wizard (skipped under --dry-run):

  1. Walks each referenced credential id and prints the URL hint, target repo, install path, and chmod 0600 command. Annotates already- configured credentials with a line.
  2. For configs with local_mail destinations, pre-flights every /var/mail/<user> and prints actionable warnings.
  3. Previews every file path it will create ([exists] / [new] per entry) and refuses to write without confirmation.
  4. Creates the static gcit user via useradd when local_mail is present + scope is system.
  5. Writes files atomically + writes the install manifest at $STATE_DIRECTORY/.install-manifest.json.
  6. Triggers daemon-reload via the user session bus (or hints for --system).
  7. 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 --force to remove them anyway.
  • Path-traversal defense: every manifest entry must canonicalize under one of the expected install directories. A tampered manifest pointing at /etc/passwd is 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:

statemeaningexit
1Config parses, validates, and every credential resolves now.0
2Config error OR credential id referenced but not declared.78 (EX_CONFIG)
3Config 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 shows name: state. Indented summary lines for last_sha, last_poll_at, active_runs, notified_runs, last_error[kind] at: message. The active_runs: and notified_runs: lines are suppressed when the count is zero. The retry_at sub-line appears only for GithubErrorKind::RateLimited errors.
  • --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:

  1. Register the template into a notify::strict_handlebars() instance — strict_mode + DEREGISTERED_HELPERS + no_escape.
  2. AST check — reject single-segment references like {{flow}} or {{gcit_run_id}}. The runtime template namespace is dotted only.
  3. Render against the probe context (the same shape gcit check uses 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:

shellsuggested path
bash~/.local/share/bash-completion/completions/gcit (per-user) or /etc/bash_completion.d/gcit (system)
zsha 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
powershella .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

scopeservice unitsocket unitconfig dirstate 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=notify and NotifyAccess=main. The daemon emits READY=1 once the supervisor’s select! 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> run and ExecReload=<binary> reload. The binary path is resolved at install time via std::env::current_exe() so a --user install rooted at ~/.cargo/bin/gcit records that exact path rather than a hardcoded /usr/bin/gcit.

User model

config has local_mail?user model
noDynamicUser=yes (systemd assigns a transient uid each start)
yesUser=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):

  1. 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 --user scope are annotated (owned by root — rotate via sudo) so operators know future rotations require sudo.
  2. Local mail check (only when local_mail is configured). For each user named in a local_mail destination, probes /var/mail/<user> and prints either confirmation or actionable warnings (sudo touch, sudo chgrp mail, sudo chmod 0660).
  3. 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 any useradd invocation the wizard will run.
  4. Confirmation. Prompts Proceed? [y/N]. Operator changing their mind exits 0; only y / Y / yes / YES / Yes proceed. --non-interactive skips this prompt.
  5. 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.
  6. daemon-reload. Triggered via the user session bus (zbus), or skipped with a hint when running --system without root.
  7. Next steps. Prints the exact systemctl daemon-reload && systemctl enable --now gcit.socket gcit.service command for the chosen scope and the journalctl -u gcit -f follow-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.

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

statusmeaning
401webhook token revoked or invalid
403webhook lacks permission (rare for Discord webhooks)
404webhook deleted
410webhook 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:

targetwhat it covers
gcit::supervisortop-level select! loop, signal handlers, reload, shutdown
gcit::flow::pollper-flow poll loop, strategy selection, SHA-diff observations
gcit::flow::dispatcherper-flow workflow_dispatch send + correlation
gcit::flow::monitorper-run monitor loop, terminal detection
gcit::flow::notifyuniform per-notifier outcome (Sent/Skipped/Err) for run-start, run-complete, and per-job-complete fan-outs
gcit::controlcontrol socket accept loop and per-connection handlers
gcit::statestate-writer thread, persistence, schema-version checks
gcit::git::ls_remotels-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:

  1. gcit check — config and credentials valid?
  2. gcit status <flow> --format json — what state is the supervisor in? Any last_error recorded?
  3. journalctl -u gcit --since "10 minutes ago" — what did the daemon actually do?
  4. gcit trigger <flow> --dry-run — does the dispatch payload render correctly with current source SHA?
  5. If still unclear: journalctl -u gcit -f and gcit 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

moduleresponsibility
clievery subcommand implementation (check, install, uninstall, status, trigger, reload, validate-template); exit codes
configTOML parse, validation, credential id resolution
controlthe Unix-socket control protocol (length-delimited JSON)
discordDiscord webhook notifier, embed builder, conclusion → color/label mapping
flowper-flow task pipeline (poll → dispatch → monitor) and the daemon supervisor
gitthree polling strategies (github_api, grokmirror, ls_remote), strategy auto-detection, jitter math, SHA-diff
githubworkflow_dispatch send + correlator + run/job monitor
logtracing init (journald + stderr layers)
maillocal-mail (mbox append) notifier
notifyshared Notifier trait, RunContext, error/outcome types, strict handlebars factory
statepersistent on-disk state (mpsc-driven, atomic-rename writes, schema-versioned)
systemdunit-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 under root.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 via std::panic::AssertUnwindSafe(...).catch_unwind() so the flow name is preserved on the JoinSet exit; JoinError::is_panic alone 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, and SIGHUP.
  • sd_notify lifecycle (READY=1 once the supervisor’s select! loop is live; STOPPING=1 on 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:

taskpurpose
pollruns 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
dispatcherreads 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 StateUpdate variants 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:

variantapply
PollObservationLWW on (last_sha, last_poll_at) for the named flow. Creates the flow entry if it does not yet exist.
PollTimestamprefresh 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.
RunStartedappend to flows[name].active_runs. Multiple runs per flow are supported.
RunFinishedmove the entry from active_runs to notified_runs, capped at 100 per flow.
FlowRemoveddrop 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.