diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85954e..e9b135a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -53,3 +54,4 @@ jobs: - run: pip install ruff - run: ruff check . - run: ruff format --check . + - run: bash -n scripts/* diff --git a/.github/workflows/publish-dry-run.yml b/.github/workflows/publish-dry-run.yml new file mode 100644 index 0000000..45be36e --- /dev/null +++ b/.github/workflows/publish-dry-run.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index fed2e84..90e6172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5f453d3..160a02a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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. @@ -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) @@ -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` | @@ -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 diff --git a/RELEASES.md b/RELEASES.md index 1c9adb4..5d7c427 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -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 @@ -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. diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index 91b5017..f04b825 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -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 diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 296369c..35baeca 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -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` @@ -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: diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..4978f64 --- /dev/null +++ b/docs/PUBLISHING.md @@ -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. diff --git a/foundrygate/main.py b/foundrygate/main.py index b0cd64a..c1c50fa 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -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)) @@ -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, @@ -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) @@ -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") diff --git a/pyproject.toml b/pyproject.toml index fbf3e87..2dd96e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "pytest-asyncio>=0.24", "httpx", # for TestClient "ruff>=0.8", + "twine>=6.1", ] [project.scripts] diff --git a/scripts/foundrygate-bootstrap b/scripts/foundrygate-bootstrap new file mode 100755 index 0000000..156e79a --- /dev/null +++ b/scripts/foundrygate-bootstrap @@ -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 <&2 + status=1 +} + +if [ -f "$config_file" ]; then + ok "config present: $config_file" +else + fail "missing config file: $config_file" +fi + +if [ -f "$env_file" ]; then + ok "env file present: $env_file" +else + fail "missing env file: $env_file" +fi + +db_path="/var/lib/foundrygate/foundrygate.db" +if [ -f "$env_file" ]; then + env_db_path="$(awk -F= '/^[[:space:]]*FOUNDRYGATE_DB_PATH=/{sub(/^[[:space:]]+/, "", $2); print $2}' "$env_file" | tail -n1)" + if [ -n "$env_db_path" ]; then + db_path="$env_db_path" + fi +fi + +db_dir="$(dirname "$db_path")" +if [ -d "$db_dir" ] && [ -w "$db_dir" ]; then + ok "db directory is writable: $db_dir" +elif [ -d "$db_dir" ]; then + warn "db directory exists but is not writable by $(id -un): $db_dir" +else + warn "db directory does not exist yet: $db_dir" +fi + +if [ -f "$env_file" ] && grep -Eq '^(DEEPSEEK_API_KEY|GEMINI_API_KEY|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENCODE_API_KEY)=[^[:space:]#]+' "$env_file"; then + ok "at least one provider API key is configured" +else + warn "no provider API key detected in $env_file" +fi + +if command -v curl >/dev/null 2>&1; then + if curl -fsS -m 2 http://127.0.0.1:8090/health >/dev/null 2>&1; then + ok "local /health endpoint is reachable" + else + warn "local /health endpoint is not reachable on 127.0.0.1:8090" + fi + + if curl -fsS -m 2 http://127.0.0.1:8090/v1/models >/dev/null 2>&1; then + ok "local /v1/models endpoint is reachable" + else + warn "local /v1/models endpoint is not reachable on 127.0.0.1:8090" + fi +else + warn "curl is not available; skipping local endpoint checks" +fi + +exit "$status" diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install index cc81331..bd33aa7 100755 --- a/scripts/foundrygate-install +++ b/scripts/foundrygate-install @@ -3,6 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" helpers=( + foundrygate-bootstrap + foundrygate-doctor foundrygate-install foundrygate-start foundrygate-stop diff --git a/tests/test_request_hooks.py b/tests/test_request_hooks.py index 3843371..93d8574 100644 --- a/tests/test_request_hooks.py +++ b/tests/test_request_hooks.py @@ -46,7 +46,11 @@ async def aclose(self): RequestHookResult, apply_request_hooks, ) -from foundrygate.main import _resolve_route_preview +from foundrygate.main import ( + _invalid_request_response, + _request_hook_error_response, + _resolve_route_preview, +) from foundrygate.router import Router @@ -243,6 +247,27 @@ async def test_locality_and_profile_hooks_shape_one_request(self, hook_config): class TestRequestHookHardening: + def test_hook_error_response_hides_exception_text(self): + response = _request_hook_error_response( + HookExecutionError("traceback: provider token leaked in hook output") + ) + + assert response.status_code == 500 + assert b"traceback" not in response.body + assert b"provider token leaked" not in response.body + assert b"Request hook processing failed" in response.body + + def test_invalid_request_response_hides_exception_text(self): + response = _invalid_request_response( + "Invalid image generation request", + exc=ValueError("prompt missing: user payload echoed"), + ) + + assert response.status_code == 400 + assert b"prompt missing" not in response.body + assert b"user payload echoed" not in response.body + assert b"Invalid image generation request" in response.body + @pytest.mark.asyncio async def test_invalid_hook_outputs_are_sanitized(self, monkeypatch): def _unsafe_hook(_context):