Skip to content

cicd(release): mail-worker v2026.7.10 #12

cicd(release): mail-worker v2026.7.10

cicd(release): mail-worker v2026.7.10 #12

name: Deploy Mail-worker
on:
push:
tags:
- "mail-worker-v*"
workflow_dispatch:
concurrency:
group: deploy-mail-worker
cancel-in-progress: false
# Least privilege: deploy authenticates to Unikraft Cloud via secrets, not the
# GitHub token. It only needs to read the repo.
permissions:
contents: read
env:
KRAFTCLOUD_METRO: fra
# Pinned new Unikraft CLI (`unikraft`, from unikraft-cloud/cli) version.
# Bump deliberately after verifying a new release against a dry-run deploy.
UNIKRAFT_CLI_VERSION: "0.4.1"
# Image namespace on Unikraft Cloud is the organization slug; published
# images and running instances live under `<org>/<name>`.
KRAFTCLOUD_ORG: gaaaa
jobs:
deploy:
name: Deploy to Unikraft Cloud
runs-on: ubuntu-latest
# The image now builds once in its own step (below); an uncached build is
# the long pole, so keep a generous ceiling until build caching lands.
timeout-minutes: 30
environment: production
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Prepare files for Unikraft Cloud
run: |
cp apps/mail-worker/Kraftfile Kraftfile
cp apps/mail-worker/Dockerfile Dockerfile
# Installs the `unikraft` CLI and logs it in headlessly. The new CLI keeps
# its own auth store (a legacy `kraft`/UKC_TOKEN login does not carry
# over), so every deploy authenticates here via the org token.
- name: Install and log in to Unikraft Cloud
uses: unikraft/setup-action@29c9cce5bc7e894f8929c845d4fba518ef8f1015 # v1
with:
version: ${{ env.UNIKRAFT_CLI_VERSION }}
token: ${{ secrets.KRAFTCLOUD_TOKEN }}
organization: ${{ env.KRAFTCLOUD_ORG }}
- name: Extract version from tag
id: meta
run: |
if [[ "$GITHUB_REF_NAME" == mail-worker-v* ]]; then
echo "version=${GITHUB_REF_NAME#mail-worker-v}" >> $GITHUB_OUTPUT
else
echo "version=$(node -p "require('./apps/mail-worker/package.json').version")" >> $GITHUB_OUTPUT
fi
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
# Build and publish the image ONCE. The new CLI separates packaging from
# running, so the deploy step below only re-runs the fast `unikraft run`
# on retry — never a fresh ~9-min image build (the old kraftkit path
# rebuilt on every retry and ran out of each attempt's window). The build
# (~9-min compile + ~300 MiB push) has no tight deadline of its own; the
# job's timeout-minutes is its single ceiling, so a slow uncached build
# isn't killed by a second, shorter clock.
- name: Build and publish image
run: |
unikraft build . \
--output ${{ env.KRAFTCLOUD_ORG }}/batuda-mail-worker:${{ steps.meta.outputs.version }}
- name: Pre-flight cmdline size guard
run: |
# Unikraft Cloud bakes every -e var into the unikernel kernel command
# line, which Unikraft caps at 4096 bytes / 60 args: past the byte
# cap the boot aborts with -E2BIG, past the arg cap argv is silently
# truncated and vars vanish at runtime. Assemble the exact -e payload
# this deploy will send and fail well under both ceilings, so an
# oversized release is caught here instead of at boot. Non-secret
# config ships in the baked file and is not counted. Secret values
# stay inside the array and are never printed.
E_ARGS=(
-e NODE_ENV=production
-e SERVICE_VERSION="${{ steps.meta.outputs.version }}"
-e GIT_SHA="${{ github.sha }}"
-e REGION="${{ env.KRAFTCLOUD_METRO }}"
-e OTEL_EXPORTER_OTLP_HEADERS="${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }},x-honeycomb-dataset=batuda-mail-worker"
-e DATABASE_URL="${{ secrets.DATABASE_URL }}"
-e EMAIL_CREDENTIAL_KEY="${{ secrets.EMAIL_CREDENTIAL_KEY }}"
-e STORAGE_ACCESS_KEY_ID="${{ secrets.STORAGE_ACCESS_KEY_ID }}"
-e STORAGE_SECRET_ACCESS_KEY="${{ secrets.STORAGE_SECRET_ACCESS_KEY }}"
)
BYTES=$(printf '%s' "${E_ARGS[*]}" | wc -c | tr -d ' ')
FLAGS=0
for tok in "${E_ARGS[@]}"; do
if [ "$tok" = "-e" ]; then
FLAGS=$((FLAGS + 1))
fi
done
echo "Assembled -e payload: ${BYTES} bytes across ${FLAGS} -e flags (limits: 3500 bytes / 50 flags)"
if [ "$BYTES" -gt 3500 ] || [ "$FLAGS" -gt 50 ]; then
echo "::error::cmdline -e payload is ${BYTES} bytes / ${FLAGS} flags, over the pre-flight limit of 3500 bytes / 50 flags; move non-secret config into apps/mail-worker/config.production.json"
exit 1
fi
- name: Deploy to Unikraft Cloud
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 3
retry_wait_seconds: 30
timeout_minutes: 10
# Background worker — no public HTTP endpoint, so no service group.
# Use a stable instance name (`batuda-mail-worker`, mirroring the
# batuda-server / batuda-web naming) so ops commands target a
# predictable identity. The IMAP IDLE connections drop on any
# restart and the worker's claim-and-reconnect loop re-establishes
# them, so a ~5s gap between delete + recreate is acceptable.
# Each retry first deletes any leftover instance so the fixed name
# is free before `run` recreates it (a half-created instance from
# a failed attempt would otherwise collide on the name).
# -m 2048M mirrors server's footprint; the deploy tree (aws-sdk +
# imapflow + mailparser + pg + effect) is comparable. 1024 ran
# out of initrd RAM extracting the CPIO.
# --restart on-failure: if the worker crashes (non-zero exit), the
# platform starts a fresh one instead of leaving it dead but still
# listed as "running" and processing no mail. A clean shutdown
# (exit 0) is left alone. Pairs with the crash-guards in main.ts.
# --timeout 5m gives the deploy up to 5 minutes to wait for the
# freshly-pushed ~300 MiB image to become available before the
# instance is created — the Unikraft team's guidance for the
# propagation delay. The new CLI's --timeout is a whole-command
# deadline that takes a real duration; the old kraftkit API
# capped per-RPC timeouts at 10s (HTTP 400 above 10000ms).
# Scale-to-zero stays off via the Kraftfile label (baked into the
# image), keeping the IDLE-holding worker always-warm.
command: |
# instances delete/get/logs take a bare name and reject --metro
# (only run/services create accept it); the login profile spans metros.
unikraft instances delete batuda-mail-worker 2>&1 | grep -v "not found" || true
unikraft run \
--timeout 5m \
--metro ${{ env.KRAFTCLOUD_METRO }} \
--name batuda-mail-worker \
--image ${{ env.KRAFTCLOUD_ORG }}/batuda-mail-worker:${{ steps.meta.outputs.version }} \
-m 2048M \
--restart on-failure \
-e NODE_ENV=production \
-e SERVICE_VERSION="${{ steps.meta.outputs.version }}" \
-e GIT_SHA="${{ github.sha }}" \
-e REGION="${{ env.KRAFTCLOUD_METRO }}" \
-e OTEL_EXPORTER_OTLP_HEADERS="${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }},x-honeycomb-dataset=batuda-mail-worker" \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e EMAIL_CREDENTIAL_KEY="${{ secrets.EMAIL_CREDENTIAL_KEY }}" \
-e STORAGE_ACCESS_KEY_ID="${{ secrets.STORAGE_ACCESS_KEY_ID }}" \
-e STORAGE_SECRET_ACCESS_KEY="${{ secrets.STORAGE_SECRET_ACCESS_KEY }}"
- name: Verify deployment
run: |
sleep 5
unikraft instances get batuda-mail-worker
# claimAvailableInboxes runs every 5 s, so an IDLE-established
# log line should appear within ~10 s if any inbox is provisioned.
unikraft instances logs batuda-mail-worker --tail 100 || echo "::warning::No logs yet"