Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
177 lines
8.0 KiB
YAML
177 lines
8.0 KiB
YAML
name: Deploy instance
|
|
|
|
# This content repo owns its portal instance: which portal version runs,
|
|
# the service env, the systemd unit, and the Caddy route. The portal repo
|
|
# only publishes versioned release artifacts (uhhm/portal's Publish
|
|
# workflow); PORTAL_RELEASE below pins the one this site runs.
|
|
#
|
|
# Rolling out a new portal version = bumping PORTAL_RELEASE (a commit,
|
|
# so every rollout is auditable and revertable). Content-only changes
|
|
# never come through here - lint-and-reload hot-swaps those into the
|
|
# running instance over NATS.
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- .gitea/workflows/deploy.yml
|
|
|
|
env:
|
|
PORTAL_RELEASE: v0.6.23
|
|
INSTANCE: uhhm-portal
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: bare
|
|
steps:
|
|
# Instance config comes from THIS repo's Actions variables/secrets
|
|
# (Settings -> Actions) - not the portal repo's. Guard before
|
|
# touching anything on the host: a missing secret must fail the
|
|
# run, not silently write an empty value into the live env file.
|
|
- name: Check instance secrets are configured
|
|
run: |
|
|
set -eu
|
|
[ -n "${{ secrets.NATS_URL }}" ] || { echo "missing secret NATS_URL"; exit 1; }
|
|
[ -n "${{ secrets.OAUTH2_CLIENT_SECRET }}" ] || { echo "missing secret OAUTH2_CLIENT_SECRET"; exit 1; }
|
|
[ -n "${{ secrets.AUTOMATION_READ_TOKEN }}" ] || { echo "missing secret AUTOMATION_READ_TOKEN"; exit 1; }
|
|
[ -n "${{ secrets.PORTAL_GITEA_API_TOKEN }}" ] || { echo "missing secret PORTAL_GITEA_API_TOKEN"; exit 1; }
|
|
|
|
# uhhm/portal is public, so the asset download is anonymous. The
|
|
# app@ template's ExecStart is /srv/app/%i/current/%i - the shipped
|
|
# binary is named "portal", so link the instance name to it.
|
|
- name: Ship pinned portal release
|
|
run: |
|
|
set -euo pipefail
|
|
api="${{ github.server_url }}/api/v1/repos/uhhm/portal"
|
|
url=$(curl -sf "$api/releases/tags/$PORTAL_RELEASE" | jq -r '.assets[0].browser_download_url')
|
|
rel="/srv/app/$INSTANCE/releases/$PORTAL_RELEASE"
|
|
rm -rf "$rel"
|
|
mkdir -p "$rel"
|
|
curl -sfL "$url" | tar -xz -C "$rel"
|
|
ln -sfn portal "$rel/$INSTANCE"
|
|
|
|
- name: Write service env
|
|
run: |
|
|
cat > /etc/app/$INSTANCE.env <<EOF
|
|
NATS_URL=${{ secrets.NATS_URL }}
|
|
KANIDM_URL=${{ vars.KANIDM_URL }}
|
|
OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }}
|
|
OAUTH2_CLIENT_SECRET=${{ secrets.OAUTH2_CLIENT_SECRET }}
|
|
# Onboarding from a desk (portal 0.3.40+, `invite:` alternatives): a
|
|
# Kanidm service-account token in idm_people_on_boarding. Empty
|
|
# means invites are not configured on this instance.
|
|
KANIDM_API_TOKEN=${{ secrets.KANIDM_API_TOKEN }}
|
|
PUBLIC_URL=${{ vars.PUBLIC_URL }}
|
|
COOKIE_SECURE=true
|
|
# This repo is its own instance's content source.
|
|
CONTENT_REPO=${{ github.server_url }}/${{ github.repository }}
|
|
CONTENT_BRANCH=main
|
|
SITE_NAME=${{ vars.SITE_NAME }}
|
|
LEPTOS_SITE_ADDR=0.0.0.0:3010
|
|
# The release tarball carries the site bundle at site/ (no
|
|
# target/ prefix), so override Cargo.toml's build-time path.
|
|
LEPTOS_SITE_ROOT=site
|
|
# Portal ships content-hashed pkg files (portal.<hash>.js) with
|
|
# a hash.txt in the site root; this makes the server reference
|
|
# them, so a stale cached bundle can never pair with new wasm.
|
|
LEPTOS_HASH_FILES=true
|
|
AUTOMATION_READ_TOKEN=${{ secrets.AUTOMATION_READ_TOKEN }}
|
|
# Read-only (read:user,read:repository,read:organization),
|
|
# used only by the Gitea resource sources (portal
|
|
# src/resource.rs) - content loading stays anonymous.
|
|
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
|
|
EOF
|
|
|
|
# Activate only after the release and env are fully written, so a
|
|
# failed download or missing config never takes the site down.
|
|
#
|
|
# No sudo: the runner's unit sets NoNewPrivileges=yes - systemctl
|
|
# talks to PID1 over D-Bus, authorized by the polkit rule scoped
|
|
# to deploy-runner + the app@* unit pattern.
|
|
# enable: the unit must come back after a host reboot (2026-08-30 a
|
|
# reboot left both portal instances down - deploys had only ever
|
|
# started them). Needs the manage-unit-files polkit grant; until
|
|
# that's on the host the enable is reported and skipped, never a
|
|
# failed deploy.
|
|
- name: Activate and restart
|
|
run: |
|
|
ln -sfn "/srv/app/$INSTANCE/releases/$PORTAL_RELEASE" /srv/app/$INSTANCE/current
|
|
systemctl enable app@$INSTANCE.service \
|
|
|| echo "::warning::could not enable app@$INSTANCE (polkit) - unit will not survive a reboot"
|
|
systemctl restart app@$INSTANCE.service
|
|
|
|
# Apex and www are separate cookie scopes (no shared Domain
|
|
# attribute on the session cookie), but Kanidm's redirect_uri is
|
|
# fixed to PUBLIC_URL - redirecting www to the naked domain keeps
|
|
# every visit on one canonical host. The runner is in the docker
|
|
# group, so no sudo here either.
|
|
- name: Update Caddy routing
|
|
run: |
|
|
cat > /etc/caddy/services.d/$INSTANCE.caddy <<'EOF'
|
|
www.{$DOMAIN} {
|
|
redir https://{$DOMAIN}{uri} permanent
|
|
}
|
|
|
|
{$DOMAIN} {
|
|
# Mail clients look for their configuration on the bare domain -
|
|
# Thunderbird at /.well-known/autoconfig, Outlook by POSTing to
|
|
# /Autodiscover. Stalwart already generates both, templated on the
|
|
# address asked about, so these two paths go to it instead of being
|
|
# copied into this repo: one source of truth, and no second
|
|
# hostname to certify. Host is rewritten to the name Stalwart
|
|
# answers for; it never sees a request for the bare domain
|
|
# otherwise. (MTA-STS cannot be served here - RFC 8461 requires
|
|
# mta-sts.<domain> - so it keeps its own record and route.)
|
|
# PACC, the standard that replaces guessing at hostnames: one JSON
|
|
# document on the mail domain itself advertising every protocol,
|
|
# which is why it belongs here and not on a subdomain. Stalwart
|
|
# generates it, and /.well-known/mail-v1.xml beside it.
|
|
@pacc path /.well-known/user-agent-configuration.json /.well-known/mail-v1.xml
|
|
handle @pacc {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
handle /.well-known/autoconfig/* {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
# CalDAV and CardDAV discovery (RFC 6764): a client looks for
|
|
# these on the domain its address is in, and Stalwart answers
|
|
# each with a 307 to the collection. iOS uses these even though
|
|
# Apple Mail ignores autoconfig and Autodiscover entirely.
|
|
# The collections the two redirects above point at. Stalwart builds
|
|
# those Location headers from the forwarded host, not from the Host
|
|
# this route rewrites, so they land on the bare domain - which means
|
|
# the collections have to be reachable here too, or a client follows
|
|
# a 307 into portal's catch-all. Keeping them on one host is the
|
|
# better answer anyway: the client never changes origin.
|
|
@dav path /.well-known/caldav /.well-known/carddav /dav/*
|
|
handle @dav {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
@autodiscover path /Autodiscover/* /autodiscover/* /AutoDiscover/*
|
|
handle @autodiscover {
|
|
reverse_proxy stalwart:8080 {
|
|
header_up Host mail.{$DOMAIN}
|
|
}
|
|
}
|
|
|
|
handle {
|
|
reverse_proxy host.docker.internal:3010
|
|
}
|
|
|
|
log {
|
|
output file /var/log/caddy/www.log
|
|
}
|
|
}
|
|
EOF
|
|
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|