Actions¶
Actions are the response-side counterpart of Triggers. Where a Trigger fires an evaluation when an event arrives, an Action reacts to evaluation and build lifecycle events and does something: sends an email, calls a webhook, or posts a commit status back to a forge.
Actions are per-project. Four types ship in v1:
| Type | Summary | Prerequisite |
|---|---|---|
send_mail |
Email one or more recipients | Server SMTP configured |
send_web_request |
HTTP POST to an external URL | None |
forge_status_report |
Post commit status to a forge | Outbound integration in the org |
open_pr |
Open/update a flake.lock-update pull request | Outbound integration in the org |
Events¶
| Event | Fired when |
|---|---|
evaluation.queued |
Evaluation enters the queue |
evaluation.started |
Evaluation begins running |
evaluation.building |
Evaluation enters the building phase |
evaluation.completed |
Evaluation completed successfully |
evaluation.failed |
Evaluation failed |
evaluation.aborted |
Evaluation was aborted |
evaluation.action_required |
Evaluation parked waiting for maintainer approval on a fork PR |
evaluation.approval_granted |
Maintainer cleared the approval gate (flips the Approval check to success) |
build.queued |
Build enters the queue |
build.started |
Build starts executing on a worker |
build.completed |
Build completed successfully |
build.failed |
Build failed |
build.substituted |
Build output came from an upstream cache substitution |
An action with an empty events list never fires. forge_status_report and open_pr ignore the events list and expose no event selection in the UI: forge_status_report is hard-wired to the full evaluation and build lifecycle (every evaluation.* and build.* event above), so the per-build check tracks live progress, not just the terminal result; open_pr fires on the input_update evaluation's verify-gate transition (see below).
Send Mail¶
Uses the server-global SMTP configuration (services.gradient.email.*). If SMTP is not configured, creating a send_mail action returns 400.
Config fields:
| Field | Required | Description |
|---|---|---|
recipients |
yes | List of email addresses |
subject_template |
no | Subject line with placeholders |
Subject placeholders: {event}, {project}, {org}, {id}, {status}
Default subject: [Gradient] {event}: {project}
Default body includes: event name, project slug, entity id (eval/build UUID), status, and a link to the Gradient UI.
Send Web Request¶
POSTs a JSON payload to a URL. Optional Authorization: Bearer <token> header.
Config fields:
| Field | Required | Description |
|---|---|---|
url |
yes | HTTPS endpoint |
token |
no | Bearer token (write-only; never returned in reads) |
Request headers:
Content-Type: application/json
X-Gradient-Event: build.completed
Authorization: Bearer <token> # only if token is set
Payload shape:
{
"event": "build.completed",
"project": "my-project",
"organization": "acme",
"id": "<eval-or-build-uuid>",
"status": "completed"
}
Token management: the plaintext token is revealed exactly once - on create or after POST .../regenerate-token. Store it immediately.
Forge Status Report¶
Posts commit status (pending / success / failure / action-required) back to the forge as three separate check runs per PR - gradient/{project}: Approval (fork-PR gate), gradient/{project}: Evaluation (eval phase), and gradient/{project}: Build {label} (one per entry point, labelled by its entry-point name). Each check is updated in place as the phase progresses; the Approval check flips to Success when a maintainer clears the gate, and the Evaluation check is posted as Pending at the same instant so the PR immediately reflects that the pipeline is in flight.
Each Build {label} check tracks its entry point's whole lifecycle, not just the final result: Pending when the build is queued, Running while it builds, then Success when it completes or is substituted from cache. A build that fails reports Failure - and a dependency failure or an abort surfaces as a failed Build check too, rather than leaving the check stuck on Pending. (The graph transitions that queue, dependency-fail, or abort a build run as bulk SQL updates outside the per-build status path, so the reporter is notified for the affected entry points explicitly.)
A run that targets a wildcard other than the project default - e.g. /gradient run packages.x86_64-linux.foo - reports under gradient/{project}: Evaluation: {wildcard} so the custom run shows as its own check line instead of overwriting the default evaluation check.
Maintainer-initiated runs skip the fork-PR approval gate. The gate only exists to hold untrusted external contributions; when the action comes from a repo writer it is not needed. The Evaluation runs immediately (no Approval check) when any of these happen: a maintainer issues /gradient run / /gradient approve on the PR, a maintainer submits an approving review through the forge's native PR-review UI (GitHub / Gitea / Forgejo pull_request_review), or a maintainer force-pushes onto the contributor's branch. In every case the actor is verified as a repo writer via the forge API before the gate is cleared. GitLab is the exception - it emits no webhook on merge-request approval, so use /gradient approve there.
Config fields:
| Field | Required | Description |
|---|---|---|
integration_id |
yes | UUID of an outbound integration in the same org |
The integration must be kind: outbound. The forge type determines the API call format (Gitea, GitLab, GitHub App).
The Test button does not post a synthetic status (a forge rejects a status against a placeholder commit); it runs a non-mutating connectivity check that confirms the integration's credentials can reach the project repository. The same applies to Open PR.
Open PR¶
Opens (or updates) a pull request that bumps the project's flake inputs, driven by a native flake.lock updater. Unlike the other actions it does not react to a project's ordinary runs and has no user-selectable event triggers: it fires only for input_update evaluations, at the point fixed by verify_gate.
Config fields:
| Field | Required | Default | Description |
|---|---|---|---|
integration_id |
yes | (none) | UUID of an outbound integration in the same org |
generator |
no | flake_lock |
Update generator; only the native flake.lock updater exists in v1 |
granularity |
no | per_run |
per_run opens one PR for the whole run; per_input opens one PR per tracked input |
verify_gate |
no | build |
How far the candidate lock is verified before the PR opens. eval/none open once the flake evaluates (evaluation.building); build waits until the whole evaluation completes, i.e. every build succeeded (evaluation.completed) |
branch_pattern |
no | gradient/flake-lock-update |
Branch name for the PR; for per_input it must contain the {input} placeholder |
title_template |
no | (none) | PR title with placeholders |
body_template |
no | (none) | PR body with placeholders |
update_existing |
no | true |
Update an already-open PR in place instead of opening a duplicate |
Tracked inputs. The set of inputs to bump is declared with flake-input override rows on the project. An override whose url is unset marks that input as tracked, and it will be bumped to its newest revision. As a safety gate, the presence of any override with a url set (a pinned input) blocks the run, so a project cannot accidentally open a PR while an input is being held at a fixed revision. v1 supports github, gitlab, and git flake inputs, including git inputs served over ssh:// - the updater clones them with the organization's SSH key, the same key used for private repo access.
Wildcards. An override name may be a glob (*, ?), e.g. nixpkgs* or a bare * for every input, so one row tracks many inputs. Globs are expanded worker-side against the project's flake.lock; a literal override always wins over a glob. Under per_input granularity a glob still opens one PR per matched input (the worker discovers the matches, then the server fans out one update per input).
This is distinct from gradient build's per-run --override-input, which overrides an input for a single build request rather than declaring it tracked for the updater.
When it runs. An input_update evaluation is created whenever a project trigger fires - the periodic polling/time schedule (on every due tick, independent of whether the repository has a new commit, since upstream input bumps never move HEAD), a manual Run trigger, or a Start Evaluation - provided the project has an open_pr action and at least one tracked input. It is self-gated, so triggers on projects without the action are unaffected. The update run is concurrent: it runs alongside the project's normal CI evaluation for the same trigger, and neither aborts the other regardless of the project's concurrency policy.
PR lifecycle. Gradient creates the input_update evaluation; the worker bumps each tracked input to its newest revision with a natively recomputed narHash, and the candidate lock is verified by a normal eval/build per verify_gate. The gate keys off the evaluation's own terminal transition, not a per-build event: build opens the PR at evaluation.completed (which is reached only if every build succeeded), eval/none at evaluation.building. This is robust to a candidate whose closure is already built or substitutable from cache - that fires no fresh build event, yet the evaluation still completes. An empty or no-change patch opens no PR.
The branch is force-pushed to a single clean commit on the current base every run, so re-runs never stack commits or leave the branch behind a moved base (the branch is replaced, not appended). The evaluation's own commit stays blank until that push, then is repointed at the generated flake.lock commit - so the project shows the actual update commit and never the unrelated base commit it was seeded from.
Declarative configuration via Nix¶
services.gradient.state.projects.web-app = {
actions = [
{
name = "notify-failures";
type = "send_mail";
events = [ "evaluation.failed" "build.failed" ];
config = {
recipients = [ "ops@example.com" ];
subject_template = "[Gradient] {event}: {project}";
};
}
{
name = "webhook-completed";
type = "send_web_request";
events = [ "build.completed" ];
config = {
url = "https://hooks.example.com/gradient";
token_file = "/run/credentials/gradient.service/webhook-token";
};
}
{
name = "github-status";
type = "forge_status_report";
config = {
integration = "github-main";
};
}
{
name = "flake-lock-pr";
type = "open_pr";
config = {
integration = "github-main";
granularity = "per_input";
verify_gate = "build";
branch_pattern = "gradient/flake-lock-update/{input}";
};
}
];
};
token_file is read at activation time and stored encrypted. It is not re-read on reload; rotate with services.gradient.state.projects.<name>.actions.<n>.config.token_file and systemctl restart gradient.
State-managed actions (managed: true) cannot be mutated through the API; remove or change them via NixOS config.
Troubleshooting¶
Open the action's Deliveries popup in the UI (Actions page → click the delivery count badge on any action row). Each row shows:
- HTTP status or error message
- Duration (ms)
- Request body sent
- Response body received (if any)
Common issues:
| Symptom | Cause |
|---|---|
400 on create (send_mail) |
SMTP not configured on the server |
Delivery shows connection refused |
Target URL unreachable from the server |
| No deliveries logged | Action active: false, or no matching events fired |
403 on regenerate-token |
Action is not of type send_web_request |