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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ jobs:
python-version: "3.12"
- name: Build Python package
run: |
python -m pip install --upgrade pip build
python -m pip install --upgrade pip build twine
python -m build
python -m twine check dist/*

lint:
runs-on: ubuntu-latest
Expand All @@ -53,3 +54,4 @@ jobs:
- run: pip install ruff
- run: ruff check .
- run: ruff format --check .
- run: bash -n scripts/*
55 changes: 55 additions & 0 deletions .github/workflows/publish-dry-run.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Publish Dry Run

on:
workflow_dispatch:
pull_request:
branches: [main]
paths:
- "Dockerfile"
- ".dockerignore"
- "pyproject.toml"
- "README.md"
- "RELEASES.md"
- ".github/workflows/publish-dry-run.yml"
- ".github/workflows/release-artifacts.yml"

jobs:
python-publish-dry-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build distributions
run: |
python -m pip install --upgrade pip build twine
python -m build
- name: Validate Python distributions
run: python -m twine check dist/*
- name: Upload dry-run Python artifacts
uses: actions/upload-artifact@v4
with:
name: python-publish-dry-run
path: dist/*

ghcr-publish-dry-run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=dry-run
type=sha
- name: Build container image without pushing
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
- Added `contract: image-provider` plus an OpenAI-compatible `POST /v1/images/generations` path for image-capable providers
- Added a shipped Dockerfile and tag-driven release-artifacts workflow for Python distributions, GHCR images, and optional PyPI publishing
- Added public community-health and security baseline files: Code of Conduct, Security Policy, issue templates, PR template, Dependabot, and CodeQL
- Added generic onboarding helpers (`foundrygate-bootstrap`, `foundrygate-doctor`) and a publish-dry-run workflow for GHCR and Python package validation

## v0.4.0 - 2026-03-12

Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
[![OpenClaw-friendly](https://img.shields.io/badge/OpenClaw-friendly-111827.svg)](https://openclaw.ai/)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](./Dockerfile)
[![PyPI](https://img.shields.io/badge/pypi-workflow%20ready-3775A9?logo=pypi&logoColor=white)](./RELEASES.md)
[![Publish Dry Run](https://github.com/typelicious/FoundryGate/actions/workflows/publish-dry-run.yml/badge.svg)](https://github.com/typelicious/FoundryGate/actions/workflows/publish-dry-run.yml)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](./pyproject.toml)

## Quick Navigation
Expand All @@ -24,6 +25,7 @@
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Helper Scripts](#helper-scripts)
- [Publishing](#publishing)
- [Community And Security](#community-and-security)
- [Repo Safety And CI](#repo-safety-and-ci)
- [Workflow](#workflow)
Expand Down Expand Up @@ -83,6 +85,14 @@ curl -fsS http://127.0.0.1:8090/health
curl -fsS http://127.0.0.1:8090/v1/models
```

If you want the fastest local bootstrap, use the generic helpers first:

```bash
./scripts/foundrygate-bootstrap
$EDITOR .env
./scripts/foundrygate-doctor
```

If you prefer the Linux service path instead of a manual Python run, jump to [Helper Scripts](#helper-scripts) and use `./scripts/foundrygate-install`.

If you install the project as a package, the `foundrygate` and `foundrygate-stats` console scripts are available.
Expand All @@ -94,6 +104,7 @@ If every configured provider API key is empty, FoundryGate still starts, but it
- [Architecture](./docs/ARCHITECTURE.md)
- [Integrations](./docs/INTEGRATIONS.md)
- [Onboarding](./docs/ONBOARDING.md)
- [Publishing](./docs/PUBLISHING.md)
- [Troubleshooting](./docs/TROUBLESHOOTING.md)
- [Roadmap](./docs/FOUNDRYGATE-ROADMAP.md)

Expand Down Expand Up @@ -641,14 +652,36 @@ python -m build

Tagged releases always build Python artifacts. PyPI publishing is wired behind the repository variable `PYPI_PUBLISH=true` plus GitHub trusted publishing for the `pypi` environment.

## Publishing

FoundryGate now has a real publish dry-run path for both Python artifacts and the container image.

GitHub workflow:

- [publish-dry-run](./.github/workflows/publish-dry-run.yml) builds the wheel and sdist, runs `twine check`, and builds the GHCR image without pushing it
- [release-artifacts](./.github/workflows/release-artifacts.yml) is still the tag-driven publish path for real releases

Local dry-run commands:

```bash
python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*
docker build -t foundrygate:dry-run .
```

Use the dry run before cutting a release when Docker, packaging metadata, or release automation changed.

## Helper Scripts

The scripts in [scripts](./scripts) are optional wrappers around `systemd`, `journalctl`, and `curl`. They are most useful on Linux hosts that already use the included `systemd` unit.
The scripts in [scripts](./scripts) are optional wrappers around onboarding, `systemd`, `journalctl`, and `curl`.

Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin`.

| Script | What it does |
| --- | --- |
| `foundrygate-bootstrap` | Creates `.env` from `.env.example` if needed, creates a local state dir, and appends a safe local `FOUNDRYGATE_DB_PATH` if none is set |
| `foundrygate-doctor` | Checks for config/env presence, writable DB path, at least one configured provider key, and optional local health endpoints |
| `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service |
| `foundrygate-start` | Runs `systemctl start foundrygate.service` |
| `foundrygate-stop` | Runs `systemctl stop foundrygate.service` |
Expand Down Expand Up @@ -683,6 +716,7 @@ Security automation and review baseline:
FoundryGate includes two GitHub Actions workflows:

- [CI](./.github/workflows/ci.yml): runs Ruff plus the test matrix on Python 3.10 through 3.13
- [publish-dry-run](./.github/workflows/publish-dry-run.yml): validates Python distributions and the container build without publishing them
- [release-artifacts](./.github/workflows/release-artifacts.yml): builds Python distributions on tags, pushes container images to GHCR, and can publish to PyPI when trusted publishing is configured
- [repo-safety](./.github/workflows/repo-safety.yml): rejects accidental artifacts and secrets-like files
- [CodeQL](./.github/workflows/codeql.yml): performs repository code scanning for Python
Expand Down
7 changes: 7 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This repo does not require a heavy release process. Use lightweight tags plus Gi
6. Create a GitHub Release from that tag.
7. Use the changelog entry as the release notes, then add any short upgrade notes if needed.
8. Confirm that README plus the relevant docs pages still match the shipped runtime behavior.
9. If packaging or Docker changed shortly before the release, run the publish dry run first.

## Example

Expand All @@ -34,6 +35,12 @@ Tagged releases now trigger [release-artifacts](./.github/workflows/release-arti
- push the container image to GHCR
- publish to PyPI only when `PYPI_PUBLISH=true` is set and GitHub trusted publishing is configured for the `pypi` environment

The repo also includes [publish-dry-run](./.github/workflows/publish-dry-run.yml):

- build Python distributions without publishing them
- run `twine check`
- build the GHCR image without pushing it

## Versioning Guidance

- Use `x.y.z` version numbers and matching `vx.y.z` Git tags.
Expand Down
1 change: 1 addition & 0 deletions docs/FOUNDRYGATE-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Primary goals:
- publish an official Docker release path
- publish FoundryGate to PyPI
- add provider and client onboarding helpers for many-provider and many-client deployments
- add a publish dry-run path for Python package and GHCR validation before real release tags
- add validation workflows so operators can catch config mistakes before rollout
- complete the public community-health baseline and security-overview baseline for the repo

Expand Down
13 changes: 12 additions & 1 deletion docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ The safest onboarding order is:

1. one provider
2. one client
3. observability
3. bootstrap + diagnostics
4. second provider
5. client-specific defaults
6. policy constraints

## Provider onboarding sequence

### 0. Bootstrap the local checkout

Run the generic helpers before changing config:

```bash
./scripts/foundrygate-bootstrap
$EDITOR .env
./scripts/foundrygate-doctor
```

### 1. Add one provider

- define the provider in `config.yaml`
Expand Down Expand Up @@ -93,6 +103,7 @@ Current state:

- manual updates via Git or `foundrygate-update`
- tag-driven release artifacts for Python distributions and container images
- publish dry-run workflow for Python packaging and GHCR container builds

Planned state:

Expand Down
53 changes: 53 additions & 0 deletions docs/PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# FoundryGate Publishing

## Goal

Keep release publishing boring and repeatable.

FoundryGate currently ships through:

- Git tags and GitHub Releases
- Python distributions (`sdist` and `wheel`)
- a GHCR container image

PyPI remains opt-in and only publishes when trusted publishing is configured and `PYPI_PUBLISH=true` is set at the repository level.

## Dry-Run Path

Use the dry-run path whenever packaging, Docker, or release automation changes.

### GitHub

The repo includes [publish-dry-run](../.github/workflows/publish-dry-run.yml):

- builds the Python package
- runs `twine check dist/*`
- builds the container image through `docker/build-push-action`
- does not push to GHCR
- does not publish to PyPI

### Local

```bash
python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*
docker build -t foundrygate:dry-run .
```

## Real Release Path

The real publish flow stays tag-driven through [release-artifacts](../.github/workflows/release-artifacts.yml):

1. cut the release PR and merge it to `main`
2. tag the release from `main`
3. push the tag
4. let `release-artifacts` build Python distributions and the GHCR image
5. publish the GitHub Release
6. optionally allow PyPI publication through trusted publishing

## Trust Boundaries

- Dry-run workflows should never require production credentials.
- Real release publication should use GitHub environments and trusted publishing instead of long-lived secrets where possible.
- PyPI publication should remain opt-in until the package workflow is stable across several releases.
30 changes: 26 additions & 4 deletions foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@
_metrics: MetricsStore


def _client_error_response(message: str, *, error_type: str, status_code: int) -> JSONResponse:
"""Return a client-facing JSON error without exposing internal exception details."""
return JSONResponse({"error": message, "type": error_type}, status_code=status_code)


def _request_hook_error_response(exc: Exception) -> JSONResponse:
"""Return a sanitized request-hook failure response."""
logger.warning("Request hook processing failed: %s", exc)
return _client_error_response(
"Request hook processing failed",
error_type="request_hook_error",
status_code=500,
)


def _invalid_request_response(message: str, *, exc: Exception | None = None) -> JSONResponse:
"""Return a sanitized invalid-request response."""
if exc is not None:
logger.info("Invalid request rejected: %s", exc)
return _client_error_response(message, error_type="invalid_request_error", status_code=400)


async def _refresh_local_worker_probes(force: bool = False) -> None:
"""Refresh local-worker health state when probes are due."""
timeout_seconds = float(_config.health.get("timeout_seconds", 10))
Expand Down Expand Up @@ -537,7 +559,7 @@ async def preview_route(request: Request):
effective_body,
) = await _resolve_route_preview(body, headers)
except HookExecutionError as exc:
return JSONResponse({"error": str(exc), "type": "request_hook_error"}, status_code=500)
return _request_hook_error_response(exc)

return {
"requested_model": model_requested,
Expand Down Expand Up @@ -578,9 +600,9 @@ async def image_generations(request: Request):
effective_body,
) = await _resolve_image_route_preview(body, headers)
except HookExecutionError as exc:
return JSONResponse({"error": str(exc), "type": "request_hook_error"}, status_code=500)
return _request_hook_error_response(exc)
except ValueError as exc:
return JSONResponse({"error": str(exc), "type": "invalid_request_error"}, status_code=400)
return _invalid_request_response("Invalid image generation request", exc=exc)

prompt = effective_body["prompt"].strip()
image_fields = _collect_image_request_fields(effective_body)
Expand Down Expand Up @@ -690,7 +712,7 @@ async def chat_completions(request: Request):
effective_body,
) = await _resolve_route_preview(body, headers)
except HookExecutionError as exc:
return JSONResponse({"error": str(exc), "type": "request_hook_error"}, status_code=500)
return _request_hook_error_response(exc)
messages = effective_body.get("messages", [])
stream = effective_body.get("stream", False)
temperature = effective_body.get("temperature")
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dev = [
"pytest-asyncio>=0.24",
"httpx", # for TestClient
"ruff>=0.8",
"twine>=6.1",
]

[project.scripts]
Expand Down
38 changes: 38 additions & 0 deletions scripts/foundrygate-bootstrap
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
env_file="${1:-$repo_root/.env}"
state_dir="${FOUNDRYGATE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/foundrygate}"
db_path="${FOUNDRYGATE_DB_PATH:-$state_dir/foundrygate.db}"

mkdir -p "$state_dir"

if [ ! -f "$env_file" ]; then
cp "$repo_root/.env.example" "$env_file"
created_env=yes
else
created_env=no
fi

if ! grep -Eq '^[[:space:]]*FOUNDRYGATE_DB_PATH=' "$env_file"; then
printf '\nFOUNDRYGATE_DB_PATH=%s\n' "$db_path" >> "$env_file"
added_db_path=yes
else
added_db_path=no
fi

cat <<EOF
FoundryGate bootstrap complete.

- env file: $env_file
- state dir: $state_dir
- db path : $db_path
- .env created: $created_env
- FOUNDRYGATE_DB_PATH added: $added_db_path

Next steps:
1. Edit $env_file and set at least one provider API key.
2. Run ./scripts/foundrygate-doctor to verify the local setup.
3. Start FoundryGate with python -m foundrygate or use the systemd helper flow.
EOF
Loading
Loading