From 96fe4d26c7f9afda0ef80e404bcf8b567e7432c7 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 16:45:40 +0200 Subject: [PATCH 1/6] ci: deploy website from Forgejo Actions --- .forgejo/workflows/dimensionlab-website.yml | 72 +++++ apps/web/src/lib/workspace-boundary.test.ts | 39 +++ scripts/deploy-dimensionlab-website.sh | 291 ++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 .forgejo/workflows/dimensionlab-website.yml create mode 100755 scripts/deploy-dimensionlab-website.sh diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml new file mode 100644 index 0000000..cf9c2fc --- /dev/null +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -0,0 +1,72 @@ +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: 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: Deploy production website + env: + DEPLOY_CONTAINER_CLI: docker + DEPLOY_EVENT_NAME: ${{ github.event_name }} + DEPLOY_REF: ${{ github.ref }} + DEPLOY_RESTART_STRATEGY: kill-container + DEPLOY_SHA: ${{ github.sha }} + run: scripts/deploy-dimensionlab-website.sh diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index c1ccaca..fac4673 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -253,6 +253,45 @@ 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("github.event_name == 'push'"); + expect(workflow).toContain("github.ref == 'refs/heads/main'"); + 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"); + }); }); type WorkspacePackageExport = diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh new file mode 100755 index 0000000..238a72c --- /dev/null +++ b/scripts/deploy-dimensionlab-website.sh @@ -0,0 +1,291 @@ +#!/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 + +usage() { + cat <&2 + 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 +} + +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" + ;; + kill-container) + 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 + 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 + local deadline + + 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" + 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 new image" + return 0 + fi + + sleep 2 + done + + fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" +} + +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"'* ]] || fail "tile batch smoke did not return ready state" +} + +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() { + if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ]; then + return 0 + fi + + printf '[deploy:%s] rolling back to %s\n' "$APP_NAME" "$rollback_tag" >&2 + "$container_cli" tag "$rollback_tag" "$latest_tag" || true + restart_service || true +} + +on_error() { + local status=$? + rollback + exit "$status" +} + +trap on_error ERR + +require_main_push +select_container_cli + +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" From 7e4d13a85c65a9c2205f8b8f7cf19dedbdc4b3e8 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 16:54:18 +0200 Subject: [PATCH 2/6] fix(ci): harden website deploy rollback --- .forgejo/workflows/dimensionlab-website.yml | 2 +- apps/web/src/lib/workspace-boundary.test.ts | 140 +++++++++++++++++++- scripts/deploy-dimensionlab-website.sh | 56 +++++++- 3 files changed, 193 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml index cf9c2fc..f272de5 100644 --- a/.forgejo/workflows/dimensionlab-website.yml +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -67,6 +67,6 @@ jobs: DEPLOY_CONTAINER_CLI: docker DEPLOY_EVENT_NAME: ${{ github.event_name }} DEPLOY_REF: ${{ github.ref }} - DEPLOY_RESTART_STRATEGY: kill-container + DEPLOY_RESTART_STRATEGY: quadlet-container DEPLOY_SHA: ${{ github.sha }} run: scripts/deploy-dimensionlab-website.sh diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index fac4673..efb9e5b 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"; @@ -292,6 +294,59 @@ describe("workspace boundaries", () => { 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("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"); + }); }); type WorkspacePackageExport = @@ -308,3 +363,86 @@ 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 +printf '%s\\n' "$*" >> "$DEPLOY_TEST_LOG" +if [ "$1" = "image" ] && [ "$2" = "inspect" ]; then + if [ "$4" = "--format" ]; then + echo sha256:new + fi + exit 0 +fi +if [ "$1" = "inspect" ]; then + case "$*" in + *PODMAN_SYSTEMD_UNIT*) + echo ${systemdUnit} + ;; + *State.Running*) + echo true + ;; + *'.Image'*|*'{{.Image}}'*) + echo sha256:new + ;; + 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 +`; diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh index 238a72c..05178e0 100755 --- a/scripts/deploy-dimensionlab-website.sh +++ b/scripts/deploy-dimensionlab-website.sh @@ -18,6 +18,8 @@ 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 } @@ -135,6 +140,46 @@ tag_existing_latest_for_rollback() { 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/" @@ -162,7 +207,8 @@ restart_service() { systemctl) run systemctl --user restart "$SERVICE_NAME" ;; - kill-container) + quadlet-container|kill-container) + require_container_managed_by_service run "$container_cli" stop "$CONTAINER_NAME" ;; auto) @@ -232,7 +278,7 @@ smoke_tiles() { "$TILE_BATCH_URL" )" - [[ "$response" == *'"state":"ready"'* ]] || fail "tile batch smoke did not return ready state" + [[ "$response" == *'"state":"ready"'* ]] } wait_for_smoke() { @@ -256,13 +302,16 @@ wait_for_smoke() { } rollback() { - if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ]; then + 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 "$container_cli" tag "$rollback_tag" "$latest_tag" || true restart_service || true + rollback_in_progress=false } on_error() { @@ -275,6 +324,7 @@ trap on_error ERR require_main_push select_container_cli +validate_restart_strategy log "using container CLI: $container_cli" log "target image: $latest_tag" From a010520a942276272f79f6becef46db1542bad98 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 16:58:13 +0200 Subject: [PATCH 3/6] fix(ci): run web unit tests with Bun --- apps/web/package.json | 4 ++-- apps/web/src/lib/workspace-boundary.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index efb9e5b..b70aafe 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -44,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"]), From 006c7e041f39d8c445fa8467b6f6b76dba9594e6 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 16:59:36 +0200 Subject: [PATCH 4/6] fix(ci): guard deploy auto fallback --- apps/web/src/lib/workspace-boundary.test.ts | 96 +++++++++++++++++++++ scripts/deploy-dimensionlab-website.sh | 1 + 2 files changed, 97 insertions(+) diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index b70aafe..77ee6a6 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -326,6 +326,60 @@ describe("workspace boundaries", () => { 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: "0", + 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.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( { @@ -349,6 +403,30 @@ describe("workspace boundaries", () => { 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 = @@ -448,3 +526,21 @@ 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/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh index 05178e0..1101e90 100755 --- a/scripts/deploy-dimensionlab-website.sh +++ b/scripts/deploy-dimensionlab-website.sh @@ -215,6 +215,7 @@ restart_service() { 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 ;; From d61d296ea1ae883aed0aa620b64cf10a36521fa0 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 17:10:08 +0200 Subject: [PATCH 5/6] fix(ci): verify deployment socket and rollback image --- .forgejo/workflows/dimensionlab-website.yml | 6 ++++ apps/web/src/lib/workspace-boundary.test.ts | 32 +++++++++++++++++++-- scripts/deploy-dimensionlab-website.sh | 28 +++++++++++++++--- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml index f272de5..8cac0a1 100644 --- a/.forgejo/workflows/dimensionlab-website.yml +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -62,6 +62,12 @@ jobs: 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: | + timeout 15s docker version + unit="$(timeout 15s docker inspect dimensionlab-website --format '{{ index .Config.Labels "PODMAN_SYSTEMD_UNIT" }}')" + test "$unit" = "dimensionlab-website.service" + - name: Deploy production website env: DEPLOY_CONTAINER_CLI: docker diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index 77ee6a6..50dcac6 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -277,6 +277,8 @@ describe("workspace boundaries", () => { expect(workflow).toContain("needs: ci"); expect(workflow).toContain("github.event_name == 'push'"); expect(workflow).toContain("github.ref == 'refs/heads/main'"); + expect(workflow).toContain("docker inspect dimensionlab-website"); + expect(workflow).toContain("PODMAN_SYSTEMD_UNIT"); expect(workflow).toContain("scripts/deploy-dimensionlab-website.sh"); }); @@ -335,7 +337,8 @@ describe("workspace boundaries", () => { }, { DEPLOY_CONTAINER_CLI: "podman", - DEPLOY_CONTAINER_START_TIMEOUT_SECONDS: "0", + 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", @@ -346,6 +349,7 @@ describe("workspace boundaries", () => { 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/, ); @@ -492,13 +496,29 @@ 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 - echo sha256:new + 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*) @@ -508,7 +528,13 @@ if [ "$1" = "inspect" ]; then echo true ;; *'.Image'*|*'{{.Image}}'*) - echo sha256:new + 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 diff --git a/scripts/deploy-dimensionlab-website.sh b/scripts/deploy-dimensionlab-website.sh index 1101e90..4fac4cd 100755 --- a/scripts/deploy-dimensionlab-website.sh +++ b/scripts/deploy-dimensionlab-website.sh @@ -241,7 +241,6 @@ container_running() { wait_for_container_restart() { local expected_image - local deadline if "$dry_run"; then log "DRY-RUN: would wait for $CONTAINER_NAME to run $latest_tag" @@ -250,18 +249,27 @@ wait_for_container_restart() { 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 new image" + log "$CONTAINER_NAME is running the $label" return 0 fi sleep 2 done - fail "$CONTAINER_NAME did not restart on $latest_tag within ${DEPLOY_CONTAINER_START_TIMEOUT_SECONDS}s" + return 1 } smoke_get() { @@ -303,6 +311,8 @@ wait_for_smoke() { } rollback() { + local rollback_image + if [ "$deployment_started" != "true" ] || [ -z "$rollback_tag" ] || [ "$rollback_done" = "true" ]; then return 0 fi @@ -310,8 +320,18 @@ rollback() { 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 - restart_service || 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 } From 35cb1574b4579c97d877ba0b8b0575e502593470 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 17:12:29 +0200 Subject: [PATCH 6/6] fix(ci): allow deploy job Podman socket access --- .forgejo/workflows/dimensionlab-website.yml | 3 +++ apps/web/src/lib/workspace-boundary.test.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/.forgejo/workflows/dimensionlab-website.yml b/.forgejo/workflows/dimensionlab-website.yml index 8cac0a1..672ab25 100644 --- a/.forgejo/workflows/dimensionlab-website.yml +++ b/.forgejo/workflows/dimensionlab-website.yml @@ -49,6 +49,9 @@ jobs: needs: ci if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-latest + options: --security-opt label=disable timeout-minutes: 30 steps: - name: Checkout diff --git a/apps/web/src/lib/workspace-boundary.test.ts b/apps/web/src/lib/workspace-boundary.test.ts index 50dcac6..49f4cee 100644 --- a/apps/web/src/lib/workspace-boundary.test.ts +++ b/apps/web/src/lib/workspace-boundary.test.ts @@ -275,6 +275,7 @@ describe("workspace boundaries", () => { expect(workflow).toContain("bun run test"); expect(workflow).toContain("bun run build"); expect(workflow).toContain("needs: ci"); + expect(workflow).toContain("options: --security-opt label=disable"); expect(workflow).toContain("github.event_name == 'push'"); expect(workflow).toContain("github.ref == 'refs/heads/main'"); expect(workflow).toContain("docker inspect dimensionlab-website");