Deploy becomes publish: portal ships as a versioned release artifact
Publish release / publish (push) Successful in 1m7s

The portal repo no longer deploys instances. CI builds once, packages
portal + question_lint + site/ into a tarball, and publishes it as a
Gitea release tagged build-<shortsha> using the run's ephemeral token.
Content repos (uhhm/questions, redoal/questions) now own their instance
deploys - env, unit restart, Caddy route - pinned to a release tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-24 21:21:06 +02:00
co-authored by Claude Fable 5
parent e0094f2e97
commit 2b593ba53f
+40 -129
View File
@@ -1,11 +1,17 @@
name: Deploy name: Publish release
# Portal no longer deploys itself. Each content repo (uhhm/questions,
# redoal/questions) owns its instance - domain, port, env, Caddy route -
# and its deploy workflow downloads a pinned release published here.
# Rolling a new portal version out to a site = bumping PORTAL_RELEASE
# in that site's .gitea/workflows/deploy.yml (an auditable commit).
on: on:
push: push:
branches: [main] branches: [main]
jobs: jobs:
deploy: publish:
runs-on: bare runs-on: bare
env: env:
# The bare runner's own systemd service intentionally has a minimal # The bare runner's own systemd service intentionally has a minimal
@@ -42,8 +48,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# SITE_NAME is read via option_env! (src/app.rs) - compile-time, # SITE_NAME is read via option_env! (src/app.rs) - compile-time,
# not a runtime env var - so it has to be set here, not just in # not a runtime env var. It is only the last-resort fallback name:
# the "Write service env" step below. # each instance's site.yaml (content-driven branding) overrides it,
# so one artifact serves every site.
- name: Build - name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
@@ -52,136 +59,40 @@ jobs:
- name: Build question-lint - name: Build question-lint
run: cargo build --release --bin question_lint --features ssr run: cargo build --release --bin question_lint --features ssr
- name: Ship release - name: Package
run: | run: |
set -euo pipefail set -euo pipefail
rel="/srv/app/uhhm-portal/releases/${{ github.sha }}" tag="build-$(echo ${{ github.sha }} | cut -c1-7)"
mkdir -p "$rel" echo "TAG=$tag" >> "$GITHUB_ENV"
cp "$CARGO_TARGET_DIR/release/portal" "$rel/uhhm-portal" stage=$(mktemp -d)
# questions' own CI execs this directly by path (same bare-metal cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
# runner/host as this job, no artifact download needed) instead cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
# of running its own hand-maintained yq/jq subset of these rules.
cp "$CARGO_TARGET_DIR/release/question_lint" "$rel/question_lint"
# site-root ("target/site" in Cargo.toml) is project-relative, # site-root ("target/site" in Cargo.toml) is project-relative,
# not affected by CARGO_TARGET_DIR - only the plain `cargo build` # not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override. # outputs (release/, front/) move with that override.
cp -r target/site "$rel/site" cp -r target/site "$stage/site"
ln -sfn "$rel" /srv/app/uhhm-portal/current tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint site
rm -rf "$stage"
# Sourced from this repo's own Settings -> Actions Variables/Secrets, # The run's own ephemeral token has write access to this repo -
# not typed onto the host by hand - see the infrastructure repo's # no long-lived PAT to manage. Re-running a build for the same sha
# deploy-runner plan for the exact names/values to configure once. # finds the existing release instead of failing on the tag.
- name: Write service env - name: Publish release
run: |
cat > /etc/app/uhhm-portal.env <<EOF
NATS_URL=${{ secrets.PORTAL_NATS_URL }}
KANIDM_URL=${{ vars.PORTAL_KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.PORTAL_OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.PORTAL_OAUTH2_CLIENT_SECRET }}
PUBLIC_URL=${{ vars.PORTAL_PUBLIC_URL }}
COOKIE_SECURE=true
CONTENT_REPO=https://project.uhhm.no/uhhm/questions
CONTENT_BRANCH=main
SITE_NAME=${{ vars.PORTAL_SITE_NAME }}
LEPTOS_SITE_ADDR=0.0.0.0:3010
# Cargo.toml's site-root ("target/site") is a build-time path;
# the deploy layout copies the bundle to releases/<sha>/site
# (no target/ prefix) - override so the running binary looks
# in the right place for /pkg/*.
LEPTOS_SITE_ROOT=site
AUTOMATION_READ_TOKEN=${{ secrets.PORTAL_AUTOMATION_READ_TOKEN }}
# Read-only (read:user,read:repository,read:organization),
# used only by the GiteaStarred/GiteaOrgRepos resource sources
# (src/resource.rs) - the repo-contents/repo-info calls
# content loading already makes stay anonymous.
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
EOF
# No sudo: the runner's own unit sets NoNewPrivileges=yes, which
# blocks setuid escalation outright (sudo can't work at all under
# it, regardless of sudoers config) - systemctl talks to PID1 over
# D-Bus instead, authorized by a polkit rule scoped to deploy-runner
# + this unit pattern (see /etc/polkit-1/rules.d/10-deploy-runner.rules
# on the host).
- name: Restart service
run: systemctl restart app@uhhm-portal.service
# No sudo here either - the runner is already in the `docker` group,
# so it can talk to the Docker socket directly.
# 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 - a login started on the other host set its
# session cookie there, then landed on PUBLIC_URL's callback with
# an empty session ("no login in progress"). Redirecting www to
# the naked domain keeps every visit on one canonical host
# instead - PUBLIC_URL (repo variable) is set to https://{$DOMAIN}
# to match.
- name: Update Caddy routing
run: |
cat > /etc/caddy/services.d/uhhm-portal.caddy <<'EOF'
www.{$DOMAIN} {
redir https://{$DOMAIN}{uri} permanent
}
{$DOMAIN} {
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
# ── redoal.com - second instance, same build ─────────────────
# One binary serves both faces: branding is content-driven
# (site.yaml in each CONTENT_REPO), so only the env differs.
# The compile-time SITE_NAME baked above is just this instance's
# fallback - redoal/questions' site.yaml overrides it.
- name: Ship redoal release
run: | run: |
set -euo pipefail set -euo pipefail
rel="/srv/app/redoal-portal/releases/${{ github.sha }}" api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
mkdir -p "$rel" auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
cp "$CARGO_TARGET_DIR/release/portal" "$rel/redoal-portal" subject=$(git log -1 --format=%s)
cp -r target/site "$rel/site" body=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"%s"}' \
ln -sfn "$rel" /srv/app/redoal-portal/current "$TAG" "${{ github.sha }}" "$TAG: $(echo "$subject" | sed 's/"/\\"/g')")
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
- name: Write redoal service env -d "$body" "$api/releases" | jq .id) \
run: | || id=$(curl -sf -H "$auth" "$api/releases/tags/$TAG" | jq .id)
cat > /etc/app/redoal-portal.env <<EOF # Replace the asset if a re-run already uploaded one.
NATS_URL=${{ secrets.PORTAL_NATS_URL }} for aid in $(curl -sf -H "$auth" "$api/releases/$id/assets" | jq '.[].id'); do
KANIDM_URL=${{ vars.PORTAL_KANIDM_URL }} curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$aid"
OAUTH2_CLIENT_ID=${{ vars.REDOAL_OAUTH2_CLIENT_ID }} done
OAUTH2_CLIENT_SECRET=${{ secrets.REDOAL_OAUTH2_CLIENT_SECRET }} curl -sf -X POST -H "$auth" \
PUBLIC_URL=https://redoal.com -F "attachment=@portal-$TAG.tar.gz" \
COOKIE_SECURE=true "$api/releases/$id/assets?name=portal-$TAG.tar.gz" > /dev/null
CONTENT_REPO=https://project.uhhm.no/redoal/questions echo "published $TAG"
CONTENT_BRANCH=main
LEPTOS_SITE_ADDR=0.0.0.0:3020
LEPTOS_SITE_ROOT=site
# Same read-only token as uhhm's instance - here it also
# backs the gitea_releases source for the private
# redoal/redoal repo, so the owning user needs read access
# there.
GITEA_API_TOKEN=${{ secrets.PORTAL_GITEA_API_TOKEN }}
EOF
- name: Restart redoal service
run: systemctl restart app@redoal-portal.service
- name: Update redoal Caddy routing
run: |
cat > /etc/caddy/services.d/redoal-portal.caddy <<'EOF'
www.redoal.com {
redir https://redoal.com{uri} permanent
}
redoal.com {
reverse_proxy host.docker.internal:3020
log {
output file /var/log/caddy/redoal.log
}
}
EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile