Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
110 changes: 109 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,113 @@ jobs:
--type spdxjson \
ghcr.io/${{ github.repository }}/api-proxy@${{ steps.build_api_proxy.outputs.digest }}

build-router:
name: Build Router Image
runs-on: ubuntu-latest
needs: bump-version
outputs:
digest: ${{ steps.build_router.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
ref: ${{ needs.bump-version.outputs.version }}

# The image version label is the router's own __version__ (the Dockerfile
# default), not the AWF version, so VERSION is deliberately not passed.
# VCS_REF is the checked-out tag commit, not github.sha: bump-version
# creates a tag-only version commit on top of the dispatched commit.
- name: Compute router build arguments
id: router_args
run: |
set -euo pipefail
echo "vcs_ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "openapi_sha256=$(sha256sum containers/router/openapi.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"

- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: arm64

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

- name: Build and push Router image
id: build_router
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: ./containers/router
push: true
pull: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}/router:${{ needs.bump-version.outputs.version_number }}
ghcr.io/${{ github.repository }}/router:latest
build-args: |
VCS_REF=${{ steps.router_args.outputs.vcs_ref }}
OPENAPI_SHA256=${{ steps.router_args.outputs.openapi_sha256 }}
cache-from: type=gha,scope=router
cache-to: type=gha,mode=max,scope=router

- name: Verify Router image labels and platforms
env:
REFERENCE: ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }}
EXPECTED_SOURCE: ${{ github.server_url }}/${{ github.repository }}
EXPECTED_REVISION: ${{ steps.router_args.outputs.vcs_ref }}
EXPECTED_OPENAPI_SHA256: ${{ steps.router_args.outputs.openapi_sha256 }}
run: |
set -euo pipefail
docker pull --quiet "$REFERENCE"
labels=$(docker image inspect "$REFERENCE" --format '{{json .Config.Labels}}')
require() {
value=$(jq -r --arg key "$1" '.[$key] // ""' <<<"$labels")
if [[ "$value" != "$2" ]]; then
echo "::error::Router label $1 is '$value', expected '$2'"
exit 1
fi
}
require org.opencontainers.image.source "$EXPECTED_SOURCE"
require org.opencontainers.image.revision "$EXPECTED_REVISION"
require io.github.gh-aw-router.openapi-sha256 "$EXPECTED_OPENAPI_SHA256"
version=$(jq -r '.["org.opencontainers.image.version"] // ""' <<<"$labels")
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "::error::Router version label is '$version', expected a semantic version"
exit 1
fi
docker buildx imagetools inspect "$REFERENCE" --format '{{json .Manifest}}' \
| jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | index("linux/amd64") and index("linux/arm64")' > /dev/null \
|| { echo "::error::Router image must include linux/amd64 and linux/arm64"; exit 1; }
echo "Router ${version} at ${REFERENCE}"

- name: Sign Router image with cosign
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }}

- name: Generate SBOM for Router image
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }}
format: spdx-json
output-file: router-sbom.spdx.json

- name: Attest SBOM for Router image
run: |
cosign attest --yes \
--predicate router-sbom.spdx.json \
--type spdxjson \
ghcr.io/${{ github.repository }}/router@${{ steps.build_router.outputs.digest }}

build-cli-proxy:
name: Build CLI Proxy Image
runs-on: ubuntu-latest
Expand Down Expand Up @@ -722,7 +829,7 @@ jobs:
release:
name: Create Release
runs-on: ubuntu-latest
needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts, build-nvx-test-artifacts]
needs: [bump-version, build-squid, build-agent, build-api-proxy, build-router, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts, build-nvx-test-artifacts]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
Expand Down Expand Up @@ -876,6 +983,7 @@ jobs:
"ghcr.io/${{ github.repository }}/agent@${{ needs['build-agent'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/agent-act@${{ needs['build-agent-act'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/api-proxy@${{ needs['build-api-proxy'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/router@${{ needs['build-router'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \
"ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-enclaves'].outputs.enclave_script_digest }}" \
"ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-enclaves'].outputs.enclave_agent_digest }}" \
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/supply-chain-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ name: Supply Chain Scan
#
# Blocking vs. report-only:
# * BLOCKING gate = "Build and scan PR container images". It rebuilds the
# first-party images (agent, api-proxy, cli-proxy, gh-aw-node) from the
# first-party images (agent, api-proxy, router, cli-proxy, gh-aw-node) from the
# Dockerfiles in the PR and fails on any High/Critical CVE. This is the
# only step that reflects the changes in a PR, so it is the one that gates merges.
# * REPORT-ONLY = the Grype and Grant scans of the `gh-aw-manifest` digests.
Expand Down Expand Up @@ -197,12 +197,16 @@ jobs:
GRYPE_DB: ${{ runner.temp }}/grype-db
run: |
python3 - <<'PYEOF'
import os, subprocess, sys
import hashlib, os, subprocess, sys
cwd = os.getcwd()
db_cache = os.environ['GRYPE_DB']
# The router build requires the SHA-256 of its OpenAPI contract.
with open('containers/router/openapi.yaml', 'rb') as f:
router_openapi_sha256 = hashlib.sha256(f.read()).hexdigest()
containers = [
('agent', 'containers/agent', []),
('api-proxy', 'containers/api-proxy', []),
('router', 'containers/router', ['--build-arg', f'OPENAPI_SHA256={router_openapi_sha256}']),
('cli-proxy', 'containers/cli-proxy', []),
('gh-aw-node', 'containers/gh-aw-node', []),
('enclave-script', 'containers', ['-f', 'containers/enclave/Dockerfile', '--target', 'enclave-script']),
Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ runs:
AGENT_DIGEST="$(extract_digest agent || true)"
AGENT_ACT_DIGEST="$(extract_digest agent-act || true)"
API_PROXY_DIGEST="$(extract_digest api-proxy || true)"
ROUTER_DIGEST="$(extract_digest router || true)"
CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)"
ENCLAVE_SCRIPT_DIGEST="$(extract_digest enclave-script || true)"
ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)"
Expand All @@ -148,6 +149,7 @@ runs:
[ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}")
[ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}")
[ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}")
[ -n "${ROUTER_DIGEST:-}" ] && DIGEST_ENTRIES+=("router=${ROUTER_DIGEST}")
[ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}")
[ -n "${ENCLAVE_SCRIPT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-script=${ENCLAVE_SCRIPT_DIGEST}")
[ -n "${ENCLAVE_AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-agent=${ENCLAVE_AGENT_DIGEST}")
Expand Down
7 changes: 4 additions & 3 deletions containers/api-proxy/fixtures/routing-corpus.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use strict';

// Responses recorded from githubnext/gh-aw-router. Refreshing means taking a new
// export from that repo; never edit a case to make a test pass. A case names the
// routing tables it was recorded against, and that repo owns those tables.
// Responses recorded from the router in containers/router. Refreshing means taking a
// new export with its contract exporter (see containers/router/README.md); never edit
// a case to make a test pass. A case names the routing tables it was recorded
// against, and containers/router owns those tables.

const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
Expand Down
19 changes: 9 additions & 10 deletions containers/router/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1.7

FROM python:3.12.15-slim-trixie@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
# Alpine, like the other AWF images: Debian slim carries High CVEs that Debian won't fix,
# which the blocking supply-chain scan rejects.
FROM python:3.12.15-alpine3.24@sha256:1b668429b3511ab407d8e00648891631b0b1a4d7e15e3ca70f38ab5b91ad4ab4

ARG VERSION=0.1.3
ARG VCS_REF=unknown
Expand All @@ -20,14 +22,11 @@ ENV HOME=/home/gh-aw-router \
PYTHONUNBUFFERED=1 \
GH_AW_ROUTER_ROUTING_TABLES=/routing

RUN apt-get update \
&& apt-get upgrade --yes --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 gh-aw-router \
&& useradd --system --uid 10001 --gid gh-aw-router \
--create-home --home-dir /home/gh-aw-router \
--shell /usr/sbin/nologin gh-aw-router \
&& mkdir --parents /app/src /routing
RUN apk upgrade --no-cache \
&& addgroup -S -g 10001 gh-aw-router \
&& adduser -S -D -u 10001 -G gh-aw-router \
-h /home/gh-aw-router -s /sbin/nologin gh-aw-router \
&& mkdir -p /app/src /routing

COPY requirements.lock /tmp/requirements.lock
RUN python -m pip install \
Expand All @@ -43,7 +42,7 @@ RUN python -c "import sys; from gh_aw_router import __version__; sys.exit(0 if s
COPY routing /routing
# Fails the build unless the label matches the shipped contract, so the label cannot drift from it.
COPY openapi.yaml /app/openapi.yaml
RUN printf '%s /app/openapi.yaml\n' "${OPENAPI_SHA256}" | sha256sum --check --strict --quiet
RUN printf '%s /app/openapi.yaml\n' "${OPENAPI_SHA256}" | sha256sum -c -s

RUN find /app /routing -type d -exec chmod 0555 {} + \
&& find /app /routing -type f -exec chmod 0444 {} +
Expand Down
26 changes: 19 additions & 7 deletions containers/router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ docker build \
The optional `VERSION` build argument must match the package version. A mismatch fails
the build rather than publishing image metadata that disagrees with the CLI or service.

Each AWF release publishes the image for `linux/amd64` and `linux/arm64` as
`ghcr.io/github/gh-aw-firewall/router:<awf-version>`, signed with cosign and with an attested
SPDX SBOM. The tag is the AWF version, while the image's version label stays the package version.

The image bundles every published table under `/routing` and serves all six profiles. It runs as
user `10001:10001` and needs no credentials or outbound network. This invocation uses a private
network, a read-only filesystem, and no published host port. The service listens on port `8737`
Expand Down Expand Up @@ -305,11 +309,12 @@ OpenAPI text uses LF in the archive, while the external attachment retains its o
Ordinary PR CI runs corpus, packaging, and native Linux amd64 Docker checks without
provider credentials. Use the exporter above to create a local contract archive.

Publication requires a separate reviewed source and registry authorization. A deployment pin
has the form `<approved-registry>/<repository>:<reviewed-tag>@sha256:<manifest-digest>`.
AWF's release workflow, `.github/workflows/release.yml`, publishes the image together with the
other AWF images and lists its digest in the release's `containers.txt`. A deployment pin has
the form `ghcr.io/github/gh-aw-firewall/router:<awf-version>@sha256:<manifest-digest>`.
A local image ID or Docker archive checksum is not that registry manifest digest. Retain
supported immutable images and matching contract archives. Deliver security fixes through
supported release updates and tested client pin changes, not replacement bytes under old pins.
new AWF releases and tested client pin changes, not replacement bytes under old pins.

## Development

Expand Down Expand Up @@ -373,7 +378,7 @@ Use the shared synthetic table and request fixtures in `tests/conftest.py` for g
service, and transport tests. Reserve the bundled-table `service` fixture for release-data and
example integration checks so ranking refreshes do not affect unrelated behavior tests.

The HTTP contract follows the router release. There is no API-version request field or
The HTTP contract follows the router version. There is no API-version request field or
negotiation layer. Review breaking changes with affected consumers, include migration notes,
and test the client, immutable image, OpenAPI document, and corpus as one release combination.
Keep the table format marker because files and mounts can change independently of the image.
Expand All @@ -386,8 +391,14 @@ must not derive expectations from the current service. Review classifier prompt
contract fixture changes. Invalid non-null classifier output belongs in rejection cases;
caller-authorized degradation uses omitted or null classification.

Router changes ship in the next AWF release. Bump the router version in the same pull request
as any change to routing behaviour or the HTTP contract. The version appears in
`pyproject.toml`, `uv.lock`, `src/gh_aw_router/__init__.py`, the Dockerfile's `VERSION`
default, and `openapi.yaml`.

Keep release publication and credentialed attestations in separately reviewed,
explicitly permissioned jobs. Do not execute untrusted PR code through `pull_request_target`.
explicitly permissioned jobs, which today means AWF's `.github/workflows/release.yml`.
Do not execute untrusted PR code through `pull_request_target`.

### Dependencies and routing data

Expand Down Expand Up @@ -427,6 +438,7 @@ Request size and deadline limits are not a substitute for network isolation, con
or proxy timeouts. Context-window estimation is approximate. Frozen Python models do not make
nested containers deeply immutable, so in-process callers must not mutate shared routing data.

Report vulnerabilities through the repository's
[security policy](https://github.com/github/gh-aw-firewall/security/policy). The code is licensed
Supported router versions follow AWF releases. Report vulnerabilities through the repository's
[security policy](https://github.com/github/gh-aw-firewall/security/policy), and include the AWF
version and the router version that `GET /capabilities` reports. The code is licensed
under [MIT](https://github.com/github/gh-aw-firewall/blob/main/LICENSE).
7 changes: 4 additions & 3 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2261,9 +2261,10 @@ require the native `api.openai.com` or `api.anthropic.com` target; AWF does not
route custom gateways or translate or forward requests across provider
boundaries. A supported routed run also requires a complete
`container.images` manifest containing digest-pinned references for `router`
and every other enabled image role. The legacy `latest` router default is kept
only for resolver compatibility and is not a supported tag-only routed
configuration.
and every other enabled image role. Without a manifest, the router image
resolves like the other AWF images, from `--image-registry` and `--image-tag`
(`ghcr.io/github/gh-aw-firewall/router:<tag>` by default), and routing still
fails unless that reference is pinned by digest.

The candidate pool uses models discovered for the selected native provider.
The `/reflect` `model_api_mapping` includes maintained routing metadata for
Expand Down
7 changes: 6 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Once the workflow completes:
- `squid:<version>` and `squid:latest`
- `agent:<version>` and `agent:latest`
- `api-proxy:<version>` and `api-proxy:latest`
- `router:<version>` and `router:latest`
- `cli-proxy:<version>` and `cli-proxy:latest`
- `agent-act:<version>` and `agent-act:latest` (GitHub Actions parity image)
- `enclave-script:<version>` and `enclave-script:latest`
Expand Down Expand Up @@ -142,6 +143,7 @@ Docker images are published to `ghcr.io/github/gh-aw-firewall`:
- `squid:<version>` and `squid:latest` - Squid proxy container
- `agent:<version>` and `agent:latest` - Agent execution environment (minimal, ~200MB)
- `api-proxy:<version>` and `api-proxy:latest` - API proxy sidecar for credential isolation
- `router:<version>` and `router:latest` - Model router sidecar for routed runs, built from `containers/router`. Its `org.opencontainers.image.version` label is the router's own version (`__version__` in `containers/router/src/gh_aw_router/__init__.py`), not the AWF version, so the release doesn't pass a `VERSION` build argument
- `cli-proxy:<version>` and `cli-proxy:latest` - CLI proxy sidecar for gh CLI access via mcpg DIFC proxy
- `agent-act:<version>` and `agent-act:latest` - Agent with GitHub Actions parity (~2GB)
- `enclave-script:<version>` and `enclave-script:latest` - No-network script executor, built from `containers/enclave/Dockerfile` target `enclave-script`
Expand Down Expand Up @@ -180,6 +182,9 @@ pkg . --targets node18-linux-x64 --output release/awf
docker build -t awf-test/squid:local ./containers/squid
docker build -t awf-test/agent:local ./containers/agent
docker build -t awf-test/api-proxy:local ./containers/api-proxy
docker build -t awf-test/router:local \
--build-arg OPENAPI_SHA256="$(sha256sum containers/router/openapi.yaml | cut -d ' ' -f 1)" \
./containers/router
docker build -t awf-test/cli-proxy:local ./containers/cli-proxy

# Test with local images
Expand Down Expand Up @@ -222,7 +227,7 @@ If users report that Docker images can't be pulled:

To make packages public:
1. Go to repository **Packages** page
2. Click on the package (squid, agent, api-proxy, cli-proxy, or agent-act)
2. Click on the package (squid, agent, api-proxy, router, cli-proxy, or agent-act)
3. Go to **Package settings**
4. Change visibility to **Public**

Expand Down
Loading
Loading