Skip to content

feat(gateway): publish the ghcr.io/forestadmin/gateway Docker image - #1987

Merged
nbouliol merged 5 commits into
feature/prd-1338-unify-the-standalone-gateway-configuration-on-one-set-of-envfrom
feature/prd-1339-publish-the-ghcrioforestadmingateway-docker-image
Oct 8, 2026
Merged

nbouliol merged 5 commits into
feature/prd-1338-unify-the-standalone-gateway-configuration-on-one-set-of-envfrom
feature/prd-1339-publish-the-ghcrioforestadmingateway-docker-image

Conversation

@nbouliol

@nbouliol nbouliol commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

ghcr.io/forestadmin/gateway: the MCP and/or the API in one image, built and pushed on every @forestadmin/gateway release. The MCP gets an image for the first time. agent-bff's image and workflow are untouched.

push on main
  build.yml › release
    snapshot @forestadmin/gateway@* tags
    multi-semantic-release              # first run releases gateway 1.0.0 (no longer private)
    new gateway tag? (also after a partial publish)
      gh workflow run docker-publish-gateway.yml -f version=X.Y.Z
        build (amd64, arm64): closure check › build › smoke › Trivy › push by digest + SBOM
        merge: :X.Y.Z, and :X.Y / :X / :latest when it is the latest stable
        notify-server: repository_dispatch gateway-published (latest stable only)
packages/gateway/
├── Dockerfile                # node:22 slim, isolated prod deps, 7-package closure
│                             # agent-bff's tracing preload, OTEL_SERVICE_NAME=forest-gateway
│                             # EXPOSE 3450 3931, HEALTHCHECK node dist/healthcheck.js
├── src/healthcheck.ts        # probes /health on resolveListenPort(env); only a 200 passes
└── docker/                   # copied from agent-bff/docker, rooted at @forestadmin/gateway
    ├── build-deps-manifest.js   # + mcp-server, MCP-side security resolutions
    ├── check-deps-closure.js
    ├── deps/yarn.lock
    ├── scan-gate.js
    └── smoke-test.sh

The probe and the server resolve the port the same way:

 parseGatewayEnv(env)
-  port = parsePort(aliases.resolve(PORT, [MCP_SERVER_PORT, HTTP_PORT]), default)
+  port = resolvePort(aliases, withMcp)        # PORT › aliases › 3931 with mcp, 3450 api only
+resolveListenPort(env)                        # same resolution, no warnings; used by the probe

Also: mcp-server pins zod to 4.6.5 like agent-bff (the manifest builder refuses two ranges; the root lockfile already resolved 4.6.5).

Decisions made during grilling:

  • Preload reused from agent-bff, required from the path the Gateway imports agent-bff by, so the tracing handle is shared. Side effect: the image's OTEL_SERVICE_NAME wins over a service.name in OTEL_RESOURCE_ATTRIBUTES.
  • Degraded (503) means unhealthy: the minimal config includes the MCP secrets.
  • gateway-published gated like agent-bff-published: latest stable only; fails when SERVER_DEPLOY_DISPATCH_TOKEN is missing. forestadmin-server ignores it until its receiver lands.

Evidence

  • Before: no Gateway image; node build-deps-manifest.js over the Gateway closure → Conflicting ranges for "zod".
    After:
    • yarn workspace @forestadmin/gateway test: 195/195, lint clean. New: healthcheck.test.ts (200 passes; 503, refused connection, invalid services/port, PORT=0 fail with the reason), resolveListenPort defaults and agreement with parseGatewayEnv.
    • check-deps-closure.js: closure OK (7 packages); docker/deps/yarn.lock installs with --frozen-lockfile in the image.
    • smoke-test.sh gateway:local (arm64, local build), all green:
      --version / --help / openapi / openapi --output / module graph / Redoc bundle
      boot mcp,api, Forest unreachable: "Forest Gateway started", OTel enabled as forest-gateway,
        /health 200 (mcp ok, api oauth false), /api/docs 200, POST /mcp 401
      mcp,api without PORT: HEALTHCHECK healthy on 3931
      mcp without PORT: HEALTHCHECK healthy on 3931
      api without PORT: HEALTHCHECK healthy on 3450
      api with HTTP_PORT=8080: HEALTHCHECK healthy
      mcp without its secrets: HEALTHCHECK unhealthy on a 503
      fully configured (stub Forest server): /health healthy, api oauth true
      graceful shutdown with an unreachable collector: exit 0 in 1s
      
    • Manual run against a real agent (mabank, dev Forest server, FOREST_GATEWAY_BASE_PATH=/ai), traces exported to a local Jaeger:
      • HEALTHCHECK healthy.
      • API with a fgw_ key: POST /ai/api/agent/v1/internal_notes/list 200; trace shows bff-api-keys/resolve 200 › forest-schema 200 › agent GET /forest/internal_notes 200 › activity log POST + PATCH status 200.
      • MCP from Claude Code: discovery on /.well-known/oauth-authorization-server/ai 200 › /ai/oauth/authorize 302 › /ai/oauth/token 200 › POST /ai/mcp 401 then 200 once signed in; tool calls work.
      • 63 traces under service forest-gateway, no Error log line.

Not verified yet:

  • The publish path (dispatch from build.yml, push, :latest, gateway-published): it checks out the @forestadmin/gateway@X.Y.Z tag, which only exists after the release. To check on the first release after merge.
  • arm64 CI build, amd64 image (the PR job builds amd64).
  • Traces in Datadog: verified up to OTLP export (Jaeger); Datadog ingestion needs someone with access.
  • docker-publish-gateway.yml not linted with actionlint (YAML parsed).

Merge Danger

Door: one-way

Merging releases @forestadmin/gateway@1.0.0 to npm (a published version cannot be reused) and pushes the first public image. Before merging:

  • Reserve @forestadmin/gateway on npm and set up trusted publishing; without it the npm publish fails and no image is built.
  • Right after the first push: flip the GHCR package to public (the push token cannot); until then anonymous pulls fail.

Rollback: stop publishing the image. Nothing consumes it until the hosted API service switches to it.

Blast Radius: release-pipeline

New steps in build.yml's release job (guarded by !cancelled(), exit 0 when there is no new tag). docker-publish-bff.yml and the agent-bff image are untouched. Every agent-bff or mcp-server release now also releases the Gateway (--deps.bump=override) and rebuilds its image.

Stacked on #1980.

Fixes PRD-1339

🤖 Generated with Claude Code

Note

Add gateway Docker image build and publish pipeline to ghcr.io/forestadmin/gateway

  • Adds a three-stage Dockerfile that builds the gateway with its workspace dependencies, installs external runtime deps from a committed lockfile, and runs as an unprivileged node user with a Docker healthcheck
  • Adds a docker-publish workflow that builds amd64 and arm64 images, gates publication on smoke tests and Trivy vulnerability scans, merges them into a multi-arch manifest on GHCR, and moves minor/major/latest tags for the highest stable release
  • Adds a release workflow step that detects newly created @forestadmin/gateway tags and dispatches the publish workflow with the highest new version
  • Adds support scripts: build-deps-manifest.js for generating the runtime dependency manifest, check-deps-closure.js for verifying workspace closure copies, scan-gate.js for scan gating, and an end-to-end smoke-test.sh
  • Changes: @forestadmin/gateway is no longer marked private in package.json; mcp-server pins zod to exact 4.6.5 in package.json; latest gateway publication sends a gateway-published repository_dispatch event to ForestAdmin/forestadmin-server

Macroscope summarized c6d6c31.

nbouliol and others added 3 commits October 8, 2026 10:27
The Gateway image merges both packages' runtime deps into one manifest,
which refuses two ranges for the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dist/healthcheck.js probes /health on the port the Gateway listens on:
PORT, then its legacy aliases, then 3931 with mcp, 3450 with api only.
Only a 200 passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Dockerfile modeled on agent-bff's: agent-bff's tracing preload with
  OTEL_SERVICE_NAME=forest-gateway, EXPOSE 3450 3931, HEALTHCHECK on
  dist/healthcheck.js
- docker/: deps manifest and lockfile, closure check rooted at the
  Gateway, scan gate, smoke test (mcp, api, mcp,api, legacy port,
  degraded 503, clean stop)
- docker-publish-gateway.yml, dispatched by build.yml on a new
  @forestadmin/gateway tag; sends gateway-published on the latest stable
- the package is no longer private, so it reaches npm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 8, 2026

Copy link
Copy Markdown

PRD-1339

@qltysh

qltysh Bot commented Oct 8, 2026

Copy link
Copy Markdown

4 new issues

Tool Category Rule Count
qlty Duplication Found 47 lines of similar code in 2 locations (mass = 226) 3
qlty Structure Function with high complexity (count = 12): generate 1

Comment thread .github/workflows/docker-publish-gateway.yml Outdated
Comment thread .github/workflows/docker-publish-gateway.yml Outdated
@nbouliol

nbouliol commented Oct 8, 2026

Copy link
Copy Markdown
Member Author

Test report

Area Check Result
Unit yarn workspace @forestadmin/gateway test: 195/195 (incl. health probe, resolveListenPort), lint clean ✅
Image closure check-deps-closure.js: 7 packages; deps/yarn.lock installs with --frozen-lockfile ✅
Smoke (local arm64 build) CLI (--version, --help, openapi, --output), module graph, Redoc bundle ✅
Boot mcp,api with Forest unreachable: /health 200, /api/docs 200, POST /mcp 401, OTel enabled as forest-gateway ✅
Docker HEALTHCHECK: mcp and mcp,api on 3931, api on 3450, port via HTTP_PORT ✅
MCP without its secrets: 503, container unhealthy ✅
Fully configured: API OAuth on, clean stop in 1s ✅
Real run (mabank agent, dev Forest server, base path /ai) API with a fgw_ key: list 200; key resolution, agent call and activity logs OK ✅
MCP from Claude Code: full OAuth, tool calls OK ✅
63 traces under forest-gateway in a local Jaeger, no Error log line ✅

Not verified yet

  • Publish path (dispatch from build.yml, push, :latest, gateway-published, anonymous pull): only testable on the first release after merge.
  • CI: the PR job (amd64 + Trivy) runs here; arm64 builds only on release.
  • Datadog ingestion: verified up to the OTLP export (Jaeger); needs someone with Datadog access.
  • docker-publish-gateway.yml not linted with actionlint (YAML parsed).

Before merging

  • Reserve @forestadmin/gateway on npm and set up trusted publishing; otherwise the npm release fails and no image is built.
  • Flip the GHCR package to public right after the first push.

…the push

- each pushed digest is an index once sbom is on; resolve it to its
  platform manifests before comparing with what :version points to
- fetch tags and decide latest-stable in the step that pushes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qltysh

qltysh Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/gateway/src/gateway-env.ts100.0%
New file Coverage rating: A
packages/gateway/src/healthcheck.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (PRD-1339): conforms. The publish path, the arm64 build and the public pull stay unverified until the first release, as the description states.

- name: Trigger gateway Docker image publish (if released)
if: ${{ !cancelled() }}
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}

Copy link
Copy Markdown
Member

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-review locally before merging.

Triggers
  • .github/workflows/build.yml:321 hands GH_TOKEN to a new step that dispatches the image publish.
  • .github/workflows/docker-publish-gateway.yml:182 grants packages: write to the build job that pushes.
  • .github/workflows/docker-publish-gateway.yml:320 grants packages: write to the merge job that moves tags.
  • .github/workflows/docker-publish-gateway.yml:448 sends SERVER_DEPLOY_DISPATCH_TOKEN to forestadmin-server.
  • packages/gateway/docker/deps/yarn.lock:1 adds the image production lockfile.
  • packages/gateway/docker/build-deps-manifest.js:47 pins security resolutions for the image dependencies.
  • packages/mcp-server/package.json:26 pins zod to 4.6.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/security-review run on this PR's own diff (against its base branch, compared with the BFF workflow and agent-bff/docker it is modeled on): no finding above the confidence bar.

Checked against each trigger:

  • build.yml dispatch with GH_TOKEN: same shape as the existing agent-bff step; VERSION comes from tags semantic-release just created and is passed quoted to gh workflow run.
  • packages: write on build and merge: both are gated if: github.event_name != 'pull_request'; top-level permissions are contents: read. The PR job (validate) has no registry login, push: false, no cache-to, no secret. The trigger is pull_request, not pull_request_target, so fork PRs get neither secrets nor a write token.
  • version input (workflow_dispatch, write access only): reaches the shell through env:, checked against a strict semver regex before use; every derived tag (FULL/MINOR/MAJOR) flows from the validated output. build checks out the @forestadmin/gateway@<version> tag.
  • SERVER_DEPLOY_DISPATCH_TOKEN: only in env:, never echoed; the payload is built from the validated version; the job runs only on non-PR, latest-stable publishes.
  • Image: base pinned by digest, runs as node, code stays root-owned (only /app itself is chowned), installs with --frozen-lockfile --ignore-scripts, no secret baked as ARG/ENV.
  • deps/yarn.lock and build-deps-manifest.js resolutions: they add security pins (qs, lodash, hono, fast-uri, body-parser, propagator-jaeger), and nothing is loosened; scan-gate.js is identical to agent-bff's, so Trivy still blocks fixable CRITICAL/HIGH.
  • zod pin: aligns mcp-server with agent-bff on the version the root lockfile already resolved.

Also checked: the health probe only hits localhost on an env-resolved port; dropping private publishes only dist/**/*.js and .d.ts (same files as agent-bff), so neither docker/ nor any env file ships to npm.

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after /validator run 1987-20261008121230-Tonours: nothing at or above should-fix posted, 0 suppressed.

@nbouliol
nbouliol added this pull request to stack #1989 October 8, 2026 12:34
@nbouliol
nbouliol removed this pull request from stack #1989 October 8, 2026 12:35
@nbouliol
nbouliol merged commit 76466fa into feature/prd-1338-unify-the-standalone-gateway-configuration-on-one-set-of-env Oct 8, 2026
89 of 98 checks passed
@nbouliol
nbouliol deleted the feature/prd-1339-publish-the-ghcrioforestadmingateway-docker-image branch October 8, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants