Skip to content

docs: expand README badges to show the full tech stack #46

docs: expand README badges to show the full tech stack

docs: expand README badges to show the full tech stack #46

Workflow file for this run

# CI + CD: test → multi-arch build → deploy to Oracle Cloud E1 Micro on push to main.
#
# Flow:
# 1. Run `dune build` and `dune runtest` (matching correctness +
# perf regression guards). Gates the rest of the pipeline.
# 2. Build linux/amd64 + linux/arm64 in parallel on architecture-
# native GHA runners (ubuntu-24.04 + ubuntu-24.04-arm). Each
# arch pushes its image by digest only — no tag clutter.
# 3. Merge the two per-arch digests into a single multi-platform
# manifest list tagged :latest and :sha-<short> on GHCR.
# 4. SSH into the Oracle VM, pull the new image (Docker auto-picks
# the amd64 variant), restart the container.
# 5. Smoke-test the local endpoint inside the VM.
#
# Caddy in front handles WSS termination, so the brief restart shows
# up to a recruiter as a sub-second 502 — acceptable for a demo.
# For zero-downtime, see the comments at the bottom.
#
# Required secrets (Settings → Secrets and variables → Actions):
# SSH_HOST public IP or hostname of the VM
# SSH_USER usually "ubuntu" on Oracle's Ubuntu image
# SSH_PRIVATE_KEY the matching private key for the public key in ~ubuntu/.ssh/authorized_keys
#
# One-time setup after the first successful push:
# GitHub → your repo → Packages → ocaml_lob → Package settings → Change visibility → Public.
# This lets the VM `docker pull` without authenticating to GHCR.
name: Deploy
on:
push:
branches: [main]
workflow_dispatch: # manual trigger from the Actions UI
# Don't run two deploys at the same time — they'd race on the VM.
concurrency:
group: deploy-prod
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
# OCaml-side CI. Catches type errors (`dune build`), matching-
# correctness regressions (`test_engine.ml`), and perf regressions
# (`perf_test.ml`'s bytes/order + throughput + p99 guards) before
# anything ships. ~3-5 min cache-warm; ~6-8 min on first run.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up OCaml
uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: '5.2'
opam-pin: false # skip pinning the package — saves ~30s
# Only install what test/ and bench/ actually need. dream + yojson
# + lwt_ppx are server-only and their dep trees are slow to compile
# on a clean opam switch; the Docker build catches server-side
# type errors anyway.
- name: Install test deps
run: opam install --yes alcotest
- name: Build (lib + tests + bench)
run: opam exec -- dune build lib/ test/ bin/bench.exe
- name: Run tests
run: opam exec -- dune runtest --force
# Per-arch image builds, each on a native runner — no QEMU.
# The previous attempt at multi-arch ran both arches through a
# single x86 runner with QEMU emulation for the arm64 half; the
# OCaml compiler is single-threaded + allocation-heavy, which is
# the worst case for QEMU, and the total build went from ~3 min
# to 30+ min. GHA's native ARM runners (`ubuntu-24.04-arm`)
# eliminate that — both arches build natively in parallel, and
# the merge job composes the manifest list. Wallclock is now
# max(amd64, arm64) instead of amd64 + arm64*emulation_factor.
build:
needs: test
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-24.04
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
# Two small env preps:
# IMAGE_NAME_LC — Docker rejects mixed-case repo names, but
# ${{ github.repository }} preserves owner casing
# ("Builder106/ocaml_limit"). The old workflow leaned on
# [docker/metadata-action] silently lowercasing its
# [images:] input; the push-by-digest path doesn't go
# through metadata-action, so do it here.
# PLATFORM_PAIR — "linux/amd64" → "linux-amd64". Used as the
# GHA cache scope (so amd64 + arm64 builds don't fight over
# cache entries) and the digest-artifact name.
- name: Prep env
run: |
echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
platform: ${{ matrix.platform }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Push by digest, no tag. The merge job assembles the
# final multi-arch manifest from the two digests via
# [docker buildx imagetools create]. This keeps GHCR's
# package page clean (no per-arch staging tags) and means
# blobs are referenced only by the manifest list, so
# nothing GC-eligible sits orphaned between builds.
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
# See merge job below for why these stay off.
provenance: false
sbom: false
# Stash the digest as a per-platform artifact so the merge job
# can pick it up. Each matrix shard writes one file named after
# its platform, then the merge job downloads the whole pattern.
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Combine the two per-arch image digests into a single manifest
# list tagged :latest + :sha-<short>. This is what consumers
# pull; Docker picks the appropriate arch transparently.
merge:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# Same lowercase prep as the build job — Docker rejects
# mixed-case repo names, and we reference the image directly
# in the manifest-create shell pipeline (which doesn't go
# through metadata-action's auto-lowercasing).
- name: Prep env
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
# Combine per-arch digests into a manifest list, then tag it.
# The shell expansion produces something like:
# docker buildx imagetools create \
# -t ghcr.io/.../ocaml_limit:latest \
# -t ghcr.io/.../ocaml_limit:sha-abc1234 \
# ghcr.io/.../ocaml_limit@sha256:<amd64-digest> \
# ghcr.io/.../ocaml_limit@sha256:<arm64-digest>
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@sha256:%s ' *)
- name: Inspect resulting manifest
run: |
docker buildx imagetools inspect \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.meta.outputs.version }}
deploy:
needs: merge
runs-on: ubuntu-latest
steps:
- name: Pull and restart on the VM
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
# Inline script — keep it short. Anything more involved
# belongs in a script file checked into the repo.
script: |
set -euo pipefail
# Docker requires lowercase repository names; ${{ github.repository }}
# preserves owner casing, so lowercase via bash parameter expansion.
REPO="${{ github.repository }}"
IMAGE="${{ env.REGISTRY }}/${REPO,,}:latest"
echo "→ pulling $IMAGE"
docker pull "$IMAGE"
# Pause the host watchdog (see /etc/systemd/system/ocaml-lob-watchdog.timer)
# for the duration of the swap. Otherwise it can fire between
# `docker stop` and `docker rm`, see the new container as down,
# and `docker restart` it back to running — at which point our
# subsequent `docker rm` either fails or leaves a stale container,
# and `docker run --name ocaml_lob` hits a name conflict. We
# restart the timer unconditionally after, even on failure, so
# liveness coverage resumes regardless of how the deploy exited.
trap 'sudo systemctl start ocaml-lob-watchdog.timer 2>/dev/null || true' EXIT
sudo systemctl stop ocaml-lob-watchdog.timer 2>/dev/null || true
echo "→ replacing container"
# `-f` on rm forces removal of a running container — defense in
# depth in case anything (manual or otherwise) restarted it.
docker stop ocaml_lob 2>/dev/null || true
docker rm -f ocaml_lob 2>/dev/null || true
docker run -d \
--name ocaml_lob \
--restart unless-stopped \
-p 127.0.0.1:8080:8080 \
"$IMAGE"
echo "→ smoke test"
for i in 1 2 3 4 5; do
if curl -fsS -m 2 http://127.0.0.1:8080/ > /dev/null; then
echo "✓ deploy healthy (attempt $i)"
break
fi
sleep 1
if [ "$i" = "5" ]; then
echo "✗ deploy unhealthy after 5 attempts"
docker logs --tail 50 ocaml_lob
exit 1
fi
done
echo "→ pruning old images"
docker image prune -af --filter "until=168h" >/dev/null
# Notes for evolving this:
#
# - For zero-downtime: run two containers behind Caddy with separate
# names (ocaml_lob_blue / ocaml_lob_green), update Caddy to point
# at whichever is healthy, then stop the old one. Caddy's load
# balancer can do this with `lb_policy first` and active health
# checks. Overkill for a demo but the path is clear.
#
# - The `test` job intentionally skips installing dream / yojson /
# lwt_ppx — those are server-only and their dep trees take ~10 min
# to compile on a clean opam switch. Server-side type errors are
# still caught by `build-and-push`'s Docker build (which compiles
# bin/server.exe), so we don't lose coverage, just CI runtime.