Deploy hooks: CI-triggered app updates - #29
Merged
Merged
Conversation
Lets an external CI pipeline (GitHub Actions or anything that can POST) trigger the same pull-and-restart the dashboard's Update button runs, without Selfhostly ever knowing GitHub, or any Git host, exists. Backend: - New `app_deploy_hooks` table: named, per-app bearer tokens, sha256-hashed at rest (same scheme as join tokens), never recoverable after creation. - `POST/GET /api/apps/:id/deploy-hooks`, `DELETE .../deploy-hooks/:hookId` (session-authed CRUD) and `POST /api/apps/:id/deploy-trigger` (token-authed, rate-limited per app, mounted outside the session/node auth group since the token is its own credential). - The trigger route calls the exact same `UpdateAppContainersAsync` the manual Update button calls - a hook grants no capability a signed-in user didn't already have. - Extensible `source_kind` verifier registry for future trigger kinds beyond the current bearer-token-only `generic` kind. - Full multi-node support: the gateway routes by `node_id` like every other by-id route, is exempted from its JWT check (a deploy-hook token isn't a JWT), and `forwardDeployTriggerToLinkedNode` relays a trigger to a linked secondary while deliberately preserving `Authorization` (unlike the session-forwarding path, which strips it) since that header carries the request's only credential. - Audited (`app.deploy_hook.create/.revoke`, `app.deploy_trigger`); a trigger's actor reads `deploy-hook:<name>` since there's no session. Frontend: - New "Deploy" tab: named hooks with last-triggered time/IP, reveal-once token, a copy-paste GitHub Actions step (reads the instance URL, app id and node id from repo variables rather than baking them into the workflow file), and a warning when the dashboard's own address looks unreachable from a hosted CI runner (localhost/LAN). Verified against real running `cmd/server`/`cmd/gateway` binaries and real Docker, including a real linked secondary over a live WebSocket link: create/list/trigger/revoke, rate limiting, cross-app token rejection, and the full CI-through-gateway-through-primary-through-link-to-secondary path with a genuine `docker compose pull`/`up`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds per-app deploy hooks: a named, revocable bearer token an external CI pipeline (GitHub
Actions or anything that can POST) presents to
POST /api/apps/:id/deploy-triggerto run the samepull-and-restart the dashboard's Update button runs. Selfhostly never talks to GitHub or any Git
host — the pipeline calls Selfhostly, not the other way around. See
docs/design/app-deploy-hooks.mdfor the full design.Why this shape
AppService.UpdateAppContainersAsyncthe dashboard already calls. A hook can't touch a composefile, read a secret, or act on any app but its own.
the session/node auth group because it carries its own credential, not because it skips one.
source_kindverifier registry lets a future trigger kind (e.g. onethat also checks a caller's identity) be added without a schema change or a new route.
node_idlike every other by-idroute and is exempted from its JWT check (a deploy-hook token isn't a JWT); a trigger for an app
on a linked secondary is relayed by the primary with
Authorizationdeliberately preserved(the session-forwarding path strips it — this one can't, since the token is the only credential
the request has).
What's in the box
Backend:
app_deploy_hookstable (hashed tokens, never recoverable after creation) · hookCRUD + trigger routes · rate limiting per app · full audit trail (
deploy-hook:<name>as actor,since there's no session) · multi-node routing and forwarding.
Frontend: a new Deploy tab — named hooks with last-triggered time/IP, reveal-once token, a
copy-paste GitHub Actions step (reads the instance URL, app id and node id from repo variables
rather than baking them into the workflow file), and a warning when the dashboard's own address
looks unreachable from a hosted CI runner (localhost/LAN).
Verification
go build ./...,go vet ./..., fullgo test ./...— green.tsc --noEmit,oxlint --deny-warnings, full test suite,npm run build— green.cmd/serverandcmd/gatewaybinaries, with real Docker anda real linked secondary over a genuine WebSocket link: create → list → trigger → real
docker compose pull/up→ job completion → revoke, plus the fullCI-curl → gateway → primary → link → secondary path end to end. This caught one real bug (an
over-applied
denyNodeAuthMiddlewarethat silently broke hook creation for apps on linkedsecondaries) that no unit test had covered; it's fixed and now has a dedicated regression test.
🤖 Generated with Claude Code