Triggers

Pipelines fire from several sources:

  1. Webhooks -- the controller matches an incoming GitHub event against on: blocks under pipelines: in .sparkwing/sparkwing.yaml.
  2. Manual / API invocation -- sparkwing run <pipeline> for local execution, sparkwing pipeline trigger <pipeline> --profile prof for remote dispatch.
  3. Git hooks (optional) -- sparkwing pipeline hooks install writes pre-commit / pre-push / post-commit hook files into .git/hooks/ that fan out to pipelines declaring pre_commit: / pre_push: / post_commit: triggers. Hooks are opt-in and managed: install / uninstall / status are explicit verbs, and unmanaged hooks the user wrote by hand are left alone.

Webhook triggersSection anchor link

# .sparkwing/sparkwing.yaml
pipelines:
  - name: build-deploy
    entrypoint: BuildDeploy
    description: Build and deploy on push to main
    on:
      push:
        branches: [main]
        paths: ["*.go", "go.mod"]      # optional path filter

The trigger keys that go under on: -- and their fields -- are listed in the generated config-reference.md; this page covers how each fires. See api.md for POST /webhooks/github/{pipeline} and HMAC verification.

Both push and pull_request arrive on the same GitHub webhook endpoint (POST /webhooks/github/{pipeline}) and fire the pipeline the URL names. The controller does not read your sparkwing.yaml, so the branches / paths / actions filters under on: are declarative: they document intent, but the controller does not gate on them.

When the policy concerns the branch Sparkwing checks out, enforce it at run start with a pipeline guard. The guard blocks before any step runs:

pipelines:
  - name: deploy
    entrypoint: Deploy
    on:
      push:
        branches: [main] # documents the intended webhook delivery
    guards:
      require: [git:branch=main]

The literal name must match the checked-out head. For pull requests that is the head branch, not the base named by pull_request.branches. git:branch=default matches only when the dispatch supplies default-branch metadata; controller webhook and local trigger claims do not supply it, so use an explicit name for those paths. Branch guards do not implement path filters, custom pull-request action filters, or pull-request base-branch matching. Enforce those policies at the event source or in pipeline code; do not treat the on: fields as authorization controls.

Connecting a repositorySection anchor link

One command registers both sides:

sparkwing cluster webhooks connect --profile prod \
  --repo your-org/my-app --pipeline build-deploy

It generates a 32-byte secret, stores the binding on the controller, creates the repository's webhook through the gh CLI so it posts to the controller's delivery URL for that pipeline, and asks GitHub to ping it. The output names the delivery URL, the hook id, the events, and the status the controller answered the ping with. A ping the controller refused fails the command, so a wrong URL or an unreachable controller is reported at connect time rather than at the first push.

--events selects the events (default push,pull_request). Running connect again on the same repository and pipeline updates that webhook in place and rotates the secret on both sides. The secret is never printed, never passed on a command line, and no route reads it back; it reaches gh on stdin and the controller seals it with the secrets key when one is configured.

sparkwing cluster webhooks disconnect --profile prod \
  --repo your-org/my-app --pipeline build-deploy

Disconnect removes the binding and then deletes the webhook on GitHub. The controller answers with the webhook it was bound to, so a repository connected to two controllers under the same pipeline name loses only this one; a webhook written by hand is matched by its pipeline path instead. Every deleted hook is printed with its URL, and either side already being absent is reported rather than failing.

The delivery URL comes from the controller. Start it with --external-url (env SPARKWING_EXTERNAL_URL) set to the base URL GitHub reaches it at; a controller without one answers with the URL the connect request arrived at, which is right whenever the operator reaches the controller where GitHub does.

The command needs gh authenticated with admin rights on the repository, and a profile whose controller token carries the admin scope.

Where the secret and the allow-list liveSection anchor link

The controller resolves each delivery against the bindings it stores and the document in its environment:

  1. The bindings sparkwing cluster webhooks connect stores, one row per pipeline and repository.
  2. The GITHUB_WEBHOOK_BINDINGS document in the controller's environment, which names per-pipeline repository allow-lists and per-pipeline or per-repository secrets, plus the single GITHUB_WEBHOOK_SECRET.

Stored bindings add to the document rather than replacing it. For one pipeline and repository the stored secret wins, and the stored binding allows that repository whatever the document says, including a document entry that refuses every repository. Everywhere else the document still decides: a pipeline it names keeps its allow-list, a repository it gives a secret keeps that secret, and a pipeline it does not name stays unchecked, so connecting one repository never starts refusing deliveries an existing install accepts.

The manual equivalentSection anchor link

Without the gh CLI, do the same three things by hand:

  1. Add a webhook on the repository (Settings, Webhooks, Add webhook) with the payload URL https://<controller>/webhooks/github/<pipeline>, content type application/json, a secret you generate, and the push and pull request events.

  2. Give the controller that secret: name the pipeline and repository in the GITHUB_WEBHOOK_BINDINGS document, or post the binding yourself:

    curl -sS -X POST https://<controller>/api/v1/webhooks/github/bindings \
      -H "Authorization: Bearer $SPARKWING_TOKEN" \
      -H 'Content-Type: application/json' \
      -d '{"pipeline":"build-deploy","repo":"your-org/my-app","secret":"<secret>"}'
    
  3. Redeliver the ping from the webhook's Recent Deliveries tab and check the controller answered 200.

Pull request triggersSection anchor link

# .sparkwing/sparkwing.yaml
pipelines:
  - name: pr-gate
    entrypoint: PRGate
    description: Lint and test every pull request
    on:
      pull_request:
        branches: [main]               # declarative: PRs targeting main

A pull_request trigger fires on the opened, synchronize, and reopened actions -- the ones that change the diff. Other actions (labeled, closed, edited, ...) are acknowledged and ignored, so a gate does not re-run every time someone relabels the PR. This default action set is applied by the controller; a configured actions field records intent and does not replace that set.

The run checks out the PR head commit. The pipeline reads the PR context from RunContext.Trigger.PullRequest:

type PRGate struct{ sw.Base }

func (p *PRGate) Plan(_ context.Context, plan *sw.Plan, _ sw.NoInputs, rc sw.RunContext) error {
    if pr := rc.Trigger.PullRequest; pr != nil {
        sw.Job(plan, "diff-base", func(ctx context.Context) error {
            _, err := sw.Bash(ctx, "git diff --stat origin/"+pr.BaseRef).Run()
            return err
        })
    }
    return nil
}

Status reporting. Set GITHUB_TOKEN on the controller to report pull_request webhook runs to their head commits. The token needs Commit statuses: Read and write on every repository the controller serves (or repo:status for a classic token). An empty token disables reporting.

Each pipeline uses one context named sparkwing/<pipeline>. The controller posts pending after dispatch, then success, failure, or error when the run finishes. Set SPARKWING_DASHBOARD_URL to the public dashboard base URL to make each status link to /runs?run=<run-id>; the URL must use HTTP or HTTPS, include a host, and omit credentials, a query, and a fragment. Omitted or invalid values publish statuses without a target link. Status delivery is best-effort: GitHub errors are logged and never change webhook admission or the run result. Push, manual, and retry runs do not publish statuses. When overlapping or redelivered webhooks target the same commit and pipeline, the newest accepted run owns the status; terminal updates from superseded runs are ignored. Programs serving controller.Server.Handler directly must call Server.Shutdown; ServeWith does this automatically.

Manual / API invocationSection anchor link

sparkwing run build-deploy                                  # local execution
sparkwing run build-deploy --profile prod                   # local, state via prod
sparkwing pipeline trigger build-deploy --profile prod      # remote dispatch

sparkwing runs triggers list --profile prod surfaces pending / claimed / done triggers on the controller; sparkwing runs triggers get --id ... inspects one. To fire a fresh trigger (the sparkwing equivalent of gh workflow run), use sparkwing pipeline trigger <pipeline> --profile PROF.

Git hooksSection anchor link

Git hooks are opt-in. Declare the trigger on a pipeline in .sparkwing/sparkwing.yaml, giving it a body even when it takes no options:

pipelines:
  - name: fast-checks
    entrypoint: FastChecks
    on:
      pre_commit: {}

Then install the hooks once per checkout:

sparkwing pipeline hooks install     # writes .git/hooks/pre-commit, pre-push, post-commit
sparkwing pipeline hooks status      # report declared, installed, and missing hooks
sparkwing pipeline hooks survey      # report which registered repos git gates at all
sparkwing pipeline hooks uninstall   # remove sparkwing-managed hooks only

status emits one kind: hook record per installed hook when piped, followed by a kind: summary record with the repository, installed count, gate state, and silenced global hooks. Hook records contain the name, pipelines, and whether the hook calls the global hook. --output plain prints only hook names. --output pretty keeps the readable report and repair instructions. If configuration loading fails, status still lists installed hooks, reports the error on stderr, and exits nonzero. It emits no JSON summary in that case.

Hooks installed without --profile prove and run their pipelines with --sw-local-only, even when the project selects a default profile. Pass --profile NAME to install a hook that uses shared storage.

Each managed hook carries a marker comment so uninstall and status can distinguish sparkwing-installed hooks from hand-written ones. Existing unmanaged hooks are skipped on install with a warning. sparkwing info puts the repair command first in its next steps whenever a declared hook is missing or not firing.

pre_commit and pre_push are blocking: the hook aborts the commit or push when a pipeline fails. post_commit is non-blocking -- the commit has already landed, so the hook runs its pipelines, tolerates failures, and always exits zero. Keep post-commit pipelines fast or detach their slow work; the hook runs in the commit's foreground.

Managed hooks render quietly by default: each run prints one progress line and a one-line pass/fail status with the run id, instead of streaming every step into the commit or push. On failure the hook surfaces the failing step's error; the full log stays retrievable with sparkwing runs logs --run <id>. The hook sets SPARKWING_LOG_FORMAT=quiet; export a different value (pretty or json) before the git command to see the full stream.

Hooks are per repository, and the repository says soSection anchor link

The rule, so a new checkout does not have to rediscover it: every repository that declares a gate claims core.hooksPath for its own hook directory, and chains the machine's global hooks from there. A machine-wide hooks directory is never where a gate lives.

That is not a preference. core.hooksPath has no search path -- one directory wins outright -- so a global directory holding the gates would have to dispatch on which repository git happened to be in, and every repository's gate would change whenever that one directory did, with no commit anywhere to show it. The repository that a gate protects is the only place that can declare it, review it, and version it.

The global directory keeps whatever the machine put there. It is reached through a forwarder in each repository's own hook directory, so prepare-commit-msg and friends still fire, and a repository is never asked to choose between its gate and the machine's hooks.

Two failures follow from breaking the rule, and hooks survey names both: a repository whose hooks are shadowed by the global path holds a full set of gates and runs none, and one whose core.hooksPath points at a sibling repository runs that repository's gates instead of its own.

When your machine sets core.hooksPathSection anchor link

A core.hooksPath in your global git config replaces .git/hooks for every repository on the machine. Hooks written into .git/hooks are then never read, and a gate disappears without a message.

pipeline hooks install handles this. When it finds a global core.hooksPath, it sets core.hooksPath for this repository to the repository's own hook directory -- a repository setting wins over the global one -- and chains the global hooks from there, so nothing is lost:

  • A hook name both layers define runs the pipelines first, then hands off to the global hook. A failing blocking pipeline aborts before the hand-off, so the commit or push still stops where you expect.
  • A hook name only the global config defines gets a forwarder, so hooks like prepare-commit-msg keep firing. Only names git itself runs as hooks are forwarded; a helper script or note kept in that directory is left where it is.

Forwarders resolve the global path when they run, so changing the machine's hooks directory does not mean reinstalling. They carry the same marker as any other managed hook, so uninstall removes them -- and releases the repository's core.hooksPath at the same time, putting the machine's hooks back in charge rather than stranding them behind a claim with nothing left to forward.

If the repository already sets its own core.hooksPath pointing somewhere else, install leaves it alone -- that setting was deliberate -- and warns without publishing dormant candidate hooks. Clear it with git -C <repo> config --unset core.hooksPath and re-run sparkwing pipeline hooks install --repo <repo>.

Claiming core.hooksPath is also what would stop a global hook firing if nothing in .git/hooks hands off to it, so install refuses the claim while any global hook name is unforwarded -- a hand-written hook of that name is the usual reason, since install never overwrites one. It names the hook and leaves the machine's hooks in charge without publishing candidates; remove that file and re-run to get both layers. Once the claim is already in place there is no such decision to make, so install and pipeline hooks status both report a global hook nothing forwards to, however it came about.

sparkwing pipeline hooks status and sparkwing doctor both report a hook directory git is not reading, naming the gates that stopped firing and how to restore them.

Which repositories are gated at allSection anchor link

One repository at a time is how a repository gets forgotten. hooks survey answers for every checkout in the local registry at once:

sparkwing pipeline hooks survey            # every registered repo, classified
sparkwing pipeline hooks survey --ungated  # just the ones accepting ungated commits
sparkwing pipeline hooks install --fleet   # arm all of them

Each repository gets one state:

StateWhat it means
armedgit runs the repository's own gate for every hook it declares
shadowedgates are installed and core.hooksPath sends git elsewhere, so none fire
uninstalleda declared hook was never written
borrowedgit runs a gate for a declared hook out of another repository's hook directory
undeclaredno pipeline asks for a hook, so there is nothing to arm

The state answers what git does with the hooks a repository declares. Whether the repository runs a gate is a second question, and it is the one an operator scans for: work is refused only where a declared pre-commit or pre-push fires from that repository. A repository that declares neither -- because it declares no hook at all, or only post-commit -- is counted among the ones accepting ungated work. The pretty STATE column prints no-gate rather than armed for it, the FIRING column names the declared hooks that do run, and the state field of -o json and -o plain keeps the values above unchanged. The remedy there is a declared gate, not an install: an install writes the hooks the pipelines ask for, and that repository asks for none.

Each declared hook lands in exactly one of firing, borrowed, shadowed and missing, so a hook is never reported as both installed and missing. The one-word state takes the worst of them, and borrowed is the worst: a shadowed or uninstalled repository is honest about accepting ungated commits, while a borrowed gate refuses them under a state word that used to read as no gate at all.

borrowed counts as ungated even though commits really are refused. Nothing in the repository declares the file that runs, so an uninstall in the repository that owns it disarms this one with no commit here and no warning, and the rules being enforced are the other repository's. Fixing it means clearing this repository's own override first -- install treats a repository-scoped core.hooksPath as deliberate and will not touch it:

git -C <repo> config --unset core.hooksPath
sparkwing pipeline hooks install --repo <repo>

sparkwing doctor reports the ungated ones too, including on a run that finds nothing else to repair, and states the clean verdict when there are none -- a silent report is indistinguishable from one produced by a build too old to survey a fleet at all.

A survey that cannot read the registry says so and exits non-zero. It never answers with an empty fleet, because that is what a machine with nothing registered answers, and one stray character in repos.yaml used to make an unread fleet look like a swept one. install --fleet and fire --fleet refuse for the same reason. doctor keeps reporting the rest of its sweep and carries the reason in gates_survey_error, since the registry says nothing about this home's runs, locks or daemon.

What a hook directory cannot tell youSection anchor link

Everything above reads files. That is enough to find a gate nobody installed, and not enough to establish one that fires: a shadowed repository inspects as fully installed and refuses nothing, and a borrowed one inspects as installing nothing and refuses everything. Only a commit settles it.

sparkwing pipeline hooks fire            # this repo: make the gate refuse a commit
sparkwing pipeline hooks fire --fleet    # every registered repo

fire stages a file and commits it with the gate told to refuse, then reports whether git refused it and which hook file did. Everything happens in a throwaway linked worktree, which shares the repository's config -- so the same core.hooksPath and the same hooks apply -- while carrying its own index and its own detached HEAD, so the repository's working tree, index, branches and HEAD are untouched whatever the gate does.

fire settles the pre-commit gate only. A pre-push gate needs a remote to fire against, so it is left to the push that exercises it, and a repository that declares no pre-commit trigger has nothing for fire to prove -- --fleet counts it apart from the repositories whose gate refused rather than among them.

Every refusal is checked against a control: the same staged change is committed again with hooks switched off, and has to land. Without that, a commit that failed for an unrelated reason reads exactly like a gate doing its job.

Only a hook sparkwing wrote that carries the self-test guard is ever executed. A hand-written hook, or a managed one installed before the guard existed, is reported unprovable and fire exits non-zero -- an enforcement question that could not be answered is not a pass. Re-run hooks install to replace an older managed hook with one that can be asked.

The guard is the SPARKWING_HOOK_SELFTEST environment variable, and it can only make a gate refuse. There is no value that lets a commit through, because a variable that skipped a gate would be a bypass with an environment variable for a key.

Ungated means a hook that can refuse work does not fire. Only pre-commit and pre-push can, so those are the hooks --ungated, doctor and the --fleet summary count: a repository whose only missing hook is post-commit loses a notification rather than a gate, and one that declares no blocking hook has no gate to arm -- --fleet counts it apart from the repositories it armed rather than among them, because a sweep that reports it as armed reports a gated fleet while every commit in that repository still goes unchecked.

The list is the machine's repo registry -- ~/.config/sparkwing/repos.yaml, which sparkwing configure xrepo add <dir> writes to and which can name fallback_paths directories to scan for */.sparkwing/. That is the whole extent of the survey: a checkout the registry does not reach is not surveyed, not swept by --fleet, and not reported by doctor. Register it, or add the directory it lives under to fallback_paths, before reading a clean survey as a clean machine.

Arming a repository whose gate cannot runSection anchor link

An ungated repository still accepts work; a repository whose gate is armed but cannot execute rejects every commit. The second is worse, and installing a hook is what converts the first into the second.

So before those gates can fire, install runs the repository's blocking gates once. A gate that does not pass -- a red pipeline, an admission daemon the repository's pinned SDK cannot speak to -- rejects the whole installation before any candidate hook filename or config value is published. Existing hooks remain callable throughout the proof. Once every proof passes, each complete hook replaces its predecessor with an atomic rename and only then may the repository's core.hooksPath change. A later write or config error restores every managed hook, global-hook forwarder, file mode, and repository core.hooksPath value exactly. A failed reinstall therefore cannot remove a previously working commit or push gate, and a first install cannot leave only the gates whose proofs happened to pass. The failure is printed with the command to re-run. --no-prove skips the proof for an operator who has already made it.

The proof runs on every install that leaves a gate live, not only the one that claims core.hooksPath. An install rewrites every hook the repository declares, so a repository git already reads needs the same proof as a newly armed one. Proof-before-publication keeps its prior gates live while that proof runs or if it fails.

Hook-launched pipelines are unbound from the repositorySection anchor link

git tells a hook which repository it is acting on through the environment: GIT_INDEX_FILE on the commit paths, GIT_DIR, GIT_WORK_TREE, GIT_PREFIX and friends elsewhere. Those variables are inherited by everything the hook starts, and they outrank a directory argument -- a step that runs git -C /tmp/scratch add -A from inside a gate stages into the commit being gated, not into the scratch repository. A partial commit (git commit -- path) hands over an absolute index path, and that case fails the commit outright.

sparkwing removes the repository-binding GIT_* variables from its own environment before doing anything else, so pipelines, their steps, and the third-party tools those call all discover a repository the way they would from a plain shell. Nothing in a pipeline needs to opt in, and no hook needs reinstalling for it -- the behavior lives in the binary. The one thing that would be lost with them, the index holding the commit under test, is kept under a name git does not act on; see below.

Managed hooks scrub them a second time, in a subshell around the pipeline invocations, which covers the sparkwing on PATH being older than the install that wrote the hook. The subshell is also what keeps the scrub off the hand-off to a global hook: that hook is git's to configure, so it gets the environment git meant it to have.

Identity (GIT_AUTHOR_*, GIT_COMMITTER_*) and config selection (GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM, ...) are left alone: they carry your intent rather than a repository, and test fixtures that isolate themselves rely on them.

Reading what is being committedSection anchor link

The working directory is no substitute for the index git handed the hook. git points a commit hook at the index it is composing that commit in, and only a commit of already-staged content points at the repository's own:

how the commit was madeGIT_INDEX_FILE git exports
content staged first, then git commit.git/index
git commit -a<repo>/.git/index.lock
git commit -- <path><repo>/.git/next-index-<n>.lock

Where git names a lock file, the repository's own index is stale: it does not yet hold what is being committed, so git diff --cached read through it reports an empty change. A staged-diff check that simply lost the binding would pass every such commit without looking at it.

So the binding survives the unbind under a name git does not act on. sparkwing exports SPARKWING_GATE_INDEX, an absolute path, before dropping GIT_INDEX_FILE, and a step that wants the staged content binds it to the one command that should see it:

GIT_INDEX_FILE="$SPARKWING_GATE_INDEX" git diff --cached --name-only

Bind it per command rather than exporting it: ambient, it is exactly the binding that lets a git add in a scratch checkout write into the commit being gated. Treat an empty value as "no gate is running" and fall back to the repository's index, and check the file still exists before using it -- git deletes that index when the commit finishes, and a git pointed at a missing index quietly reads an empty one. sparkwing's own comment gate does this: it reads GIT_INDEX_FILE when a caller set one deliberately, the gate index when a hook started the run, and the repository's index otherwise.

Running checks locally without a hookSection anchor link

If you don't want hooks managing your git lifecycle, just run the pipeline:

// .sparkwing/jobs/lint.go
import sw "github.com/sparkwing-dev/sparkwing/sparkwing"

type Lint struct{ sw.Base }

func (p *Lint) Plan(_ context.Context, plan *sw.Plan, _ sw.NoInputs, rc sw.RunContext) error {
    sw.Job(plan, rc.Pipeline, func(ctx context.Context) error {
        if err := sw.Bash(ctx, "gofmt -l .").MustBeEmpty("formatting drift"); err != nil {
            return err
        }
        _, err := sw.Bash(ctx, "go vet ./...").Run()
        return err
    })
    return nil
}
sparkwing run lint        # runs locally; no git hook required