Skip to content

Deploy hooks: CI-triggered app updates - #29

Merged
SamsonNegedu merged 1 commit into
mainfrom
feat/app-deploy-hooks
Sep 28, 2026
Merged

SamsonNegedu merged 1 commit into
mainfrom
feat/app-deploy-hooks

Conversation

@SamsonNegedu

Copy link
Copy Markdown
Owner

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-trigger to run the same
pull-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.md for the full design.

Why this shape

  • Same blast radius as the manual Update button. The trigger route calls the exact same
    AppService.UpdateAppContainersAsync the dashboard already calls. A hook can't touch a compose
    file, read a secret, or act on any app but its own.
  • No new inbound trust surface. The token is checked on every request; the route sits outside
    the session/node auth group because it carries its own credential, not because it skips one.
  • Extensible by design. A source_kind verifier registry lets a future trigger kind (e.g. one
    that also checks a caller's identity) be added without a schema change or a new route.
  • Full multi-node support. The gateway routes a trigger by node_id like every other by-id
    route 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 Authorization deliberately 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_hooks table (hashed tokens, never recoverable after creation) · hook
CRUD + 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 ./..., full go test ./... — green.
  • Frontend: tsc --noEmit, oxlint --deny-warnings, full test suite, npm run build — green.
  • Live-tested against real running cmd/server and cmd/gateway binaries, with real Docker and
    a real linked secondary over a genuine WebSocket link: create → list → trigger → real
    docker compose pull/up → job completion → revoke, plus the full
    CI-curl → gateway → primary → link → secondary path end to end. This caught one real bug (an
    over-applied denyNodeAuthMiddleware that silently broke hook creation for apps on linked
    secondaries) that no unit test had covered; it's fixed and now has a dedicated regression test.

🤖 Generated with Claude Code

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>
@SamsonNegedu
SamsonNegedu merged commit f33982d into main Sep 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant