GitHub App

A GitHub App lets the dispatcher answer a push or a pull request with a check run on the commit, from the moment the delivery arrives: a queued check before anything starts, a running one while the pipeline works, and a completed one with a conclusion at the end. The dispatcher holds the App’s key; the pipeline never sees it, only a short-lived token for the one repository the delivery came from.

dispatcher -addr :8080 -secret-file /etc/senro/webhook-secret \
           -pipeline ./ci -group ci-main \
           -github-app-id 123456 -github-private-key-file /etc/senro/app.pem

Without the two App flags nothing changes: the dispatcher is the same receive, verify, exec and forget process described on Run it as a server.

Register the App

Create the App under your organization’s settings, point its webhook at the dispatcher, and give it:

SettingValue
Webhook URL and secretThe dispatcher’s address, and the contents of -secret-file
Repository permission ChecksRead and write (checks: write)
Repository permission MetadataRead-only (metadata: read)
Subscribe to eventsPush and Pull request

Install it on the repositories you want checked, and download its private key (a .pem, RSA, in PKCS1 RSA PRIVATE KEY or PKCS8 PRIVATE KEY form). The App id is on the App’s settings page. Every delivery from an installed App carries the installation id, which is how the dispatcher knows whose token to ask for.

Flags

FlagWhat it doesDefault
-github-app-idThe App’s id. Turns App mode onnone
-github-private-key-fileThe App’s PEM private key. Required with -github-app-id, and the reversenone
-github-apiThe API base URL, for GitHub Enterprise: https://github.example.com/api/v3https://api.github.com
-check-nameThe check run’s name in GitHub’s UIsenro
-details-urlThe check’s details link. {run} is replaced by the run id, path-escapednone

Giving one of the first two without the other exits 2 at startup, as does a key file that is missing, holds no PEM block, or is not an RSA key. The error names the file, never its contents. The dispatcher’s -timeout (an hour by default) matters here too, see Tokens.

What happens to a delivery

Only a push or a pull_request delivered by an App installation takes part. Anything else (a plain repository webhook, another event, another provider) goes through the dispatcher exactly as it does without the App flags.

  1. Mint a token. The dispatcher signs a short JWT with the App key and exchanges it for an installation token scoped to that one repository and to two permissions, checks: write and metadata: read, whatever else the installation covers.
  2. Create a queued check on the commit being built: the after SHA of a push, the head SHA of a pull request. Its output says Waiting for dispatcher group <group>.
  3. Take the group and answer 202. The two GitHub calls above happen before the answer, each capped at 4 seconds, so both stay inside GitHub’s ten-second webhook timeout. This is latency the delivery pays before 202 that a dispatcher without an App does not.
  4. Hand the pipeline its environment. The pipeline process gets seven SENRO_GITHUB_* variables, below, and reports through that check from there.
  5. Close the check if the pipeline didn’t. When the process exits, the dispatcher reads the check, and if it is already completed it leaves it alone.

If the token or the check can’t be created, the dispatcher logs it and runs the pipeline anyway: a run that never starts is worse than one without a check. With a token but no check, the variable for the check run id is left empty, and the pipeline’s own notifier creates one.

Who closes the check

What happenedThe check endsClosed by
The pipeline reported its resultWhatever it reportedThe pipeline
Exit 78, no trigger matchedneutral, “No trigger matched”The dispatcher
Exit 0, the check still opensuccess, “Pipeline exited 0”The dispatcher
Any other non-zero exit, the check still openfailure, “Pipeline exited N”The dispatcher
Killed by -timeout or -cancel-in-progresscancelled, “Pipeline was terminated”The dispatcher
Killed by a signal the dispatcher didn’t sendfailure, “Pipeline was terminated”The dispatcher
The pipeline could not start, or the delivery could not be stagedfailure, “Pipeline did not start”The dispatcher
Another delivery holds the groupneutral, “Not run”The dispatcher
The lock itself brokefailure, “Not run”The dispatcher

A dispatcher conclusion for a pipeline that exited non-zero is a fallback. A pipeline that wired its notifier has already completed the check with the real conclusion and the per-step annotations, and the dispatcher sees completed and stops. Exit 78 is closed without that look, since a pipeline that matched nothing reports nothing.

A delivery rejected because the group is busy still gets 409. The queued check created for it is closed neutral at once, rather than left queued forever.

Tokens

An installation token lives one hour and covers the delivery’s repository only, with checks: write and metadata: read: an App installed on a whole organization still hands each pipeline a token for the one repository it is building. When the run ends the dispatcher mints a fresh one, scoped the same way, if the first is 50 minutes old, and keeps the old one if that fails. The token travels in the pipeline’s environment and in Authorization headers and nowhere else: no log line or error carries it, the JWT or the key.

A run longer than an hour has a token that expired mid-run, so its own notifier could no longer update the check. That is why -timeout defaults to one hour: the dispatcher kills the run at that point and can still close the check with a token it refreshes itself.

What the pipeline sees

VariableValue
SENRO_GITHUB_TOKENThe installation token
SENRO_GITHUB_API-github-api
SENRO_GITHUB_REPOowner/name
SENRO_GITHUB_SHAThe commit the check is on
SENRO_GITHUB_CHECK_RUNThe queued check’s id, or empty if it couldn’t be created
SENRO_GITHUB_CHECK_NAME-check-name
SENRO_GITHUB_DETAILS_URL-details-url, the template as given

notify.GitHubChecksFromEnv() reads exactly these, and returns ok false when the token is empty, the repository isn’t owner/name, or the check run id isn’t a number. A pipeline that also runs by hand, with none of them set, then simply runs without a notifier:

opts := []senro.Option{senro.WithTrigger(ev, trigger.OnPush(trigger.Branches("main")))}

if dest, ok := notify.GitHubChecksFromEnv(); ok {
	n := notify.New(dest)
	defer func() { _ = n.Close() }()
	opts = append(opts, senro.WithSink(n))
}

err := senro.Run(ctx, pipeline, opts...)

The destination adopts the dispatcher’s check: run.started moves it to in progress instead of creating a second one, and the completion fills in the conclusion and annotations. See GitHub Checks.

Limits

  • GitHub only. GitLab, Bitbucket and Gitea deliveries are dispatched as before, with no check.
  • One check per delivery, one run per group. The dispatcher is a lock, not a queue; see Run it as a server.
  • A dispatcher crash leaves the check open. Closing the check is step 5, done by the dispatcher when the pipeline exits. A dispatcher that dies mid-run never gets there, so a check the pipeline had not completed itself stays in_progress (or queued) on the commit until something completes it, or GitHub marks it stale after 14 days.

Where to go next