From d318b751206886f92ec91ab8a2a0a8eeaf532241 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:54:09 +0100 Subject: [PATCH] ci(image): build the bot as an arm64 container image and publish it to ghcr (Refs #238) --- .dockerignore | 42 +++++++++++++ .github/workflows/image.yml | 119 ++++++++++++++++++++++++++++++++++++ Dockerfile | 84 +++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/image.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c80640e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Keep secrets, local state and VCS metadata out of the build context. +.env +.env.* +**/.env +**/.env.* +.do-secrets +.git +.gitignore +.worktrees +.claude +.cursor +.serena +.ruff_cache +.vscode +.idea + +node_modules +**/node_modules +dist +**/dist +out +coverage +*.tsbuildinfo + +data +logs +debug-images +temp-images +*.db +*.db-shm +*.db-wal +*.log + +# Not needed at runtime +docs +test +tests +ci +.github +*.md +Dockerfile +.dockerignore diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..30a8076 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,119 @@ +# Builds the bot as a linux/arm64 container image on GitHub's native ARM runner (the target is +# the Raspberry Pi cluster) and publishes it to ghcr.io. Independent of deploy.yml, which keeps +# deploying the pm2 checkout on the odroid: this workflow never touches a running bot. +# +# pull_request build + smoke test only, nothing is pushed +# push (any branch) build + smoke test, then push : (a push event on this +# repository carries a write token; forks only ever get pull_request) +# push to main additionally pushes :latest +# workflow_dispatch same as push, for the branch it is run on +name: Container image + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: image-${{ github.ref }} + # Never cancel a main build midway: a newer push queues behind it instead. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/expensesyncbot + +jobs: + image: + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # The bank plugins live in the ZenPlugins submodule; without it the image has no banks. + submodules: recursive + persist-credentials: false + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + with: + driver: docker + - name: Decide whether this run publishes + id: publish + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + echo "push=false" >> "$GITHUB_OUTPUT" + else + echo "push=true" >> "$GITHUB_OUTPUT" + fi + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: steps.publish.outputs.push == 'true' + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Built into the local daemon first (load) so the smoke test runs on the exact bits that + # would be published; a broken image never reaches the registry. + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/arm64 + load: true + push: false + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + - name: Smoke test the image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + run: | + set -euo pipefail + # Every check must pass: sh -c with `set -e` stops at the first missing piece. + docker run --rm --entrypoint sh "$IMAGE" -ec ' + command -v flock + bun --version + test "$(id -u)" = 10001 + test -d /app/data && test -w /app/data + test -d /app/miniapp/dist && test -f /app/miniapp/dist/index.html + test -f /app/public/index.html + test -f /app/index.ts && test -f /app/bank-sync.ts + test -f /app/src/services/bank/ZenPlugins/package.json + test -d /app/src/services/bank/ZenPlugins/node_modules + test -d /app/src/services/bank/ZenPlugins/src/plugins + test -d /ms-playwright + if find /app -maxdepth 1 -name ".env*" | grep -q .; then echo "env file in image"; exit 1; fi + cd /app + # Native modules and the browser really load on this architecture. + bun -e " + (async () => { + const sharp = require(\"sharp\"); + await sharp({ create: { width: 2, height: 2, channels: 3, background: \"#fff\" } }).png().toBuffer(); + const { chromium } = require(\"playwright\"); + const browser = await chromium.launch({ headless: true, args: [\"--no-sandbox\"] }); + await browser.close(); + console.log(\"sharp + chromium ok\"); + })(); + " + ' + - name: Push image + if: steps.publish.outputs.push == 'true' + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + SHA: ${{ github.sha }} + ON_MAIN: ${{ github.ref == 'refs/heads/main' }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + docker push "$IMAGE:$SHA" + # A re-run keeps its original sha and ref: only the current tip of main may move :latest. + if [ "$ON_MAIN" = "true" ] && [ "$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)" = "$SHA" ]; then + docker tag "$IMAGE:$SHA" "$IMAGE:latest" + docker push "$IMAGE:latest" + fi + docker image inspect "$IMAGE:$SHA" --format '{{index .RepoDigests 0}}' || true + echo "Pushed $IMAGE:$SHA" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..74f961f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# ExpenseSyncBot container image (linux/arm64 on the Raspberry Pi cluster; builds on amd64 too). +# +# One image, two processes (the Pi runs them as separate workloads): +# main bot : bun run index.ts (default CMD; Telegram long polling + HTTP API on :3311) +# bank sync : bun bank-sync.ts (outbound only; shares the SQLite file with the main bot) +# +# No secrets are baked in: configuration comes from environment variables at runtime and +# .dockerignore keeps every .env* file out of the build context. +ARG BUN_VERSION=1.2.17 + +# Stage 1: bun binary, shared by every later stage. +FROM debian:bookworm-slim AS bun +ARG BUN_VERSION +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl unzip ca-certificates && \ + curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash -s "bun-v${BUN_VERSION}" && \ + rm -rf /var/lib/apt/lists/* + +# Stage 2: locked dependencies of the bot and of the ZenPlugins bank plugins. +# git is needed: ZenPlugins depends on packages fetched straight from git hosts. +FROM bun AS deps +WORKDIR /app +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* +COPY package.json bun.lock ./ +# --ignore-scripts: skips the repo's git-hook postinstall. sharp ships prebuilt +# linux-arm64 binaries as optional packages, so it needs no install script. +RUN bun install --frozen-lockfile --ignore-scripts +# yarn.lock is honoured by bun, like the pm2 deploy's identical install. An empty submodule +# (checkout without --recurse-submodules) fails here instead of shipping a bot with no banks. +COPY src/services/bank/ZenPlugins/package.json src/services/bank/ZenPlugins/yarn.lock ./src/services/bank/ZenPlugins/ +RUN bun install --cwd src/services/bank/ZenPlugins + +# Stage 3: Mini App (Vite) static build -> miniapp/dist. +FROM bun AS miniapp +WORKDIR /app/miniapp +COPY miniapp/package.json miniapp/bun.lock ./ +RUN bun install --frozen-lockfile +COPY miniapp ./ +RUN bun run build + +# Stage 4: runtime. +FROM debian:bookworm-slim AS runner +WORKDIR /app +# util-linux provides flock (the single-writer fence wrapper used by the cluster manifests); +# tzdata keeps TZ=UTC explicit. +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates tzdata util-linux && \ + rm -rf /var/lib/apt/lists/* +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun + +COPY --from=deps /app/node_modules ./node_modules + +# Headless Chromium for the table renderer and receipt fetcher (playwright launches it with +# headless: true, which uses the headless shell). Installed with its apt deps before the app +# source is copied so source edits do not invalidate this large layer. The cli runs under bun. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN apt-get update && \ + bun node_modules/playwright/cli.js install --with-deps chromium-headless-shell && \ + rm -rf /var/lib/apt/lists/* + +COPY package.json tsconfig.json bunfig.toml index.ts bank-sync.ts ./ +COPY libs ./libs +COPY src ./src +COPY --from=deps /app/src/services/bank/ZenPlugins/node_modules ./src/services/bank/ZenPlugins/node_modules +COPY public ./public +COPY --from=miniapp /app/miniapp/dist ./miniapp/dist + +# Fixed uid/gid so a volume's ownership can be matched with fsGroup / chown on the cluster. +# data/ holds the SQLite database and receipt photos; logs/ the optional AI debug transcripts. +RUN groupadd --system --gid 10001 bot && \ + useradd --system --uid 10001 --gid bot --home-dir /app --shell /usr/sbin/nologin bot && \ + mkdir -p data logs && \ + chown bot:bot data logs + +# Same environment the pm2 setup gave the bot: node-cron and date math use process-local time. +ENV NODE_ENV=production \ + TZ=UTC \ + OAUTH_SERVER_PORT=3311 +USER bot +VOLUME ["/app/data"] +EXPOSE 3311 +CMD ["bun", "run", "index.ts"]