Skip to content
Merged
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
158 changes: 158 additions & 0 deletions .github/workflows/docker-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# ===============================================================
# 🐳 Docker Release - build, sign, and publish tagged images
# ===============================================================
# - fires on version tag pushes (vX.Y.Z, matching the repo's manual
# tagging convention — see git tag history)
# - builds the single combined image (Dockerfile) for linux/amd64 +
# linux/arm64, pushes it to GHCR
# - signs the pushed tags with Cosign (keyless OIDC) and attaches an SBOM
# - cuts a GitHub Release for the tag with auto-generated notes, the
# pull command, and the SBOM as a release asset
# ---------------------------------------------------------------

name: Docker Release

on:
push:
# Strict semver only (vX.Y.Z, vX.Y.Z-pre) — matches the type=semver
# patterns below. A looser "v*" would let non-semver tags (v1, v1.2,
# v2.0.0.1) through and produce empty derived tags.
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
nodejs_image:
description: "Override NODEJS_IMAGE build arg (e.g. internal mirror) — use if registry.access.redhat.com is unreachable. Leave blank for the Dockerfile default. Dispatch this run from the tag you want to (re)release."
required: false
type: string

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-sign-release:
name: Build, Sign & Release
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # create the GitHub Release
packages: write # push to GHCR
id-token: write # Cosign keyless (OIDC) signing

steps:
- name: ⬇️ Checkout source
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false

- name: 🔡 Set image name lowercase
run: |
IMAGE_NAME_LC=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME_LC=${IMAGE_NAME_LC}" >> "${GITHUB_ENV}"

- name: 🧰 Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: 🧰 Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: 🔐 Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: 🏷️ Extract image metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}
# Skip the "latest" tag for prerelease refs (e.g. v1.0.0-RC-3) so
# they never clobber the stable tag.
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}

- name: ✅ Validate derived tags
run: |
set -euo pipefail
if [ -z "${{ steps.meta.outputs.tags }}" ]; then
echo "::error::No image tags derived from ref '${{ github.ref_name }}'. Expected strict semver (vX.Y.Z or vX.Y.Z-prerelease)."
exit 1
fi

- name: 🏗️ Build & push image
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
# Lets a maintainer re-dispatch this workflow (from the tag) with
# an internal mirror if registry.access.redhat.com is unreachable
# — see the workflow_dispatch input above.
build-args: |
${{ github.event.inputs.nodejs_image && format('NODEJS_IMAGE={0}', github.event.inputs.nodejs_image) || '' }}

- name: 🔏 Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: 🔏 Sign published tags (keyless OIDC)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}"
DIGEST="${{ steps.build.outputs.digest }}"
echo "Signing ${IMAGE}@${DIGEST}"
cosign sign --yes "${IMAGE}@${DIGEST}"

- name: 📋 Generate SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@${{ steps.build.outputs.digest }}
output-file: sbom.spdx.json
upload-artifact: false

- name: 📦 Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
files: sbom.spdx.json
body: |
## Container image

```
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ github.ref_name }}
```

Signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) (keyless OIDC) — verify with:

```
cosign verify ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@${{ steps.build.outputs.digest }} \
--certificate-identity-regexp 'https://github.com/${{ github.repository }}/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```

Built for `linux/amd64` and `linux/arm64`. SBOM (SPDX) attached below.
See [DOCKER.md](../../blob/main/DOCKER.md#published-image) for compose/Helm usage.
1 change: 1 addition & 0 deletions .husky/post-checkout
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.husky/_/post-checkout
1 change: 1 addition & 0 deletions .husky/post-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.husky/_/post-commit
1 change: 1 addition & 0 deletions .husky/post-merge
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.husky/_/post-merge
50 changes: 43 additions & 7 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ built UI (root, Vite/React SPA) as static files and proxies `/api/*`, so
the whole client stack — UI + BFF — is a single container. Redis is a
separate service, wired in via `docker-compose.yml`.

The image builds on Red Hat UBI9 Node.js 22
(`registry.access.redhat.com/ubi9/nodejs-22`, public/no-auth) rather than
`node:alpine` — enterprise-supportable, glibc-based, non-root by default.

`.env`/`.env.example` are shared with native (non-Docker) dev — see the
root README's Getting Started section. `docker-compose.yml` and
`server`'s native `npm run dev`/`start` both read the same repo-root
Expand All @@ -14,6 +18,37 @@ The upstream ContextForge/mcpgateway API is **not** part of this repo or
this compose file — it's expected to already be running somewhere you
point `CONTEXTFORGE_URL` at.

## Published image

Tagged releases (`vX.Y.Z`, matching the repo's git tags) are built and
pushed to GHCR by `.github/workflows/docker-release.yml`:

```bash
docker pull ghcr.io/contextforge-org/contextforge-web-ui:vX.Y.Z
```

Also available: the `vX.Y` (minor) tag, and `latest` (stable releases
only — prerelease/RC tags don't move it). Built for `linux/amd64` and
`linux/arm64`. See the repo's **Releases** and **Packages** tabs for the
full list of published tags, signatures, and SBOMs.

To point `docker-compose.yml` at the published image instead of building
locally, override the `app` service's `build:` key with `image:`:

```yaml
# docker-compose.override.yml
services:
app:
image: ghcr.io/contextforge-org/contextforge-web-ui:vX.Y.Z
```

```bash
docker compose -f docker-compose.yml -f docker-compose.override.yml up
```

For Helm/Kubernetes, point your chart's `image.repository` /
`image.tag` (or equivalent values) at the same coordinates.

## Quick start

```bash
Expand All @@ -36,11 +71,11 @@ this compose file's own `redis` service (`docker-compose.yml` defaults
Full reference: `.env.example` (each var has an inline comment).
Summary, grouped the same way:

| Group | Vars | Notes |
|---|---|---|
| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. |
| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. |
| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. |
| Group | Vars | Notes |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. |
| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. |
| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. |

The image itself (`Dockerfile`) sets **none** of these — it ships
respecting `config.ts`'s own defaults untouched. All configuration comes
Expand Down Expand Up @@ -137,5 +172,6 @@ docker compose -f docker-compose.yml -f docker-compose.override.yml up --build
- **Multi-arch builds** (e.g. building on Apple Silicon for an amd64
target): `docker buildx build --platform linux/amd64,linux/arm64 -t contextforge-web-ui .`
— the UI stage's native dependency (`lightningcss`, via
`@tailwindcss/vite`) ships prebuilt musl binaries for both architectures,
so no Dockerfile changes should be needed.
`@tailwindcss/vite`) ships prebuilt glibc binaries for both architectures
(the base image is glibc-based UBI, not musl-based Alpine), so no
Dockerfile changes should be needed.
55 changes: 33 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,58 @@
# owns those defaults (and fails closed on insecure combinations by design).
# Supply the right values at `docker run -e` / compose time instead.

# Red Hat UBI9 Node.js 22 — public, unauthenticated registry (no `docker
# login` needed to pull), published for linux/amd64, linux/arm64,
# linux/s390x and linux/ppc64le. Runs as non-root (UID 1001, group 0) by
# default, WORKDIR /opt/app-root/src pre-owned for that user — no manual
# addgroup/adduser/chown needed, unlike the node:*-alpine images this
# replaced. Pinned by digest for reproducibility; refresh periodically
# (e.g. via Renovate/Dependabot) or override with
# --build-arg NODEJS_IMAGE=... to point at an internal mirror (air-gapped/
# FIPS environments).
ARG NODEJS_IMAGE=registry.access.redhat.com/ubi9/nodejs-22@sha256:d1f88101a85776886e459995eacdd7ebaa97c6e0c80b35c5a07a8a7b938ea9a3

# ---- UI dependencies ----
FROM node:22-alpine AS ui-deps
WORKDIR /ui
COPY package.json package-lock.json ./
FROM ${NODEJS_IMAGE} AS ui-deps
WORKDIR /opt/app-root/src
COPY --chown=1001:0 package.json package-lock.json ./
RUN npm ci --no-audit --no-fund

# ---- UI build ----
# npm run build = "npm run generate && tsc -b && vite build". `generate`
# runs orval against the committed openapi.json (no network call). vite's
# outDir is "server/public", so output lands at /ui/server/public here.
# outDir is "server/public", so output lands at
# /opt/app-root/src/server/public here.
FROM ui-deps AS ui-build
WORKDIR /ui
COPY openapi.json orval.config.ts index.html vite.config.ts build-constants.ts ./
COPY tsconfig.json tsconfig.app.json tsconfig.node.json ./
COPY public ./public
COPY src ./src
WORKDIR /opt/app-root/src
COPY --chown=1001:0 openapi.json orval.config.ts index.html vite.config.ts build-constants.ts ./
COPY --chown=1001:0 tsconfig.json tsconfig.app.json tsconfig.node.json ./
COPY --chown=1001:0 public ./public
COPY --chown=1001:0 src ./src
RUN npm run build

# ---- BFF dependencies ----
# Full (non-prod) install here — tsc is a devDependency needed to build.
FROM node:22-alpine AS bff-deps
WORKDIR /app
COPY server/package.json server/package-lock.json ./
FROM ${NODEJS_IMAGE} AS bff-deps
WORKDIR /opt/app-root/src
COPY --chown=1001:0 server/package.json server/package-lock.json ./
RUN npm ci --no-audit --no-fund

# ---- BFF build ----
FROM bff-deps AS bff-build
WORKDIR /app
COPY server/tsconfig.json ./
COPY server/src ./src
WORKDIR /opt/app-root/src
COPY --chown=1001:0 server/tsconfig.json ./
COPY --chown=1001:0 server/src ./src
RUN npm run build

# ---- Runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
COPY server/package.json server/package-lock.json ./
FROM ${NODEJS_IMAGE} AS runtime
WORKDIR /opt/app-root/src
COPY --chown=1001:0 server/package.json server/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY --from=bff-build /app/dist ./dist
COPY --from=ui-build /ui/server/public ./public
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
USER app
COPY --from=bff-build --chown=1001:0 /opt/app-root/src/dist ./dist
COPY --from=ui-build --chown=1001:0 /opt/app-root/src/server/public ./public
USER 1001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ The web interface for ContextForge, the open source AI gateway that federates
tools, agents, and APIs into one endpoint.

The backing service is a separate process in a separate repository
([IBM/mcp-context-forge](https://github.com/IBM/mcp-context-forge)).
([IBM/mcp-context-forge](https://github.com/IBM/mcp-context-forge)).
This repository holds the BFF and client that sit in front of it. The table below also documents the API for naming reference, though it lives in a separate repo:

| Component | Lives in | Role |
| --------- | -------- | ---- |
| **ContextForge API** | separate repo | FastAPI service that owns auth and all business data |
| **BFF** | `server/` | Fastify app holding the session/CSRF boundary in front of the API |
| **Client** | `src/` | React SPA, served as static files by the BFF |
| Component | Lives in | Role |
| -------------------- | ------------- | ----------------------------------------------------------------- |
| **ContextForge API** | separate repo | FastAPI service that owns auth and all business data |
| **BFF** | `server/` | Fastify app holding the session/CSRF boundary in front of the API |
| **Client** | `src/` | React SPA, served as static files by the BFF |

Throughout this README, "the API", "the BFF", and "the client" refer to those
three. The browser only ever talks to the BFF, never directly to the API.
Expand Down
Loading