Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions .github/workflows/image.yml
Original file line number Diff line number Diff line change
@@ -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 :<full sha> (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"
84 changes: 84 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading