diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml deleted file mode 100644 index 198a8aa..0000000 --- a/.forgejo/workflows/dimensionlab-website.yml +++ /dev/null @@ -1,85 +0,0 @@ -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 85177c5..00d9708 100644 --- a/README.md +++ b/README.md @@ -161,27 +161,3 @@ 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 015fc55..790663c 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": "bun --bun vitest run", - "test:unit": "bun --bun vitest run", + "test": "vitest run", + "test:unit": "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 1a97c3b..31c01d9 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.test.ts @@ -128,6 +128,7 @@ 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 59162e6..6cc544b 100644 --- a/apps/web/src/lib/dashboard-seed/dimensionlab.ts +++ b/apps/web/src/lib/dashboard-seed/dimensionlab.ts @@ -431,6 +431,14 @@ 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 5d93422..7851ebb 100644 --- a/apps/web/src/lib/server/dashboard.test.ts +++ b/apps/web/src/lib/server/dashboard.test.ts @@ -66,6 +66,9 @@ 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"); }); @@ -135,5 +138,13 @@ 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 58cf203..c1ccaca 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -1,6 +1,4 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -44,8 +42,6 @@ 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"]), @@ -257,186 +253,6 @@ 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 = @@ -453,126 +269,3 @@ 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 3eb0944..48eac8b 100644 --- a/apps/web/tests/e2e/dashboard.spec.ts +++ b/apps/web/tests/e2e/dashboard.spec.ts @@ -16,6 +16,7 @@ const linkedServiceIds = [ "open-webui", "comfyui", "models", + "prompt-registry", "adminer", "assistant", "suna", @@ -103,7 +104,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(27); + expect(metrics.serviceRowCount).toBe(28); 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 a1c0ada..c548a8a 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 f5d5163..a007213 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 48293a4..0542e73 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 deleted file mode 100755 index 4fac4cd..0000000 --- a/scripts/deploy-dimensionlab-website.sh +++ /dev/null @@ -1,362 +0,0 @@ -#!/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"