diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml new file mode 100644 index 0000000..198a8aa --- /dev/null +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -0,0 +1,85 @@ +name: Dimension Lab website + +on: + pull_request: + types: + - opened + - synchronize + - reopened + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: dimensionlab-website-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + ci: + runs-on: docker + timeout-minutes: 30 + steps: + - name: Checkout + uses: https://data.forgejo.org/actions/checkout@v4 + with: + fetch-depth: 0 + submodules: false + + - name: Initialize submodules + run: | + git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + git submodule update --init --recursive + + - name: Install Bun + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14" + "$HOME/.bun/bin/bun" --version + + - name: Check, test, and build + run: | + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + bun install --frozen-lockfile + bun run check + bun run test + bun run build + + deploy: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: deploy + timeout-minutes: 30 + steps: + - name: Checkout + run: | + if [ -d .git ]; then + git remote set-url origin git@git.dimensionlab.net:vince/dimensionlab-website.git + else + git init + git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git + fi + git fetch --force --prune --depth=1 origin "$GITHUB_SHA" + git checkout --force --detach "$GITHUB_SHA" + git clean -ffdx + + - name: Initialize submodules + run: | + git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + git submodule update --init --recursive + + - name: Verify Podman deployment socket + run: | + command -v podman + command -v systemctl + unit="$(timeout 15s podman inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')" + test "$unit" = "dimensionlab-website.service" + + - name: Deploy production website + env: + DEPLOY_CONTAINER_CLI: podman + DEPLOY_EVENT_NAME: ${{ github.event_name }} + DEPLOY_REF: ${{ github.ref }} + DEPLOY_RESTART_STRATEGY: quadlet-container + DEPLOY_SHA: ${{ github.sha }} + run: scripts/deploy-dimensionlab-website.sh diff --git a/README.md b/README.md index 00d9708..85177c5 100644 --- a/README.md +++ b/README.md @@ -161,3 +161,27 @@ The env file must provide `AGENT_CONFIG_TOKEN`. Runtime defaults inside the image set `HOST=0.0.0.0`, `PORT=3000`, `DATABASE_URL=file:/data/dimensionlab.sqlite`, and `DASHBOARD_MIGRATIONS_DIR=/repo/apps/web/drizzle`. + +### Forgejo Actions Deployment + +Merges to `main` run `.forgejo/workflows/dimensionlab-website.yml`. Pull +requests run check, test, and build only; the deploy job is guarded to run only +for `push` events on `refs/heads/main`. + +The workflow uses two runner classes. Pull request CI runs on the containerized +`docker` runner. Production deployment runs on a separate host runner with the +`deploy:host` label so the guarded deploy script can use the user's rootless +`podman` and `systemctl --user` commands directly. The deploy job uses a +shell-only `git fetch` checkout so the host runner does not need a Node runtime +for checkout actions. + +```yaml +runner: + labels: + - deploy:host +``` + +The deploy job also performs a host preflight against the +`dimensionlab-website.service` Podman label before it builds or restarts the +production container. Do not give the general pull request runner deployment +socket access; keep deploy privileges on the dedicated `deploy` runner. diff --git a/apps/web/package.json b/apps/web/package.json index 790663c..015fc55 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,8 +8,8 @@ "build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", "preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js", "check": "tsc --noEmit", - "test": "vitest run", - "test:unit": "vitest run", + "test": "bun --bun vitest run", + "test:unit": "bun --bun vitest run", "test:e2e": "env -u NO_COLOR playwright test", "db:generate": "drizzle-kit generate", "db:check": "drizzle-kit check" diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts index 31c01d9..1a97c3b 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -128,7 +128,6 @@ const verifiedSeedIconIds = new Set([ "mdi:pulse", "mdi:robot-outline", "mdi:router-network", - "mdi:text-box-search", "mdi:thermometer", "mdi:web", "mdi:weather-sunny", diff --git a/apps/web/src/lib/dashboard-seed/dimensionlab.ts b/apps/web/src/lib/dashboard-seed/dimensionlab.ts index 6cc544b..59162e6 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -431,14 +431,6 @@ export const dimensionLabDashboardFixture: DashboardDocument = { href: "https://models.dimensionlab.net", datasource: uptimeMonitor(7), }), - service({ - id: "prompt-registry", - label: "Prompt Registry", - description: "Shared prompts, traces, evals", - icon: "mdi:text-box-search", - href: "https://prompts.dimensionlab.net", - datasource: uptimeMonitor(20), - }), ]), group("systems", "Systems", [ service({ diff --git a/apps/web/src/lib/server/dashboard.test.ts b/apps/web/src/lib/server/dashboard.test.ts index 7851ebb..5d93422 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -66,9 +66,6 @@ describe("dashboard runtime loader", () => { expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain( "auto-refresh", ); - expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain( - "prompt-registry", - ); expect(store.listRevisions()).toHaveLength(2); expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed"); }); @@ -138,13 +135,5 @@ function olderDimensionLabSeed() { ...strip, items: strip.items.filter((item) => item.id !== "auto-refresh"), })); - document.serviceGroups = document.serviceGroups.map((group) => - group.id === "ai-automation" - ? { - ...group, - services: group.services.filter((service) => service.id !== "prompt-registry"), - } - : group, - ); return document; } diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index c1ccaca..58cf203 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -1,4 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -42,6 +44,8 @@ describe("workspace boundaries", () => { "turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e", ); expect(webPackage.scripts).not.toHaveProperty("test:qa"); + expect(webPackage.scripts?.test).toBe("bun --bun vitest run"); + expect(webPackage.scripts?.["test:unit"]).toBe("bun --bun vitest run"); expect(turboConfig.tasks).not.toHaveProperty("test:qa"); expect(turboConfig.globalDependencies).toEqual( expect.arrayContaining(["bun.lock", "tsconfig.base.json"]), @@ -253,6 +257,186 @@ describe("workspace boundaries", () => { "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build", ); }); + + test("defines Forgejo CI and main-branch deploy automation", () => { + const workflow = readFileSync( + join(root, ".forgejo/workflows/dimensionlab-website.yml"), + "utf8", + ); + + expect(workflow).toContain("name: Dimension Lab website"); + expect(workflow).toContain("pull_request:"); + expect(workflow).toContain("push:"); + expect(workflow).toContain("branches:"); + expect(workflow).toContain("- main"); + expect(workflow).toContain("runs-on: docker"); + expect(workflow).toContain("bun install --frozen-lockfile"); + expect(workflow).toContain("bun run check"); + expect(workflow).toContain("bun run test"); + expect(workflow).toContain("bun run build"); + expect(workflow).toContain("needs: ci"); + expect(workflow).toContain("runs-on: deploy"); + expect(workflow).toContain("github.event_name == 'push'"); + expect(workflow).toContain("github.ref == 'refs/heads/main'"); + expect(workflow).toContain( + "git remote add origin git@git.dimensionlab.net:vince/dimensionlab-website.git", + ); + expect(workflow).toContain('git fetch --force --prune --depth=1 origin "$GITHUB_SHA"'); + expect(workflow).toContain("podman inspect dimensionlab-website"); + expect(workflow).toContain("DEPLOY_CONTAINER_CLI: podman"); + expect(workflow).toContain("PODMAN_SYSTEMD_UNIT"); + expect(workflow).toContain("scripts/deploy-dimensionlab-website.sh"); + }); + + test("keeps production deployment behind a guarded script", () => { + const deployScript = readFileSync( + join(root, "scripts/deploy-dimensionlab-website.sh"), + "utf8", + ); + + expect(deployScript).toContain("refs/heads/main"); + expect(deployScript).toContain("dimensionlab-website.service"); + expect(deployScript).toContain("localhost/dimensionlab-website"); + expect(deployScript).toContain("apps/web/Containerfile"); + expect(deployScript).toContain("rollback-"); + expect(deployScript).toContain("https://dimensionlab.net"); + expect(deployScript).toContain("/api/dashboard/tiles"); + expect(deployScript).toContain("--dry-run"); + expect(deployScript).toContain("DEPLOY_RESTART_STRATEGY"); + }); + + test("rolls back the latest image when production smoke checks fail", () => { + const result = runDeployScriptWithFakes( + { + curl: failingCurlCommand, + git: fakeGitCommand, + podman: fakePodmanCommand("dimensionlab-website.service"), + }, + { + DEPLOY_CONTAINER_CLI: "podman", + DEPLOY_EVENT_NAME: "push", + DEPLOY_REF: "refs/heads/main", + DEPLOY_RESTART_STRATEGY: "quadlet-container", + DEPLOY_SHA: "1234567890abcdef", + DEPLOY_SMOKE_TIMEOUT_SECONDS: "0", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("smoke checks failed"); + expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); + expect(result.log).toMatch( + /tag localhost\/dimensionlab-website:latest localhost\/dimensionlab-website:rollback-\d{14}/, + ); + expect(result.log).toMatch( + /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, + ); + expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); + }); + + test("rolls back the latest image when the container fails to restart", () => { + const result = runDeployScriptWithFakes( + { + curl: passingCurlCommand, + git: fakeGitCommand, + podman: fakePodmanCommand("dimensionlab-website.service"), + }, + { + DEPLOY_CONTAINER_CLI: "podman", + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS: "1", + DEPLOY_TEST_CONTAINER_IMAGE: "wrong", + DEPLOY_EVENT_NAME: "push", + DEPLOY_REF: "refs/heads/main", + DEPLOY_RESTART_STRATEGY: "quadlet-container", + DEPLOY_SHA: "1234567890abcdef", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("did not restart on localhost/dimensionlab-website:latest"); + expect(result.stderr).toContain("rolling back to localhost/dimensionlab-website:rollback-"); + expect(result.stderr).toContain("rollback image is running"); + expect(result.log).toMatch( + /tag localhost\/dimensionlab-website:rollback-\d{14} localhost\/dimensionlab-website:latest/, + ); + expect(result.log.match(/^stop dimensionlab-website$/gm)).toHaveLength(2); + }); + + test.each([ + { + env: { DEPLOY_EVENT_NAME: "pull_request", DEPLOY_REF: "refs/heads/main" }, + message: "production deploys only run for push", + }, + { + env: { DEPLOY_EVENT_NAME: "push", DEPLOY_REF: "refs/heads/codex/test" }, + message: "expected refs/heads/main", + }, + ])("refuses guarded deploy contexts before host mutations", ({ env, message }) => { + const result = runDeployScriptWithFakes( + { + curl: passingCurlCommand, + git: fakeGitCommand, + podman: fakePodmanCommand("dimensionlab-website.service"), + }, + { + DEPLOY_CONTAINER_CLI: "podman", + DEPLOY_SHA: "1234567890abcdef", + ...env, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(message); + expect(result.log).toBe(""); + }); + + test("refuses stop-based deploys unless the container belongs to the expected unit", () => { + const result = runDeployScriptWithFakes( + { + curl: passingCurlCommand, + git: fakeGitCommand, + podman: fakePodmanCommand("other.service"), + }, + { + DEPLOY_CONTAINER_CLI: "podman", + DEPLOY_EVENT_NAME: "push", + DEPLOY_REF: "refs/heads/main", + DEPLOY_RESTART_STRATEGY: "quadlet-container", + DEPLOY_SHA: "1234567890abcdef", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", + ); + expect(result.log).not.toContain("build "); + expect(result.log).not.toContain("stop dimensionlab-website"); + }); + + test("checks the expected unit before auto falls back to stopping the container", () => { + const result = runDeployScriptWithFakes( + { + curl: passingCurlCommand, + git: fakeGitCommand, + podman: fakePodmanCommand("other.service"), + systemctl: fakeSystemctlCommand({ active: false, show: true }), + }, + { + DEPLOY_CONTAINER_CLI: "podman", + DEPLOY_EVENT_NAME: "push", + DEPLOY_REF: "refs/heads/main", + DEPLOY_RESTART_STRATEGY: "auto", + DEPLOY_SHA: "1234567890abcdef", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "refusing to stop dimensionlab-website; expected PODMAN_SYSTEMD_UNIT=dimensionlab-website.service", + ); + expect(result.log).not.toContain("stop dimensionlab-website"); + }); }); type WorkspacePackageExport = @@ -269,3 +453,126 @@ function expectPackageExport( ): void { expect(actual).toMatchObject(expected); } + +function runDeployScriptWithFakes( + commands: Record, + env: Record, +): { log: string; status: number | null; stderr: string; stdout: string } { + const tempDir = mkdtempSync(join(tmpdir(), "dimensionlab-deploy-test-")); + const logPath = join(tempDir, "commands.log"); + + for (const [name, source] of Object.entries(commands)) { + const commandPath = join(tempDir, name); + writeFileSync(commandPath, source); + chmodSync(commandPath, 0o755); + } + + const result = spawnSync("bash", [join(root, "scripts/deploy-dimensionlab-website.sh")], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + ...env, + DEPLOY_TEST_LOG: logPath, + PATH: `${tempDir}:${process.env.PATH ?? ""}`, + }, + }); + + return { + log: existsSync(logPath) ? readFileSync(logPath, "utf8") : "", + status: result.status, + stderr: result.stderr, + stdout: result.stdout, + }; +} + +const fakeGitCommand = `#!/usr/bin/env bash +case "$1" in + branch) + echo main + ;; + rev-parse) + echo 1234567890abcdef + ;; + config|submodule) + exit 0 + ;; +esac +`; + +function fakePodmanCommand(systemdUnit: string): string { + return `#!/usr/bin/env bash +state_file="$DEPLOY_TEST_LOG.state" +[ -f "$state_file" ] || printf 'initial' > "$state_file" +printf '%s\\n' "$*" >> "$DEPLOY_TEST_LOG" +if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then + if [ "$4" = "--format" ]; then + case "$3" in + *:rollback-*) + echo sha256:old + ;; + *) + if [ "$(cat "$state_file")" = "rollback" ]; then + echo sha256:old + else + echo sha256:new + fi + ;; + esac + fi + exit 0 +fi +if [ "$1" = "tag" ] && [ "$2" != "localhost/dimensionlab-website:latest" ]; then + printf 'rollback' > "$state_file" +fi +if [ "$1" = "inspect" ]; then + case "$*" in + *PODMAN_SYSTEMD_UNIT*) + echo ${systemdUnit} + ;; + *State.Running*) + echo true + ;; + *'.Image'*|*'{{.Image}}'*) + if [ "$(cat "$state_file")" = "rollback" ]; then + echo sha256:old + elif [ "\${DEPLOY_TEST_CONTAINER_IMAGE:-new}" = "wrong" ]; then + echo sha256:wrong + else + echo sha256:new + fi + ;; + esac +fi +`; +} + +const failingCurlCommand = `#!/usr/bin/env bash +printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" +exit 22 +`; + +const passingCurlCommand = `#!/usr/bin/env bash +printf 'curl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" +if [ "$*" = *'/api/dashboard/tiles'* ]; then + printf '{"state":"ready","tiles":[]}' +fi +`; + +function fakeSystemctlCommand(options: { active: boolean; show: boolean }): string { + const activeStatus = options.active ? 0 : 3; + const showStatus = options.show ? 0 : 1; + + return `#!/usr/bin/env bash +printf 'systemctl %s\\n' "$*" >> "$DEPLOY_TEST_LOG" +if [ "$1" = "--user" ] && [ "$2" = "is-active" ]; then + exit ${activeStatus} +fi +if [ "$1" = "--user" ] && [ "$2" = "show" ]; then + exit ${showStatus} +fi +if [ "$1" = "--user" ] && [ "$2" = "restart" ]; then + exit 0 +fi +`; +} diff --git a/apps/web/tests/e2e/dashboard.spec.ts b/apps/web/tests/e2e/dashboard.spec.ts index 48eac8b..3eb0944 100644 --- a/apps/web/tests/e2e/dashboard.spec.ts +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -16,7 +16,6 @@ const linkedServiceIds = [ "open-webui", "comfyui", "models", - "prompt-registry", "adminer", "assistant", "suna", @@ -104,7 +103,7 @@ test.describe("dashboard page QA gate", () => { expect(metrics.runtimeBottom).toBeLessThanOrEqual(956); expect(metrics.footerBottom).toBeLessThanOrEqual(956); expect(metrics.telemetryCardCount).toBe(16); - expect(metrics.serviceRowCount).toBe(28); + expect(metrics.serviceRowCount).toBe(27); expect(metrics.footerCellCount).toBe(5); expect(metrics.clippedItems).toEqual([]); }); diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png index c548a8a..a1c0ada 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-desktop-chromium-desktop-linux.png differ diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png index a007213..f5d5163 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-light-desktop-chromium-desktop-linux.png differ diff --git a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png index 0542e73..48293a4 100644 Binary files a/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png and b/apps/web/tests/e2e/dashboard.spec.ts-snapshots/dashboard-mobile-chromium-mobile-linux.png differ diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh new file mode 100755 index 0000000..4fac4cd --- /dev/null +++ b/scripts/deploy-dimensionlab-website.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_NAME="${APP_NAME:-dimensionlab-website}" +SERVICE_NAME="${SERVICE_NAME:-dimensionlab-website.service}" +CONTAINER_NAME="${CONTAINER_NAME:-dimensionlab-website}" +IMAGE_REPO="${IMAGE_REPO:-localhost/dimensionlab-website}" +CONTAINERFILE="${CONTAINERFILE:-apps/web/Containerfile}" +PUBLIC_URL="${PUBLIC_URL:-https://dimensionlab.net/}" +TILE_BATCH_URL="${TILE_BATCH_URL:-https://dimensionlab.net/api/dashboard/tiles}" +DEPLOY_RESTART_STRATEGY="${DEPLOY_RESTART_STRATEGY:-auto}" +DEPLOY_SMOKE_TIMEOUT_SECONDS="${DEPLOY_SMOKE_TIMEOUT_SECONDS:-120}" +DEPLOY_CONTAINER_START_TIMEOUT_SECONDS="${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS:-90}" + +dry_run=false +rollback_tag="" +release_tag="" +latest_tag="${IMAGE_REPO}:latest" +container_cli="" +deployment_started=false +rollback_done=false +rollback_in_progress=false + +usage() { + cat <&2 + if [ "${deployment_started:-false}" = "true" ] && [ "${rollback_in_progress:-false}" != "true" ]; then + rollback || true + fi + exit 1 +} + +run() { + if "$dry_run"; then + printf '[deploy:%s] DRY-RUN:' "$APP_NAME" + printf ' %q' "$@" + printf '\n' + return 0 + fi + + "$@" +} + +for arg in "$@"; do + case "$arg" in + --dry-run) + dry_run=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown argument: $arg" + ;; + esac +done + +deployment_ref() { + printf '%s' "${DEPLOY_REF:-${GITHUB_REF:-${FORGEJO_REF:-}}}" +} + +deployment_event() { + printf '%s' "${DEPLOY_EVENT_NAME:-${GITHUB_EVENT_NAME:-${FORGEJO_EVENT_NAME:-}}}" +} + +require_main_push() { + local event + local ref + + event="$(deployment_event)" + ref="$(deployment_ref)" + + if [ -n "$event" ] && [ "$event" != "push" ]; then + fail "refusing to deploy for event '$event'; production deploys only run for push" + fi + + if [ -n "$ref" ]; then + [ "$ref" = "refs/heads/main" ] || fail "refusing to deploy ref '$ref'; expected refs/heads/main" + return 0 + fi + + local branch + branch="$(git branch --show-current 2>/dev/null || true)" + [ "$branch" = "main" ] || fail "refusing to deploy branch '$branch'; expected main" +} + +select_container_cli() { + if [ -n "${DEPLOY_CONTAINER_CLI:-}" ]; then + command -v "$DEPLOY_CONTAINER_CLI" >/dev/null 2>&1 || fail "container CLI not found: $DEPLOY_CONTAINER_CLI" + container_cli="$DEPLOY_CONTAINER_CLI" + return 0 + fi + + if command -v podman >/dev/null 2>&1; then + container_cli="podman" + return 0 + fi + + if command -v docker >/dev/null 2>&1; then + container_cli="docker" + return 0 + fi + + fail "podman or docker is required" +} + +current_sha() { + if [ -n "${DEPLOY_SHA:-${GITHUB_SHA:-}}" ]; then + printf '%s' "${DEPLOY_SHA:-${GITHUB_SHA:-}}" + return 0 + fi + + git rev-parse HEAD +} + +tag_existing_latest_for_rollback() { + rollback_tag="${IMAGE_REPO}:rollback-$(date -u +%Y%m%d%H%M%S)" + if "$container_cli" image inspect "$latest_tag" >/dev/null 2>&1; then + log "tagging current latest image as $rollback_tag" + run "$container_cli" tag "$latest_tag" "$rollback_tag" + else + log "no existing $latest_tag image found; rollback image tag will not be created" + rollback_tag="" + fi +} + +container_systemd_unit() { + "$container_cli" inspect "$CONTAINER_NAME" \ + --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}' 2>/dev/null || true +} + +require_container_managed_by_service() { + local unit + + if "$dry_run"; then + log "DRY-RUN: would require $CONTAINER_NAME to be managed by $SERVICE_NAME" + return 0 + fi + + unit="$(container_systemd_unit)" + [ "$unit" = "$SERVICE_NAME" ] || fail "refusing to stop $CONTAINER_NAME; expected PODMAN_SYSTEMD_UNIT=$SERVICE_NAME, got '${unit:-unset}'" +} + +validate_restart_strategy() { + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + if ! "$dry_run" && ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + fail "systemctl --user cannot access $SERVICE_NAME" + fi + ;; + quadlet-container|kill-container) + require_container_managed_by_service + ;; + auto) + if "$dry_run"; then + log "DRY-RUN: would validate automatic restart strategy" + elif ! command -v systemctl >/dev/null 2>&1 || ! systemctl --user show "$SERVICE_NAME" >/dev/null 2>&1; then + require_container_managed_by_service + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +initialize_submodules() { + log "initializing submodules" + run git config --global url."https://git.dimensionlab.net/".insteadOf "ssh://git@git.dimensionlab.net/" + run git submodule update --init --recursive +} + +build_image() { + local sha + local short_sha + + sha="$(current_sha)" + short_sha="${sha:0:12}" + release_tag="${IMAGE_REPO}:${short_sha}" + + [ -f "$CONTAINERFILE" ] || fail "containerfile not found: $CONTAINERFILE" + + log "building $release_tag and $latest_tag from $CONTAINERFILE" + run "$container_cli" build -f "$CONTAINERFILE" -t "$release_tag" -t "$latest_tag" . +} + +restart_service() { + log "restarting $SERVICE_NAME with strategy $DEPLOY_RESTART_STRATEGY" + + case "$DEPLOY_RESTART_STRATEGY" in + systemctl) + run systemctl --user restart "$SERVICE_NAME" + ;; + quadlet-container|kill-container) + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + ;; + auto) + if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active "$SERVICE_NAME" >/dev/null 2>&1; then + run systemctl --user restart "$SERVICE_NAME" + else + require_container_managed_by_service + run "$container_cli" stop "$CONTAINER_NAME" + fi + ;; + *) + fail "unknown DEPLOY_RESTART_STRATEGY: $DEPLOY_RESTART_STRATEGY" + ;; + esac +} + +latest_image_id() { + "$container_cli" image inspect "$latest_tag" --format '{{.Id}}' 2>/dev/null || true +} + +container_image_id() { + "$container_cli" inspect "$CONTAINER_NAME" --format '{{.Image}}' 2>/dev/null || true +} + +container_running() { + local running + running="$("$container_cli" inspect "$CONTAINER_NAME" --format '{{.State.Running}}' 2>/dev/null || true)" + [ "$running" = "true" ] +} + +wait_for_container_restart() { + local expected_image + + if "$dry_run"; then + log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" + return 0 + fi + + expected_image="$(latest_image_id)" + [ -n "$expected_image" ] || fail "could not resolve image id for $latest_tag" + + wait_for_container_image "$expected_image" "new image" || fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" +} + +wait_for_container_image() { + local expected_image="$1" + local label="$2" + local deadline + + deadline=$((SECONDS + DEPLOY_CONTAINER_START_TIMEOUT_SECONDS)) + + while [ "$SECONDS" -lt "$deadline" ]; do + if container_running && [ "$(container_image_id)" = "$expected_image" ]; then + log "$CONTAINER_NAME is running the $label" + return 0 + fi + + sleep 2 + done + + return 1 +} + +smoke_get() { + local url="$1" + curl -fsS --max-time 10 -o /dev/null "$url" +} + +smoke_tiles() { + local response + + response="$( + curl -fsS --max-time 20 \ + -H "content-type: application/json" \ + --data '{"tiles":[{"kind":"status","stripId":"footer-status","id":"system-status"}]}' \ + "$TILE_BATCH_URL" + )" + + [[ "$response" == *'"state":"ready"'* ]] +} + +wait_for_smoke() { + local deadline + + if "$dry_run"; then + log "DRY-RUN: would smoke check $PUBLIC_URL and $TILE_BATCH_URL" + return 0 + fi + + deadline=$((SECONDS + DEPLOY_SMOKE_TIMEOUT_SECONDS)) + until smoke_get "$PUBLIC_URL" && smoke_tiles; do + if [ "$SECONDS" -ge "$deadline" ]; then + fail "smoke checks failed for $PUBLIC_URL and $TILE_BATCH_URL" + fi + + sleep 3 + done + + log "smoke checks passed" +} + +rollback() { + local rollback_image + + if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then + return 0 + fi + + rollback_done=true + rollback_in_progress=true + printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 + rollback_image="$("$container_cli" image inspect "$rollback_tag" --format '{{.Id}}' 2>/dev/null || true)" + "$container_cli" tag "$rollback_tag" "$latest_tag" || true + if container_running; then + restart_service || true + else + printf '[deploy:%s] waiting for %s to recover with rollback image\n' "$APP_NAME" "$SERVICE_NAME" >&2 + fi + if [ -n "$rollback_image" ] && wait_for_container_image "$rollback_image" "rollback image"; then + printf '[deploy:%s] rollback image is running\n' "$APP_NAME" >&2 + else + printf '[deploy:%s] ERROR: rollback image did not become healthy\n' "$APP_NAME" >&2 + fi + rollback_in_progress=false +} + +on_error() { + local status=$? + rollback + exit "$status" +} + +trap on_error ERR + +require_main_push +select_container_cli +validate_restart_strategy + +log "using container CLI: $container_cli" +log "target image: $latest_tag" +log "target service: $SERVICE_NAME" + +initialize_submodules +tag_existing_latest_for_rollback +build_image +deployment_started=true +restart_service +wait_for_container_restart +wait_for_smoke + +log "deployment finished"