Repository navigation
feat(gateway): publish the ghcr.io/forestadmin/gateway Docker image #1987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nbouliol
merged 5 commits into
feature/prd-1338-unify-the-standalone-gateway-configuration-on-one-set-of-env
from
feature/prd-1339-publish-the-ghcrioforestadmingateway-docker-image
Oct 8, 2026
+4,107
−7
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6ae9901
chore(mcp-server): pin zod to 4.6.5 like agent-bff
nbouliol 2c4aed8
feat(gateway): add a health probe that resolves the Gateway's port
nbouliol 75b8ee1
feat(gateway): publish the ghcr.io/forestadmin/gateway image
nbouliol 9815d06
fix(gateway): let a merge replay pass and decide latest right before …
nbouliol c6d6c31
test(gateway): cover the health probe timeout
nbouliol File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
Large diffs are not rendered by default.
Oops, something went wrong.
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| # syntax=docker/dockerfile:1 | ||
| # | ||
| # Production image for @forestadmin/gateway: the MCP, the API, or both, on one port. | ||
| # The build context MUST be the monorepo root (yarn workspaces + yarn.lock): | ||
| # | ||
| # docker build -f packages/gateway/Dockerfile -t ghcr.io/forestadmin/gateway:latest . | ||
| # | ||
| # Configuration is passed entirely via environment variables; FOREST_GATEWAY_SERVICES | ||
| # (mcp, api or mcp,api) is required. | ||
|
|
||
| # Base image pinned by digest (node:22-bookworm-slim). Bump via Renovate/Dependabot. | ||
| FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4 AS base | ||
|
|
||
| # ── Stage 1: build ──────────────────────────────────────────────────────────── | ||
| FROM base AS builder | ||
| WORKDIR /app | ||
|
|
||
| # Same install as the agent-bff image: manifests + lockfile first so a source-only | ||
| # change keeps the install layer cached, --frozen-lockfile so the tree matches | ||
| # yarn.lock, --ignore-scripts to skip husky and native builds never loaded here. | ||
| COPY --parents package.json yarn.lock packages/*/package.json packages/*/*/package.json ./ | ||
| RUN yarn install --frozen-lockfile --ignore-scripts | ||
|
|
||
| COPY . . | ||
|
|
||
| # Build the Gateway and only its 6 workspace dependencies, in topological order. | ||
| # agent-bff's `build` also copies the Redoc bundle into dist/docs. | ||
| RUN node_modules/.bin/lerna run build \ | ||
| --scope @forestadmin/gateway \ | ||
| --include-dependencies | ||
|
|
||
| # ── Stage 2: isolated prod deps ─────────────────────────────────────────────── | ||
| # Only the external runtime deps of the 7 workspace packages, from the committed | ||
| # lockfile (see docker/README.md): a dependency change it does not cover fails here. | ||
| FROM base AS prod-deps | ||
| WORKDIR /deps | ||
|
|
||
| COPY packages/gateway/docker/build-deps-manifest.js ./build-deps-manifest.js | ||
| COPY packages/gateway/docker/deps/yarn.lock ./yarn.lock | ||
| COPY packages/gateway/package.json ./packages/gateway/package.json | ||
| COPY packages/agent-bff/package.json ./packages/agent-bff/package.json | ||
| COPY packages/agent-client/package.json ./packages/agent-client/package.json | ||
| COPY packages/agent-toolkit/package.json ./packages/agent-toolkit/package.json | ||
| COPY packages/datasource-toolkit/package.json ./packages/datasource-toolkit/package.json | ||
| COPY packages/forestadmin-client/package.json ./packages/forestadmin-client/package.json | ||
| COPY packages/mcp-server/package.json ./packages/mcp-server/package.json | ||
|
|
||
| RUN node build-deps-manifest.js packages package.json | ||
| RUN yarn install --frozen-lockfile --ignore-scripts | ||
|
|
||
| # ── Stage 3: runtime ────────────────────────────────────────────────────────── | ||
| FROM base AS runtime | ||
| WORKDIR /app | ||
| ENV NODE_ENV=production | ||
|
|
||
| # The digest pin freezes Debian packages too, so security fixes can only land here. APT_EPOCH is | ||
| # part of this layer's cache key: CI passes the run id so every run refetches; local builds reuse | ||
| # the cached layer until --no-cache. | ||
| ARG APT_EPOCH | ||
| RUN echo "$APT_EPOCH" \ | ||
| && apt-get update -o APT::Update::Error-Mode=any \ | ||
| && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --with-new-pkgs -o Dpkg::Options::=--force-confold \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
|
|
||
| COPY --from=prod-deps /deps/node_modules ./node_modules | ||
|
|
||
| # @forestadmin/* workspace packages placed directly in node_modules, resolved by | ||
| # walking up to /app/node_modules/@forestadmin/<pkg>/ and its package.json "main". | ||
| COPY --from=builder /app/packages/agent-bff/dist ./node_modules/@forestadmin/agent-bff/dist | ||
| COPY --from=builder /app/packages/agent-bff/package.json ./node_modules/@forestadmin/agent-bff/package.json | ||
| COPY --from=builder /app/packages/agent-client/dist ./node_modules/@forestadmin/agent-client/dist | ||
| COPY --from=builder /app/packages/agent-client/package.json ./node_modules/@forestadmin/agent-client/package.json | ||
| COPY --from=builder /app/packages/agent-toolkit/dist ./node_modules/@forestadmin/agent-toolkit/dist | ||
| COPY --from=builder /app/packages/agent-toolkit/package.json ./node_modules/@forestadmin/agent-toolkit/package.json | ||
| COPY --from=builder /app/packages/datasource-toolkit/dist ./node_modules/@forestadmin/datasource-toolkit/dist | ||
| COPY --from=builder /app/packages/datasource-toolkit/package.json ./node_modules/@forestadmin/datasource-toolkit/package.json | ||
| COPY --from=builder /app/packages/forestadmin-client/dist ./node_modules/@forestadmin/forestadmin-client/dist | ||
| COPY --from=builder /app/packages/forestadmin-client/package.json ./node_modules/@forestadmin/forestadmin-client/package.json | ||
| COPY --from=builder /app/packages/mcp-server/dist ./node_modules/@forestadmin/mcp-server/dist | ||
| COPY --from=builder /app/packages/mcp-server/package.json ./node_modules/@forestadmin/mcp-server/package.json | ||
|
|
||
| # Gateway entry point. | ||
| COPY --from=builder /app/packages/gateway/dist ./packages/gateway/dist | ||
| COPY --from=builder /app/packages/gateway/package.json ./packages/gateway/package.json | ||
|
|
||
| # The working directory, and only it: `openapi --output` defaults to ./openapi.json. Not | ||
| # recursive — node_modules and dist stay root-owned, so the process cannot rewrite its code. | ||
| RUN chown node:node /app | ||
|
|
||
| USER node | ||
|
|
||
| # Not `forestadmin-agent-bff`, the default of the preload this image borrows from agent-bff. | ||
| # Setting OTEL_SERVICE_NAME at run time still wins. | ||
| ENV OTEL_SERVICE_NAME=forest-gateway | ||
|
|
||
| # 3931 when the mcp service is on, 3450 with api only; PORT overrides both. A real port only: | ||
| # with PORT=0 nothing outside the process learns the port, and the healthcheck fails. | ||
| EXPOSE 3450 3931 | ||
|
|
||
| # The probe resolves the port the way the Gateway does (PORT, then its legacy aliases, then the | ||
| # per-service default), so the two never disagree. Only a 200 passes: a degraded service makes | ||
| # /health answer 503, and a misconfigured container is meant to read unhealthy. | ||
| HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ | ||
| CMD ["node", "/app/packages/gateway/dist/healthcheck.js"] | ||
|
|
||
| # OCI image metadata (https://github.com/opencontainers/image-spec). | ||
| LABEL org.opencontainers.image.title="forestadmin-gateway" \ | ||
| org.opencontainers.image.description="Forest Admin MCP and API services on one port." \ | ||
| org.opencontainers.image.source="https://github.com/ForestAdmin/agent-nodejs" \ | ||
| org.opencontainers.image.licenses="GPL-3.0" | ||
|
|
||
| # Exec form, no wrapper script: the CLI takes positional subcommands (`openapi`). | ||
| # | ||
| # The preload is agent-bff's, required from the same path the Gateway imports agent-bff by, so | ||
| # the tracing handle it parks is the one the shutdown path flushes. Inert until an OTLP endpoint | ||
| # or OTEL_TRACES_EXPORTER is set. | ||
| ENTRYPOINT ["node", "--require", "/app/node_modules/@forestadmin/agent-bff/dist/tracing-preload.js", "/app/packages/gateway/dist/cli.js"] |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # Docker image build assets | ||
|
|
||
| The production image installs its runtime dependencies into an **isolated** | ||
| `node_modules` (rather than shipping the whole monorepo's hoisted tree). This | ||
| keeps the image small with a fully pinned dependency tree. | ||
|
|
||
| ## How it works | ||
|
|
||
| - [`build-deps-manifest.js`](./build-deps-manifest.js) merges the external | ||
| (non-`@forestadmin`) runtime dependencies of the Gateway and its 6 | ||
| workspace dependencies, plus the OpenTelemetry packages, into a single `package.json`. | ||
| Three OTel packages are named there; `sdk-node` drags in every OTLP exporter | ||
| and the Zipkin one behind them, which is what lets the image honour | ||
| `OTEL_TRACES_EXPORTER` and `OTEL_EXPORTER_OTLP_PROTOCOL` without the app ever | ||
| choosing an exporter. | ||
| - [`deps/yarn.lock`](./deps/) pins every transitive version. | ||
| - The Docker build regenerates the manifest from the live `package.json` files | ||
| and runs `yarn install --frozen-lockfile`. If a workspace dependency changes | ||
| in a way the committed lock does not cover, **the build fails** instead of | ||
| silently shipping an unpinned version. | ||
| - [`check-deps-closure.js`](./check-deps-closure.js) recomputes the real | ||
| transitive `@forestadmin` closure of the Gateway and fails when the hardcoded list | ||
| or the Dockerfile `COPY` lines drift from it. | ||
| - [`smoke-test.sh`](./smoke-test.sh) runs a locally-loaded image before it is | ||
| published: CLI surface, module graph, Redoc bundle, boot with `mcp,api` and | ||
| an unreachable Forest server, the Docker `HEALTHCHECK` per service set, and a | ||
| clean stop on SIGTERM. | ||
|
|
||
| ## Running the smoke test | ||
|
|
||
| ```bash | ||
| sh packages/gateway/docker/smoke-test.sh <image-ref> | ||
| ``` | ||
|
|
||
| It needs `docker`, `curl`, `openssl` and **`python3`** on the host. The last one | ||
| serves a two-line stub of the Forest server so the last boot can be fully | ||
| configured and reach `/health` 200 — the path the image's own HEALTHCHECK | ||
| demands, which nothing else exercises. All four are present on the GitHub | ||
| runners; on a slim box python3 is the one likely to be missing. | ||
|
|
||
| ## Updating the lockfile | ||
|
|
||
| Only `deps/yarn.lock` is committed — the manifest is generated on demand (the | ||
| Docker build regenerates it too), so there is no stale `package.json` to drift. | ||
| Run this whenever a runtime dependency of one of the 7 workspace packages | ||
| changes **or when the `OTEL_DEPENDENCIES` versions in `build-deps-manifest.js` | ||
| are bumped** (the build will fail with `--frozen-lockfile` until you do): | ||
|
|
||
| The generated manifest carries the repo's pinned `packageManager` | ||
| (`yarn@1.22.19`), so with Corepack enabled (`corepack enable`) the refresh uses | ||
| the same Yarn as the Docker build — a global Yarn 4 would otherwise emit an | ||
| incompatible lockfile format. | ||
|
|
||
| ```bash | ||
| # from the monorepo root (Corepack enabled) | ||
| TMP=$(mktemp -d) | ||
| node packages/gateway/docker/build-deps-manifest.js packages "$TMP/package.json" | ||
| ( cd "$TMP" && yarn install --ignore-scripts ) | ||
| cp "$TMP/yarn.lock" packages/gateway/docker/deps/yarn.lock | ||
| rm -rf "$TMP" | ||
| ``` | ||
|
|
||
| Then commit the updated `deps/yarn.lock`. |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| // Generates the package.json for the Docker image's isolated runtime deps. | ||
| // | ||
| // It merges the external (non-@forestadmin) runtime dependencies of the Gateway | ||
| // and its 6 workspace dependencies into a single manifest, plus the OpenTelemetry | ||
| // packages used for APM (Docker-only; not shipped to npm consumers of the CLI). | ||
| // | ||
| // The output is deterministic (sorted keys). A committed yarn.lock sits next to | ||
| // the generated manifest; the Docker build regenerates the manifest and runs | ||
| // `yarn install --frozen-lockfile`, so any workspace dependency change that the | ||
| // lock does not cover fails the build instead of silently drifting. | ||
| // | ||
| // Usage: node build-deps-manifest.js <packagesDir> <outFile> | ||
| // <packagesDir> directory containing the workspace packages (e.g. "packages") | ||
| // <outFile> path to write the merged package.json | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const WORKSPACE_PACKAGES = [ | ||
| 'gateway', | ||
| 'agent-bff', | ||
| 'agent-client', | ||
| 'agent-toolkit', | ||
| 'datasource-toolkit', | ||
| 'forestadmin-client', | ||
| 'mcp-server', | ||
| ]; | ||
|
|
||
| // Pinned to exact versions — OTel ships only in the Docker image, so nothing else bumps them. | ||
| // sdk-node drags in every OTLP exporter plus the Zipkin one, which is why the image can honour | ||
| // OTEL_TRACES_EXPORTER and OTEL_EXPORTER_OTLP_PROTOCOL without naming an exporter here. | ||
| // A fixable CRITICAL/HIGH in this tree blocks every publish of this image until someone raises a | ||
| // pin here and refreshes deps/yarn.lock (see the propagator-jaeger resolution below for the shape | ||
| // of that fix). Renovate does not watch this file — it is a plain object, not a manifest — so an | ||
| // unrelated release will stall on it unless someone is looking. | ||
| const OTEL_DEPENDENCIES = { | ||
| '@opentelemetry/sdk-node': '0.219.0', | ||
| '@opentelemetry/auto-instrumentations-node': '0.77.0', | ||
| '@opentelemetry/exporter-trace-otlp-http': '0.219.0', | ||
| }; | ||
|
|
||
| // Security pins for transitive deps whose parents never ship a patched range. | ||
| // They mirror the monorepo root's `resolutions` for the packages that actually | ||
| // appear in this closure — the isolated install does not inherit the root ones. | ||
| const RESOLUTIONS = { | ||
| // qs DoS + array-limit bypass (Dependabot #488-#489, patched in 6.16.0). | ||
| '**/qs': '^6.16.0', | ||
| // jsonapi-serializer pins lodash ^4.17.x. | ||
| '**/lodash': '^4.18.0', | ||
| // auto-instrumentations-node pins propagator-jaeger 2.8.0 (CVE-2026-59892, fixed in 2.9.0). | ||
| '**/@opentelemetry/propagator-jaeger': '2.9.0', | ||
| // Dependabot alert #452 — Hono ReDoS + memo() SSR retention + lang middleware DoS (patched in 4.12.34). | ||
| '**/@modelcontextprotocol/sdk/hono': '^4.12.34', | ||
| // fast-uri host confusion + SSRF cluster (Dependabot #499-#502, patched in 3.1.6). | ||
| '**/ajv/fast-uri': '^3.1.6', | ||
| // Mirrors the root resolutions: express 5 (mcp-server, MCP SDK) pins an unpatched body-parser. | ||
| '**/express/body-parser': '^2.3.0', | ||
| }; | ||
|
|
||
| function generate(packagesDir, outFile) { | ||
| // A flat install holds one version per dependency, so two packages asking for | ||
| // different ranges is a decision, not something to let iteration order settle. | ||
| // It has never happened here — refusing keeps it that way, and keeps the answer | ||
| // out of this script. | ||
| const deps = {}; | ||
| const declaredBy = {}; | ||
|
|
||
| for (const pkg of WORKSPACE_PACKAGES) { | ||
| const manifestPath = path.join(packagesDir, pkg, 'package.json'); | ||
| const { dependencies = {} } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); | ||
|
|
||
| for (const [name, range] of Object.entries(dependencies)) { | ||
| if (name.startsWith('@forestadmin/')) continue; | ||
|
|
||
| if (deps[name] !== undefined && deps[name] !== range) { | ||
| throw new Error( | ||
| `Conflicting ranges for "${name}": ${deps[name]} (${declaredBy[name]}) and ` + | ||
| `${range} (${pkg}). Align the version in the source packages.`, | ||
| ); | ||
| } | ||
|
|
||
| deps[name] = range; | ||
| declaredBy[name] = pkg; | ||
| } | ||
| } | ||
|
|
||
| Object.assign(deps, OTEL_DEPENDENCIES); | ||
|
|
||
| const sorted = Object.fromEntries(Object.keys(deps).sort().map(key => [key, deps[key]])); | ||
|
|
||
| const manifest = { name: 'gateway-docker-deps', private: true }; | ||
|
|
||
| // Carry the monorepo's pinned package manager so a manual lockfile refresh | ||
| // (yarn install on this generated manifest) uses the same Yarn via Corepack, | ||
| // not a contributor's global Yarn 4 which would emit an incompatible lockfile. | ||
| // Resolved from the repo root relative to this script; absent in the Docker | ||
| // build (root package.json isn't copied there) — harmless, the image uses its | ||
| // own bundled Yarn 1.x. | ||
| const packageManager = rootPackageManager(); | ||
| if (packageManager) manifest.packageManager = packageManager; | ||
|
|
||
| manifest.dependencies = sorted; | ||
| manifest.resolutions = RESOLUTIONS; | ||
|
|
||
| fs.writeFileSync(outFile, `${JSON.stringify(manifest, null, 2)}\n`); | ||
| } | ||
|
|
||
| function rootPackageManager() { | ||
| try { | ||
| const root = JSON.parse( | ||
| fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8'), | ||
| ); | ||
|
|
||
| return root.packageManager; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| const [, , packagesDir, outFile] = process.argv; | ||
|
|
||
| if (!packagesDir || !outFile) { | ||
| console.error('Usage: node build-deps-manifest.js <packagesDir> <outFile>'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| generate(packagesDir, outFile); | ||
| } | ||
|
|
||
| module.exports = { WORKSPACE_PACKAGES, generate }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude Opus 5.5 (claude-opus-5-5): This diff meets the criteria for a security review, so please run
/security-reviewlocally before merging.Triggers
.github/workflows/build.yml:321handsGH_TOKENto a new step that dispatches the image publish..github/workflows/docker-publish-gateway.yml:182grantspackages: writeto the build job that pushes..github/workflows/docker-publish-gateway.yml:320grantspackages: writeto the merge job that moves tags..github/workflows/docker-publish-gateway.yml:448sendsSERVER_DEPLOY_DISPATCH_TOKENto forestadmin-server.packages/gateway/docker/deps/yarn.lock:1adds the image production lockfile.packages/gateway/docker/build-deps-manifest.js:47pins security resolutions for the image dependencies.packages/mcp-server/package.json:26pinszodto4.6.5.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/security-reviewrun on this PR's own diff (against its base branch, compared with the BFF workflow andagent-bff/dockerit is modeled on): no finding above the confidence bar.Checked against each trigger:
build.ymldispatch withGH_TOKEN: same shape as the existing agent-bff step;VERSIONcomes from tags semantic-release just created and is passed quoted togh workflow run.packages: writeonbuildandmerge: both are gatedif: github.event_name != 'pull_request'; top-level permissions arecontents: read. The PR job (validate) has no registry login,push: false, nocache-to, no secret. The trigger ispull_request, notpull_request_target, so fork PRs get neither secrets nor a write token.versioninput (workflow_dispatch, write access only): reaches the shell throughenv:, checked against a strict semver regex before use; every derived tag (FULL/MINOR/MAJOR) flows from the validated output.buildchecks out the@forestadmin/gateway@<version>tag.SERVER_DEPLOY_DISPATCH_TOKEN: only inenv:, never echoed; the payload is built from the validated version; the job runs only on non-PR, latest-stable publishes.node, code stays root-owned (only/appitself is chowned), installs with--frozen-lockfile --ignore-scripts, no secret baked asARG/ENV.deps/yarn.lockandbuild-deps-manifest.jsresolutions: they add security pins (qs, lodash, hono, fast-uri, body-parser, propagator-jaeger), and nothing is loosened;scan-gate.jsis identical to agent-bff's, so Trivy still blocks fixable CRITICAL/HIGH.zodpin: aligns mcp-server with agent-bff on the version the root lockfile already resolved.Also checked: the health probe only hits
localhoston an env-resolved port; droppingprivatepublishes onlydist/**/*.jsand.d.ts(samefilesas agent-bff), so neitherdocker/nor any env file ships to npm.