Set up CI: watched repos, webhooks, statuses

Goal: every push builds itself, the instant it lands if you want, and the forge shows the verdict on the commit.

1. Register the watch

Register a repo once; saggar.dev polls its refs and a head it has not seen triggers the same fan-out a manual submission would: one group per commit, attributed to the watch’s project:

$ saggar watch add https://git.example.com/me/web \
    --branch main 'release/*' \
    --formats auto --project web

Semantics worth knowing (full flags: the CLI reference):

  • A head triggers once, ever: dedup is per (watch, commit sha); re-polling a known head is a no-op, a force-push to a new sha triggers afresh.

  • Builds and tests by default: a triggered push produces the formats×arches group plus a test member sharing the group id. --no-test drops the test member; --test alone is a test-only watch.

  • The watch’s flags are the defaults its triggered builds run with; there are no per-push arguments on this path.

  • Polling is plain git (git ls-remote on the interval, first poll at startup), with no credentials attached. A repo whose refs need a credential to read cannot be polled; a repository webhook (next section) still reaches it, and github.com repositories covered by an installation get their private clone at claim time (see Import from GitHub: the GitHub App). No credential flag exists on the CLI, on purpose.

One watch per repo, keyed by the normalized URL. saggar watch trigger <id> polls right now, which is how you test a fresh registration.

2. (Optional) Instant builds with forge webhooks

Polling needs no forge-side setup. To build the instant a push lands, point the forge’s webhook at the service and paste the watch’s webhook secret, shown by saggar watch show w-…:

  • URL: https://saggar.dev/hooks/gitea (Gitea and Forgejo) or …/hooks/github; event type push only.

  • The delivery is authenticated by an HMAC-SHA256 signature of the raw body under the watch’s secret: Gitea/Forgejo send it in X-Gitea-Signature (hex), GitHub in X-Hub-Signature-256 (sha256=<hex>).

Deliveries answer honestly, so the forge’s delivery log reads sanely:

Answer

Meaning

200 {"triggered": […]}

a new commit built, same shape as the trigger endpoint

204

understood, nothing new: redelivery, filtered ref, ref deletion, disabled watch, non-push event

400

the body is not a push payload

401

signature missing or wrong

404

no watch for this repository

410

the watch has no webhook secret configured

The same rules govern a webhook as a poll: the watch’s branch/tag globs decide, one commit builds exactly one group ever (the dedup gate makes redeliveries free), and a webhook cannot add refs the watch does not listen to. Rotate a leaked secret with PUT /api/v1/watches/:id and {"rotate_webhook_secret": true} (then update the forge config).

3. (Optional) Commit statuses

When the watch names its forge and a credential, saggar reports one commit status per triggered commit: pending the moment the group is queued, then one terminal verdict when the whole group settles: success, or failure naming the first failing job’s class and phase. The status carries context: "saggar" and, when the service’s public address is configured, a target_url link to the group.

Set it on the watch (API; the CLI holds no credential flag by design):

$ curl -X PUT -H "X-Auth-Token: sgt-…" \
    -d '{"forge": "gitea", "credential": "<forge API token>"}' \
    https://saggar.dev/api/v1/watches/w-1a2b3c4d
  • forge: gitea or forgejo (statuses API beside the repo), github (api.github.com), or generic (the default; it reports nothing).

  • credential: the forge API token, sent as Authorization: token …. Stored write-only: unlike the webhook secret, it is never served back.

Status reporting is optional and never a build dependency: a generic forge, a missing credential, or a hand-submitted group reports nothing, and a failed status post is logged once and never retried. A forge outage cannot red the build it reports on.

4. Tokens for CI

CI holds the same personal tokens a laptop does: minted through Sign the CLI in, one per machine, expiring when you can afford it:

$ saggar token create "ci-runner" --expires-days 90

The runner then submits headlessly with the environment layering (flags > SAGGAR_SERVER/SAGGAR_TOKEN > cli.toml > default):

# .gitlab-ci.yml (any CI works the same)
build:
  script:
    - SAGGAR_TOKEN=$SAGGAR_TOKEN saggar submit . --formats auto --quiet
    - SAGGAR_TOKEN=$SAGGAR_TOKEN saggar download --group $GROUP -o out/

For a CI machine that only ever consumes artifacts, prefer a pull-only token ({"scope": "registry"}): it authenticates on the registries and nowhere else (see The registries).