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-testdrops the test member;--testalone 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-remoteon 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 typepushonly.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 inX-Hub-Signature-256(sha256=<hex>).
Deliveries answer honestly, so the forge’s delivery log reads sanely:
Answer |
Meaning |
|---|---|
|
a new commit built, same shape as the trigger endpoint |
|
understood, nothing new: redelivery, filtered ref, ref deletion, disabled watch, non-push event |
|
the body is not a push payload |
|
signature missing or wrong |
|
no watch for this repository |
|
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:giteaorforgejo(statuses API beside the repo),github(api.github.com), orgeneric(the default; it reports nothing).credential: the forge API token, sent asAuthorization: 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).