GitHub
shed uses one GitHub App for everything it needs from GitHub: signing you in, reading your repositories, hearing about pushes, and checking CI. You create it once, from the dashboard, in a couple of clicks.
One GitHub App
A GitHub App is an identity with its own permissions, installed on the accounts and organizations whose repositories you want to deploy. shed asks for the least it needs, all read-only: contents, metadata, checks, and statuses, plus the push event. It never writes to your repositories.
The App's credentials (app ID, slug, client ID and secret, webhook secret, and private key) live in shed's database, not in shed.toml. shed signs a short-lived JWT with the private key to act as the App, and trades it for installation tokens to clone and to read CI. Which repositories shed can see is decided on GitHub, by where you install the App and which repositories you grant it, never by who is signed in.
First-run setup
On first run there is no App. shed logs a warning containing the setup URL and a one-time token, and the dashboard sends you to /setup. The token proves you can read the server's log, since nobody else can create the App. It is regenerated at every boot until setup finishes, then discarded.
The manifest describes the App completely, so you don't fill in a GitHub form: its URL and callback are derived from server.url, the webhook points at /api/github/webhook, and the App is created public so you can install it on organizations as well as your own account. Its name is derived from the dashboard host and capped at GitHub's 34 characters, for example shed-shed-example-com. After conversion shed redirects you to GitHub's install page for the new App. If anything goes wrong the browser returns to /setup?error=… with invalid_token, already_configured, or failed.
Reusing an existing App
If you lost the database but not the App, import it instead. Generate a client secret, a private key, and a webhook secret in the App's GitHub settings, and give them to the setup page with the token. shed signs a JWT with the key and calls GET /app to confirm the ID, key, and client ID belong together, and learns the slug from the answer. GitHub offers no way to check the client secret or the webhook secret, so a typo in those only shows up later, as a failed sign-in or rejected webhooks. The App's webhook and callback URLs must already point at server.url.
Sign-in and access
Signing in is the standard OAuth web flow, through the same App. /api/auth/login redirects to GitHub with the App's client ID and a random state cookie. GitHub returns to /api/auth/callback, where shed exchanges the code, reads your profile, and checks your login against the list in its config.
[auth] allowed_users = ["your-login"] # case-insensitive; an empty list denies everyone
That list is the only access control: there are no roles, so everyone on it can do everything. The rule is checked on every request, not just at sign-in. When shed starts it permanently revokes the sessions of logins that are no longer listed, so to remove someone, edit the config and restart, which also closes their open streams.
A successful sign-in sets the shed_session cookie: 32 random bytes of which only a hash is stored, HttpOnly, SameSite=Lax, Secure when server.url is https, valid for 30 days. More in Sessions.
Push webhooks
When you push, GitHub POSTs to /api/github/webhook. This endpoint takes no session; its only credential is an HMAC-SHA256 signature of the body with the App's webhook secret. shed rejects a malformed signature before it reads a byte of the body, and applies a concurrency cap and a token bucket before doing any hashing.
A push to refs/heads/<branch> deploys every app service whose repo and branch match and whose autoDeploy is on. The repo matches case-insensitively, as GitHub treats it; the branch matches exactly. Each deployment gets the trigger push and the head commit's SHA, first message line, and author.
| Response | When |
|---|---|
409 | The GitHub App isn't configured yet. |
401 | The signature is missing, malformed, or doesn't match. |
429 | More than 4 deliveries in flight, or the bucket (burst of 8, then 1 per second) is empty. |
413 | The payload is over 1 MiB. Deploy manually. |
400 | The body can't be read or parsed. |
202 | Accepted. Also returned for events other than push, deleted branches, tags, and pushes nothing tracks. |
503 | The same delivery is still being processed, or shed couldn't store the push. Redeliver it from GitHub. |
GitHub does not redeliver a failed delivery on its own. Open the App's settings on GitHub, then Advanced → Recent Deliveries, and redeliver it from there. GitHub only redelivers deliveries from the past 3 days. Deliveries are deduplicated per service by a hash of the payload, for up to 24 hours and 1,024 entries in memory, cleared on restart. A redelivery after a 503 therefore deploys only the services that missed out the first time.
Pushes that can't deploy yet
shed stores each push in its database before deploying it, one per service: a newer push replaces an older one that hasn't deployed yet. If the service can't take a deployment right now, the delivery still gets 202 and the push waits:
- the service is held for a backup or restore,
- it is fenced by a failed restore, until you clear the fence,
- it is being deleted, or shed is shutting down.
shed tries waiting pushes again every 10 seconds and when it starts, so a push that arrived just before a restart deploys after it. A waiting push is dropped, with a log line, if the service no longer deploys that repo and branch on push (you turned autoDeploy off or changed the source), if the service is deleted, or if any deployment of the service was created after the push arrived, such as a manual deploy or a redeploy, so that an old push never replaces newer work. shed tells this by remembering which deployment was the service's latest when the push arrived, not by comparing times, and it creates pushes' and manual deployments one at a time, so a deploy you start while a push is deploying always comes after it.
A push sent while shed itself is down, or unreachable, never arrives, and shed can't know about it. Redeliver it from Recent Deliveries, or deploy the branch manually.
Waiting for CI
With waitForCi on, a deployment with a commit holds in waiting until the commit's checks pass. This is how you keep a red build from reaching production when your CI runs on GitHub Actions or anything else that reports checks or commit statuses. Manual deployments of a branch head wait too; redeploys don't, since they reuse an image that already passed.
Every 10 seconds shed reads the commit's check runs and its combined commit status and folds them into one state. A completed check run passes if its conclusion is success, neutral, or skipped; anything else, including cancelled and timed out, fails. A failure outranks anything still pending, so a red check ends the wait immediately.
Two edge cases matter. First, no checks isn't approval: shed keeps waiting until some appear and pass, because a workflow may not have registered yet. If your repository has no CI at all, a service with this setting never deploys, so leave it off. Second, errors reaching GitHub don't end the wait; they're logged to the build log as Checking CI status: … and polling continues.
| Outcome | Deployment |
|---|---|
| All checks pass | Continues with the build. |
| Any check fails | skipped, with the error "CI failed". Nothing was built. |
| Still pending after 60 minutes | failed, with "timed out after 1h0m0s waiting for CI". |
| A newer push arrives | canceled, "superseded by a newer deployment". The newer one starts its own wait. |