docs: expand README badges to show the full tech stack #46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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. |