Merge pull request 'ci: deploy website from Forgejo Actions' (#54)
This commit is contained in:
commit
b087e65978
4 changed files with 748 additions and 3 deletions
81
.forgejo/workflows/dimensionlab-website.yml
Normal file
81
.forgejo/workflows/dimensionlab-website.yml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
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
|
||||||
|
container:
|
||||||
|
image: ghcr.io/catthehacker/ubuntu:act-latest
|
||||||
|
options: --security-opt label=disable
|
||||||
|
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: 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
|
||||||
|
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
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
"build": "rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build",
|
"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",
|
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "bun --bun vitest run",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "bun --bun vitest run",
|
||||||
"test:e2e": "env -u NO_COLOR playwright test",
|
"test:e2e": "env -u NO_COLOR playwright test",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
|
|
|
||||||
|
|
@ -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 { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
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",
|
"turbo run check test:unit build @dimensionlab/ui#build-storybook @dimensionlab/web#test:e2e",
|
||||||
);
|
);
|
||||||
expect(webPackage.scripts).not.toHaveProperty("test:qa");
|
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.tasks).not.toHaveProperty("test:qa");
|
||||||
expect(turboConfig.globalDependencies).toEqual(
|
expect(turboConfig.globalDependencies).toEqual(
|
||||||
expect.arrayContaining(["bun.lock", "tsconfig.base.json"]),
|
expect.arrayContaining(["bun.lock", "tsconfig.base.json"]),
|
||||||
|
|
@ -253,6 +257,181 @@ describe("workspace boundaries", () => {
|
||||||
"rm -rf build && vite build && bun build src/server/index.ts --target bun --outdir build",
|
"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("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");
|
||||||
|
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 =
|
type WorkspacePackageExport =
|
||||||
|
|
@ -269,3 +448,126 @@ function expectPackageExport(
|
||||||
): void {
|
): void {
|
||||||
expect(actual).toMatchObject(expected);
|
expect(actual).toMatchObject(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runDeployScriptWithFakes(
|
||||||
|
commands: Record<string, string>,
|
||||||
|
env: Record<string, string>,
|
||||||
|
): { 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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
|
||||||
362
scripts/deploy-dimensionlab-website.sh
Executable file
362
scripts/deploy-dimensionlab-website.sh
Executable file
|
|
@ -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 <<USAGE
|
||||||
|
Usage: $0 [--dry-run]
|
||||||
|
|
||||||
|
Build and deploy ${APP_NAME} for the production ${SERVICE_NAME} unit.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print the deployment actions without changing the host.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[deploy:%s] %s\n' "$APP_NAME" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf '[deploy:%s] ERROR: %s\n' "$APP_NAME" "$*" >&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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue