feat(docker): publish multi-arch server image to GHCR

Replace the docker/Dockerfile-based api+web compose setup with a
single root Dockerfile that runs the fabro server with the embedded
web UI on port 80, persists state under /storage, and drops to a
non-root fabro user with CAP_NET_BIND_SERVICE.

The release workflow stages the prebuilt Linux binaries from the
compile job into a buildx context and publishes multi-arch images
to ghcr.io/fabro-sh/fabro as :<version> (always), :latest (stable
tags only), and :nightly (nightly tags only).

Also address zizmor findings in nightly.yml (pinned
create-github-app-token, persist-credentials: false with explicit
remote URL setup) and release.yml (no-cache on tag-triggered
setup-bun to close the cache-poisoning path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-17 19:39:10 -04:00
parent e891cb3961
commit d52e6829c2
No known key found for this signature in database
12 changed files with 173 additions and 152 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
*
!docker/entrypoint.sh
!docker/settings.toml
!docker-context/**

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: Mint GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
with:
app-id: ${{ vars.FABRO_RELEASES_APP_ID }}
private-key: ${{ secrets.FABRO_RELEASES_APP_PRIVATE_KEY }}
@ -30,7 +30,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # full history + tags for nightly probing
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Skip if HEAD is already a nightly tag
id: skip
@ -43,6 +43,8 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
if: steps.skip.outputs.skip != 'true'
with:
no-cache: true
- name: Install bun deps (for SPA verify)
if: steps.skip.outputs.skip != 'true'
@ -65,4 +67,6 @@ jobs:
run: |
git config user.name "fabro-releases[bot]"
git config user.email "fabro-releases[bot]@users.noreply.github.com"
git remote set-url origin \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
bin/dev/release.sh nightly

View file

@ -22,6 +22,8 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
no-cache: true
- run: bun install
- run: scripts/refresh-fabro-spa.sh
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
@ -101,6 +103,64 @@ jobs:
fi
gh release create "$TAG_NAME" target/distrib/* --generate-notes $PRERELEASE_FLAG
docker:
name: Docker image
needs: compile
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: target/distrib
pattern: artifacts-fabro-*-linux-gnu
merge-multiple: true
- name: Stage binaries for Docker build context
run: |
for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
case "$target" in
x86_64-*) arch=amd64 ;;
aarch64-*) arch=arm64 ;;
esac
mkdir -p "docker-context/$arch"
tar -xzf "target/distrib/fabro-${target}.tar.gz" -C target/distrib
cp "target/distrib/fabro-${target}/fabro" "docker-context/$arch/fabro"
done
- uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.10.0
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/fabro-sh/fabro
tags: |
type=match,pattern=v(.*),group=1
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
type=raw,value=nightly,enable=${{ contains(github.ref_name, '-nightly.') }}
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
update-homebrew:
name: Update Homebrew Formula
needs: release

44
Dockerfile Normal file
View file

@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.9
#
# Runtime image for the Fabro server.
#
# Binaries are supplied pre-built via the release workflow:
# docker-context/amd64/fabro (x86_64-unknown-linux-gnu)
# docker-context/arm64/fabro (aarch64-unknown-linux-gnu)
#
# The image serves the HTTP API (with embedded web UI) on port 80,
# persists state to /storage, and runs as the unprivileged `fabro` user.
FROM debian:trixie-slim
ARG TARGETARCH
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libcap2-bin \
tini \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 fabro \
&& useradd --system --uid 1000 --gid fabro \
--home-dir /var/fabro --shell /usr/sbin/nologin fabro \
&& install -d -o fabro -g fabro -m 0755 /var/fabro /storage \
&& install -d -m 0755 /etc/fabro
COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro
RUN setcap cap_net_bind_service=+ep /usr/local/bin/fabro
COPY docker/settings.toml /etc/fabro/settings.toml
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint
ENV FABRO_HOME=/var/fabro \
FABRO_CONFIG=/etc/fabro/settings.toml
VOLUME ["/storage"]
EXPOSE 80
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/fabro-entrypoint"]
CMD ["fabro", "server", "start", "--foreground", "--bind", "0.0.0.0:80"]

27
bin/dev/docker-build.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build a local fabro Docker image from the current working tree.
#
# The release workflow uses prebuilt release tarballs, but for local development
# we cargo build first and stage the binary where the Dockerfile expects it.
set -euo pipefail
cd "$(dirname "$0")/../.."
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*)
echo "unsupported host arch: $(uname -m)" >&2
exit 1
;;
esac
echo "Building fabro (release, host arch: $arch)..."
cargo build --release -p fabro-cli
mkdir -p "docker-context/$arch"
cp target/release/fabro "docker-context/$arch/fabro"
echo "Building Docker image..."
docker build -t fabro .

View file

@ -1,30 +0,0 @@
FROM rust:1-bookworm@sha256:4ec71e955e6c08aeb238885083222ddff79d82eb87654a96c76e38e94da1a53b AS rust-builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY lib/crates lib/crates
COPY openapi openapi
RUN cargo build --release --bin fabro
FROM oven/bun:1@sha256:c9aa897b6028d54e510c4babd41bcdd481ad4a4b5030ad31135a9cabfe2c24df
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates nodejs npm && \
rm -rf /var/lib/apt/lists/*
RUN npm i -g mintlify@4.2.507 && \
sed -i 's/const version = __VERSION__/const version = "0.0.0"/' \
/usr/local/lib/node_modules/mintlify/node_modules/katex/dist/katex.mjs
WORKDIR /app
COPY --from=rust-builder /app/target/release/fabro /usr/local/bin/fabro
COPY package.json bun.lock ./
COPY apps/fabro-web/package.json apps/fabro-web/
COPY lib/packages/fabro-api-client/package.json lib/packages/fabro-api-client/
RUN bun install --frozen-lockfile
COPY apps/fabro-web apps/fabro-web
COPY lib/packages/fabro-api-client lib/packages/fabro-api-client
COPY docs docs
COPY docker/entrypoint.ts .
ENTRYPOINT ["bun", "run", "entrypoint.ts"]

View file

@ -1,19 +0,0 @@
[api]
base_url = "http://api:3000"
[features]
session_sandboxes = false
[checkpoint]
exclude_globs = [
"**/node_modules/**",
"**/.pnpm-store/**",
"**/.npm/**",
"**/.cache/**",
"**/playwright-report/**",
"**/test-results/**",
"**/.cargo-target*/**",
"**/.cargo_target*/**",
"**/.wasm-pack/**",
"**/.tmpbuild/**",
]

View file

@ -1,35 +1,13 @@
services:
api:
fabro:
image: fabro
build:
context: ..
dockerfile: docker/Dockerfile
platforms:
- linux/arm64
pull_policy: never
command: ["api"]
dockerfile: Dockerfile
ports:
- "3000:3000"
web:
image: fabro
pull_policy: never
command: ["web"]
ports:
- "5173:5173"
environment:
- FABRO_DEMO=1
- "80:80"
volumes:
- ./demo-settings.toml:/root/.fabro/settings.toml:ro
- ../apps/fabro-web/public:/app/apps/fabro-web/public
depends_on:
- api
- fabro-storage:/storage
docs:
image: fabro
pull_policy: never
command: ["docs"]
ports:
- "3333:3333"
volumes:
- ../docs:/app/docs:ro
volumes:
fabro-storage:

12
docker/entrypoint.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/sh
set -eu
# When started as root (the default), ensure the storage volume is writable
# by the unprivileged fabro user, then drop privileges. File capabilities on
# /usr/local/bin/fabro grant CAP_NET_BIND_SERVICE so it can still bind port 80.
if [ "$(id -u)" = 0 ]; then
chown fabro:fabro /storage
exec runuser -u fabro -- "$@"
fi
exec "$@"

View file

@ -1,44 +0,0 @@
const service = process.argv[2];
type ServiceOptions = {
command: string[];
cwd?: string;
};
const services: Record<string, ServiceOptions> = {
api: {
command: ["fabro", "serve", "--host", "0.0.0.0"],
},
web: {
command: ["bun", "run", "dev", "--host", "0.0.0.0"],
cwd: "apps/fabro-web",
},
docs: {
command: ["mintlify", "dev", "--port", "3333", "--host", "0.0.0.0"],
cwd: "docs",
},
};
const validServices = Object.keys(services).join("|");
if (!service) {
console.error(`Usage: docker run fabro <${validServices}>`);
process.exit(1);
}
const config = services[service];
if (!config) {
console.error(
`Unknown service: ${service}. Use one of: ${Object.keys(services).join(", ")}`,
);
process.exit(1);
}
const child = Bun.spawn(config.command, {
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
cwd: config.cwd,
});
process.exit(await child.exited);

2
docker/settings.toml Normal file
View file

@ -0,0 +1,2 @@
[server.storage]
root = "/storage"

View file

@ -2,53 +2,37 @@
Screenshots of the Fabro web UI are embedded in the public docs. This guide covers how to retake them when the UI changes.
> **Known gaps** after the server absorbed the web UI (April 2026):
> - **Demo mode** is now per-request via the `X-Fabro-Demo: 1` header, not a container env var. The browser won't send it by default — you'll need a browser extension (e.g. ModHeader) or a sidecar proxy to inject the header. Needs a follow-up to land a server-level toggle.
> - **HMR trick is gone.** Web assets are baked into the Rust binary (`fabro-spa/assets`), so the `sed` technique for hiding nav items no longer works. Either commit a temporary nav change to a local branch and rebuild, or script visibility changes in the browser devtools.
## Prerequisites
- Docker installed
- Chrome running with DevTools MCP or similar screenshot tool
- The `fabro` Docker image already built (`docker compose -f docker/docker-compose.yaml build`)
- Header-injection tool (browser extension or proxy) to send `X-Fabro-Demo: 1`
- The `fabro` Docker image built locally: `bin/dev/docker-build.sh`
## Boot the demo environment
```bash
docker compose -f docker/docker-compose.yaml up api web -d
docker compose -f docker/docker-compose.yaml up -d
```
Wait ~10 seconds for both services to be ready, then verify:
Wait ~5 seconds for the server to be ready, then verify (sending the demo header):
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/runs
curl -s -H "X-Fabro-Demo: 1" -o /dev/null -w "%{http_code}" http://localhost/runs
# Should return 200
```
The web container runs in demo mode (`FABRO_DEMO=1`), which returns synthetic data from the API.
## Applying temporary UI changes for screenshots
The Docker image bakes in the fabro-web source at build time. To make temporary changes (like hiding nav items), edit files inside the running container using `sed`:
```bash
# Example: hide Start and Settings nav items
docker exec docker-web-1 sed -i '/{.*"Start".*}/d; /{.*"Settings".*}/d' \
/app/apps/fabro-web/app/layouts/app-shell.tsx
```
**Do not use `docker cp`** to replace source files — it breaks Vite's module resolution and causes 500 errors. Use `sed -i` inside the container instead, which preserves the file inode and lets HMR work correctly.
`docker cp` works fine for static assets in `public/` (logos, images), just not for source files that Vite processes.
After `sed` edits, wait a few seconds for HMR to rebuild, then verify pages still return 200 before taking screenshots.
With the SPA baked into the binary, there's no in-container edit path. Commit the nav change on a local branch, rerun `bin/dev/docker-build.sh`, then `docker compose up -d --force-recreate`.
## Updating logos
The logos are static files in `apps/fabro-web/public/`. The Docker compose file mounts this directory as a volume:
```yaml
volumes:
- ../apps/fabro-web/public:/app/apps/fabro-web/public
```
So changes to `apps/fabro-web/public/logotype.svg` (dark theme) and `apps/fabro-web/public/logotype-light.svg` (light theme) are picked up immediately by the running container. The source-of-truth logos are in `docs/logo/dark.svg` and `docs/logo/light.svg`.
Logos live at `apps/fabro-web/public/logotype.svg` (dark) and `apps/fabro-web/public/logotype-light.svg` (light). These are bundled into the SPA at build time — rebuilding the image picks up the changes. The source-of-truth logos are in `docs/logo/dark.svg` and `docs/logo/light.svg`.
## Browser setup
@ -61,7 +45,7 @@ If the nav bar is too crowded at this width, hide low-priority items (Start, Set
## Taking screenshots
Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL:
Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL (served from `http://localhost/` with the `X-Fabro-Demo: 1` header):
| File | URL |
|---|---|
@ -91,7 +75,6 @@ Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL:
### Pages that need extra wait time
- **Run overview** (`/runs/run-1`) — the workflow graph diagram takes 2-3 seconds to render after the page loads. Wait before screenshotting.
- **After `sed` edits** — HMR needs a few seconds to rebuild. The first navigation after an edit may hit a transient error; retry once.
## Where screenshots are used in docs